diff --git a/Campus-iOS.xcodeproj/project.pbxproj b/Campus-iOS.xcodeproj/project.pbxproj index 38718604..fe4bbe91 100644 --- a/Campus-iOS.xcodeproj/project.pbxproj +++ b/Campus-iOS.xcodeproj/project.pbxproj @@ -32,7 +32,6 @@ 08DFB97528664CFC00E357DF /* TuitionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DFB97428664CFC00E357DF /* TuitionDetailsView.swift */; }; 08DFB9772866506900E357DF /* WidgetFrameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DFB9762866506900E357DF /* WidgetFrameView.swift */; }; 08DFB97928666AD900E357DF /* CafeteriaWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DFB97828666AD900E357DF /* CafeteriaWidgetView.swift */; }; - 08DFB97D2867800C00E357DF /* CafeteriaWidgetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DFB97C2867800C00E357DF /* CafeteriaWidgetViewModel.swift */; }; 08DFB97F2867AC9200E357DF /* StudyRoomWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DFB97E2867AC9200E357DF /* StudyRoomWidgetView.swift */; }; 08DFB9812867ACB600E357DF /* StudyRoomWidgetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DFB9802867ACB600E357DF /* StudyRoomWidgetViewModel.swift */; }; 08FAFD15287DC484006A0E27 /* CalendarWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08FAFD14287DC484006A0E27 /* CalendarWidgetView.swift */; }; @@ -46,6 +45,30 @@ 08FAFD292898B6C8006A0E27 /* SpatioTemporalStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08FAFD282898B6C8006A0E27 /* SpatioTemporalStrategy.swift */; }; 100803462764E2C50013ED0E /* ProfileToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 100803452764E2C50013ED0E /* ProfileToolbar.swift */; }; 100803482764E37A0013ED0E /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 100803472764E37A0013ED0E /* ProfileView.swift */; }; + 1F04F16E297A9A700085F273 /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F16D297A9A700085F273 /* CalendarService.swift */; }; + 1F04F171297AA5F40085F273 /* Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F170297AA5F30085F273 /* Service.swift */; }; + 1F04F173297AD41B0085F273 /* CalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F172297AD41B0085F273 /* CalendarEvent.swift */; }; + 1F04F175297AD4280085F273 /* CalendarScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F174297AD4280085F273 /* CalendarScreen.swift */; }; + 1F04F179297AED150085F273 /* LectureSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F178297AED150085F273 /* LectureSearchService.swift */; }; + 1F04F17F297BDF1E0085F273 /* PersonSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F17E297BDF1E0085F273 /* PersonSearchService.swift */; }; + 1F04F183297C3EF70085F273 /* PersonDetailedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F182297C3EF70085F273 /* PersonDetailedScreen.swift */; }; + 1F04F185297C3F990085F273 /* PersonDetailedService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F184297C3F990085F273 /* PersonDetailedService.swift */; }; + 1F04F18A297C85120085F273 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F189297C85120085F273 /* Token.swift */; }; + 1F04F18C297C85190085F273 /* Confirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F04F18B297C85190085F273 /* Confirmation.swift */; }; + 1F183A172979D19000B5D22D /* APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F183A162979D19000B5D22D /* APIError.swift */; }; + 1F189E8D29968CE50056BBD8 /* TUMOnlineAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E8C29968CE50056BBD8 /* TUMOnlineAPI.swift */; }; + 1F189E8F29968CFC0056BBD8 /* TUMCabeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E8E29968CFC0056BBD8 /* TUMCabeAPI.swift */; }; + 1F189E9129968D130056BBD8 /* EatAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E9029968D130056BBD8 /* EatAPI.swift */; }; + 1F189E9329968D260056BBD8 /* TUMDevAppAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E9229968D260056BBD8 /* TUMDevAppAPI.swift */; }; + 1F189E9529968D330056BBD8 /* TUMSexyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E9429968D330056BBD8 /* TUMSexyAPI.swift */; }; + 1F189E9729968D490056BBD8 /* MVGAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E9629968D490056BBD8 /* MVGAPI.swift */; }; + 1F189E9A29968D790056BBD8 /* TUMOnlineAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E9929968D790056BBD8 /* TUMOnlineAPIError.swift */; }; + 1F189E9C29968D880056BBD8 /* TUMCabeAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E9B29968D880056BBD8 /* TUMCabeAPIError.swift */; }; + 1F189E9E29968D9B0056BBD8 /* EatAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E9D29968D9B0056BBD8 /* EatAPIError.swift */; }; + 1F189EA029968DA90056BBD8 /* TUMDevAppAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189E9F29968DA90056BBD8 /* TUMDevAppAPIError.swift */; }; + 1F189EA229968DB90056BBD8 /* TUMSexyAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189EA129968DB90056BBD8 /* TUMSexyAPIError.swift */; }; + 1F189EA429968DE60056BBD8 /* MVGAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189EA329968DE60056BBD8 /* MVGAPIError.swift */; }; + 1F189EA729968E5C0056BBD8 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F189EA629968E5C0056BBD8 /* API.swift */; }; 1F2068DC28FD6E2800DBDF67 /* LoginViewModel+LoginState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2068DB28FD6E2800DBDF67 /* LoginViewModel+LoginState.swift */; }; 1F2068DE28FD731200DBDF67 /* LoginViewModel+TokenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2068DD28FD731200DBDF67 /* LoginViewModel+TokenState.swift */; }; 1F2068E228FD73C400DBDF67 /* TokenPermissionsViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2068E128FD73C400DBDF67 /* TokenPermissionsViewModel+State.swift */; }; @@ -58,10 +81,50 @@ 1F54244F285CA059008363BC /* token-tutorial.mov in Resources */ = {isa = PBXBuildFile; fileRef = 1F54244E285CA059008363BC /* token-tutorial.mov */; }; 1F542450285CA059008363BC /* token-tutorial.mov in Resources */ = {isa = PBXBuildFile; fileRef = 1F54244E285CA059008363BC /* token-tutorial.mov */; }; 1F542451285CA059008363BC /* token-tutorial.mov in Resources */ = {isa = PBXBuildFile; fileRef = 1F54244E285CA059008363BC /* token-tutorial.mov */; }; + 1F69CE35297DB732005032CE /* NewsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE34297DB732005032CE /* NewsService.swift */; }; + 1F69CE37297DCA22005032CE /* NewsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE36297DCA22005032CE /* NewsScreen.swift */; }; + 1F69CE3C297DCC12005032CE /* MoviesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE3B297DCC12005032CE /* MoviesScreen.swift */; }; + 1F69CE3E297DCC19005032CE /* MovieService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE3D297DCC19005032CE /* MovieService.swift */; }; + 1F69CE40297DDCD3005032CE /* StudyRoomDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE3F297DDCD3005032CE /* StudyRoomDetailsScreen.swift */; }; + 1F69CE42297EC94E005032CE /* DishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE41297EC94E005032CE /* DishService.swift */; }; + 1F69CE44297EC97E005032CE /* DishViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE43297EC97E005032CE /* DishViewModel.swift */; }; + 1F69CE46297EC99D005032CE /* DishView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE45297EC99D005032CE /* DishView.swift */; }; + 1F69CE48297EDEA3005032CE /* TUMSexyScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE47297EDEA3005032CE /* TUMSexyScreen.swift */; }; + 1F69CE4B297EDEC7005032CE /* TUMSexyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE4A297EDEC7005032CE /* TUMSexyService.swift */; }; + 1F69CE4E297EDF12005032CE /* TUMSexyLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F69CE4D297EDF12005032CE /* TUMSexyLink.swift */; }; + 1F71E80329E4611000379428 /* NavigaTumRoomFinderMaps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7F629E4611000379428 /* NavigaTumRoomFinderMaps.swift */; }; + 1F71E80429E4611000379428 /* NavigaTumNavigationCoordinates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7F729E4611000379428 /* NavigaTumNavigationCoordinates.swift */; }; + 1F71E80529E4611000379428 /* NavigaTumOverlaysMaps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7F829E4611000379428 /* NavigaTumOverlaysMaps.swift */; }; + 1F71E80629E4611000379428 /* NavigaTumNavigationMaps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7F929E4611000379428 /* NavigaTumNavigationMaps.swift */; }; + 1F71E80729E4611000379428 /* NavigaTumNavigationAdditionalProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7FA29E4611000379428 /* NavigaTumNavigationAdditionalProperties.swift */; }; + 1F71E80829E4611000379428 /* NavigaTumOverlayMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7FB29E4611000379428 /* NavigaTumOverlayMap.swift */; }; + 1F71E80929E4611000379428 /* NavigaTumNavigationDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7FC29E4611000379428 /* NavigaTumNavigationDetails.swift */; }; + 1F71E80A29E4611000379428 /* NavigaTumSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7FE29E4611000379428 /* NavigaTumSearchResponse.swift */; }; + 1F71E80B29E4611000379428 /* NavigaTumSearchResponseSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E7FF29E4611000379428 /* NavigaTumSearchResponseSection.swift */; }; + 1F71E80C29E4611000379428 /* NavigaTumNavigationEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E80029E4611000379428 /* NavigaTumNavigationEntity.swift */; }; + 1F71E80D29E4611000379428 /* NavigaTumNavigationProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E80129E4611000379428 /* NavigaTumNavigationProperty.swift */; }; + 1F71E80E29E4611000379428 /* NavigaTumRoomFinderMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E80229E4611000379428 /* NavigaTumRoomFinderMap.swift */; }; + 1F71E81629E4611E00379428 /* RoomFinderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E81529E4611E00379428 /* RoomFinderService.swift */; }; + 1F71E81929E4613500379428 /* NavigaTumDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E81729E4613500379428 /* NavigaTumDetailsViewModel.swift */; }; + 1F71E81A29E4613500379428 /* NavigaTumViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E81829E4613500379428 /* NavigaTumViewModel.swift */; }; + 1F71E82229E4613F00379428 /* NavigaTumDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E81C29E4613F00379428 /* NavigaTumDetailsView.swift */; }; + 1F71E82329E4613F00379428 /* NavigaTumDetailsBaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E81D29E4613F00379428 /* NavigaTumDetailsBaseView.swift */; }; + 1F71E82429E4613F00379428 /* NavigaTumMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E81E29E4613F00379428 /* NavigaTumMapView.swift */; }; + 1F71E82529E4613F00379428 /* NavigaTumMapImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E81F29E4613F00379428 /* NavigaTumMapImagesView.swift */; }; + 1F71E82629E4613F00379428 /* NavigaTumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E82029E4613F00379428 /* NavigaTumView.swift */; }; + 1F71E82729E4613F00379428 /* NavigaTumListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E82129E4613F00379428 /* NavigaTumListView.swift */; }; + 1F71E82D29E464C400379428 /* CafeteriaWidgetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E82C29E464C400379428 /* CafeteriaWidgetViewModel.swift */; }; + 1F71E82F29E4667C00379428 /* NavigaTUMAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E82E29E4667C00379428 /* NavigaTUMAPI.swift */; }; + 1F71E83129E46A1000379428 /* NavigaTUMAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71E83029E46A1000379428 /* NavigaTUMAPIError.swift */; }; + 1FA538EE297560CD004C70A8 /* MainAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA538ED297560CD004C70A8 /* MainAPI.swift */; }; + 1FACF3F92996A49300A0B8AC /* TUMDevAppAPIOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FACF3F82996A49300A0B8AC /* TUMDevAppAPIOld.swift */; }; + 1FACF3FB2996A65700A0B8AC /* MealPlanService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FACF3FA2996A65700A0B8AC /* MealPlanService.swift */; }; + 1FACF3FD2996E34200A0B8AC /* MealPlanScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FACF3FC2996E34200A0B8AC /* MealPlanScreen.swift */; }; 1FAF9F0C284D2ABC000ABE93 /* MapScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAF9F0B284D2ABC000ABE93 /* MapScreenView.swift */; }; 1FB82E3428F95776007B1858 /* TokenPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB82E3328F95776007B1858 /* TokenPermissionsView.swift */; }; 1FB82E3628F96C9E007B1858 /* TokenPermissionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB82E3528F96C9E007B1858 /* TokenPermissionsViewModel.swift */; }; 1FBFA168285E5B2D00FC1515 /* PanelContentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBFA167285E5B2D00FC1515 /* PanelContentListView.swift */; }; + 1FFF9AC6297D31830098E874 /* ProfileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFF9AC5297D31830098E874 /* ProfileService.swift */; }; 226CB51E2798DF9C0043ABCA /* Snap in Frameworks */ = {isa = PBXBuildFile; productRef = 226CB51D2798DF9C0043ABCA /* Snap */; settings = {ATTRIBUTES = (Required, ); }; }; 2F1B2B8528652FC90023BD9A /* MovieDetailsBasicInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F1B2B8428652FC90023BD9A /* MovieDetailsBasicInfoView.swift */; }; 2F1B2B87286530120023BD9A /* MovieDetailsBasicInfoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F1B2B86286530120023BD9A /* MovieDetailsBasicInfoRowView.swift */; }; @@ -91,13 +154,11 @@ 36108BE127A304B5007DC62D /* Cafeteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BCD27A304B5007DC62D /* Cafeteria.swift */; }; 36108BE227A304B5007DC62D /* MensaMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BCE27A304B5007DC62D /* MensaMenu.swift */; }; 36108BE327A304B5007DC62D /* DishLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BCF27A304B5007DC62D /* DishLabel.swift */; }; - 36108BE427A304B5007DC62D /* MensaEnumService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BD027A304B5007DC62D /* MensaEnumService.swift */; }; 36108BE527A304B5007DC62D /* MealPlanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BD227A304B5007DC62D /* MealPlanView.swift */; }; 36108BE627A304B5007DC62D /* MealPlanViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BD327A304B5007DC62D /* MealPlanViewModel.swift */; }; 36108BE727A304B5007DC62D /* MenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BD427A304B5007DC62D /* MenuViewModel.swift */; }; 36108BE927A304B5007DC62D /* MenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BD627A304B5007DC62D /* MenuView.swift */; }; 36108BEB27A304B6007DC62D /* CafeteriaRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BD927A304B5007DC62D /* CafeteriaRowView.swift */; }; - 36108BED27A304B6007DC62D /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BDB27A304B5007DC62D /* MapView.swift */; }; 36108BEF27A304B6007DC62D /* MapContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BDD27A304B5007DC62D /* MapContentView.swift */; }; 36108BF027A304B6007DC62D /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BDE27A304B5007DC62D /* PanelContentView.swift */; }; 36108BFA27A30517007DC62D /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36108BF327A30516007DC62D /* Movie.swift */; }; @@ -137,7 +198,6 @@ 3654F364285168D2008AD5DC /* MapImagesHorizontalScrollingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F35E285168D2008AD5DC /* MapImagesHorizontalScrollingView.swift */; }; 3654F365285168D2008AD5DC /* RoomImageMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F35F285168D2008AD5DC /* RoomImageMapping.swift */; }; 3654F366285168D2008AD5DC /* StudyRoomAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F360285168D2008AD5DC /* StudyRoomAttribute.swift */; }; - 3654F368285169AC008AD5DC /* TUMDevAppAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F367285169AC008AD5DC /* TUMDevAppAPI.swift */; }; 3654F3762851710E008AD5DC /* RoomFinderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F36B2851710E008AD5DC /* RoomFinderViewModel.swift */; }; 3654F3772851710E008AD5DC /* RoomFinderMapViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F36C2851710E008AD5DC /* RoomFinderMapViewModel.swift */; }; 3654F3782851710E008AD5DC /* FoundRoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F36E2851710E008AD5DC /* FoundRoom.swift */; }; @@ -148,7 +208,7 @@ 3654F37D2851710E008AD5DC /* RoomFinderDetailsMapImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F3742851710E008AD5DC /* RoomFinderDetailsMapImagesView.swift */; }; 3654F37E2851710E008AD5DC /* RoomFinderDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F3752851710E008AD5DC /* RoomFinderDetailsView.swift */; }; 3654F38028517156008AD5DC /* ImageFullScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F37F28517156008AD5DC /* ImageFullScreenView.swift */; }; - 3654F38428517260008AD5DC /* StudyRoomVIewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F38328517260008AD5DC /* StudyRoomVIewModel.swift */; }; + 3654F38428517260008AD5DC /* StudyRoomViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F38328517260008AD5DC /* StudyRoomViewModel.swift */; }; 3654F38628517BB4008AD5DC /* CafeteriaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F38528517BB4008AD5DC /* CafeteriaView.swift */; }; 3654F388285185A4008AD5DC /* StudyRoomGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F387285185A4008AD5DC /* StudyRoomGroupView.swift */; }; 3654F38A28518640008AD5DC /* StudyRoomDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3654F38928518640008AD5DC /* StudyRoomDetailsView.swift */; }; @@ -171,20 +231,19 @@ 36AD5CF227B7FEAD00DAE143 /* TumCalendarStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CF127B7FEAD00DAE143 /* TumCalendarStyle.swift */; }; 36AD5CF427B8C83500DAE143 /* CalendarSingleEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CF327B8C83500DAE143 /* CalendarSingleEventView.swift */; }; 36AD5CF627B8D97500DAE143 /* LectureDetailsEventInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CF527B8D97500DAE143 /* LectureDetailsEventInfoView.swift */; }; - 36AD5CF827B96AD200DAE143 /* PersonSearchListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CF727B96AD200DAE143 /* PersonSearchListView.swift */; }; - 36AD5CFA27B9711B00DAE143 /* LectureSearchListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CF927B9711B00DAE143 /* LectureSearchListView.swift */; }; - 36AD5CFC27B974F100DAE143 /* ProfileMyTumSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CFB27B974F100DAE143 /* ProfileMyTumSection.swift */; }; + 36AD5CF827B96AD200DAE143 /* PersonSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CF727B96AD200DAE143 /* PersonSearchView.swift */; }; + 36AD5CFA27B9711B00DAE143 /* LectureSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CF927B9711B00DAE143 /* LectureSearchView.swift */; }; + 36AD5CFC27B974F100DAE143 /* TuitionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AD5CFB27B974F100DAE143 /* TuitionScreen.swift */; }; 36AD5CFE27BA064E00DAE143 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 36AD5CFD27BA064E00DAE143 /* GoogleService-Info.plist */; }; - 36AF61D827A2FD7800FEBD98 /* EntityImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61B927A2FD7700FEBD98 /* EntityImporter.swift */; }; - 36AF61D927A2FD7800FEBD98 /* TUMSexyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BB27A2FD7700FEBD98 /* TUMSexyAPI.swift */; }; - 36AF61DA27A2FD7800FEBD98 /* EatAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BC27A2FD7700FEBD98 /* EatAPI.swift */; }; - 36AF61DB27A2FD7800FEBD98 /* MVGAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BD27A2FD7700FEBD98 /* MVGAPI.swift */; }; - 36AF61DC27A2FD7800FEBD98 /* NetworkingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BE27A2FD7700FEBD98 /* NetworkingAPI.swift */; }; - 36AF61DD27A2FD7800FEBD98 /* CampusOnlineAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BF27A2FD7700FEBD98 /* CampusOnlineAPI.swift */; }; + 36AF61D927A2FD7800FEBD98 /* TUMSexyAPIOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BB27A2FD7700FEBD98 /* TUMSexyAPIOld.swift */; }; + 36AF61DA27A2FD7800FEBD98 /* EatAPIOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BC27A2FD7700FEBD98 /* EatAPIOld.swift */; }; + 36AF61DB27A2FD7800FEBD98 /* MVGAPIOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BD27A2FD7700FEBD98 /* MVGAPIOld.swift */; }; + 36AF61DC27A2FD7800FEBD98 /* NetworkingAPIOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BE27A2FD7700FEBD98 /* NetworkingAPIOld.swift */; }; + 36AF61DD27A2FD7800FEBD98 /* CampusOnlineAPIOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61BF27A2FD7700FEBD98 /* CampusOnlineAPIOld.swift */; }; 36AF61DE27A2FD7800FEBD98 /* APIResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C027A2FD7700FEBD98 /* APIResponse.swift */; }; - 36AF61DF27A2FD7800FEBD98 /* TUMOnlineAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C127A2FD7700FEBD98 /* TUMOnlineAPI.swift */; }; + 36AF61DF27A2FD7800FEBD98 /* TUMOnlineAPIOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C127A2FD7700FEBD98 /* TUMOnlineAPIOld.swift */; }; 36AF61E027A2FD7800FEBD98 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C227A2FD7700FEBD98 /* Cache.swift */; }; - 36AF61E127A2FD7800FEBD98 /* TUMCabeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C327A2FD7700FEBD98 /* TUMCabeAPI.swift */; }; + 36AF61E127A2FD7800FEBD98 /* TUMCabeAPIOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C327A2FD7700FEBD98 /* TUMCabeAPIOld.swift */; }; 36AF61E227A2FD7800FEBD98 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C527A2FD7700FEBD98 /* Constants.swift */; }; 36AF61E327A2FD7800FEBD98 /* APIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C627A2FD7700FEBD98 /* APIConstants.swift */; }; 36AF61E427A2FD7800FEBD98 /* NetworkingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36AF61C827A2FD7700FEBD98 /* NetworkingError.swift */; }; @@ -204,7 +263,7 @@ 36BB6F5327AFCCB500F224AB /* PersonDetailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F5227AFCCB500F224AB /* PersonDetailedView.swift */; }; 36BB6F6027AFCDFA00F224AB /* PersonSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F5B27AFCDF900F224AB /* PersonSearchViewModel.swift */; }; 36BB6F6127AFCDFA00F224AB /* Person.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F5D27AFCDFA00F224AB /* Person.swift */; }; - 36BB6F6227AFCDFA00F224AB /* PersonSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F5F27AFCDFA00F224AB /* PersonSearchView.swift */; }; + 36BB6F6227AFCDFA00F224AB /* PersonSearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F5F27AFCDFA00F224AB /* PersonSearchScreen.swift */; }; 36BB6F6427AFCFFB00F224AB /* PersonDetailedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F6327AFCFFB00F224AB /* PersonDetailedViewModel.swift */; }; 36BB6F6627AFD12B00F224AB /* PersonDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F6527AFD12B00F224AB /* PersonDetails.swift */; }; 36BB6F6827AFD26500F224AB /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F6727AFD26500F224AB /* Organization.swift */; }; @@ -215,11 +274,9 @@ 36BB6F7527B1D87200F224AB /* Tuition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F7427B1D87200F224AB /* Tuition.swift */; }; 36BB6F7927B26DE300F224AB /* TuitionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F7827B26DE300F224AB /* TuitionView.swift */; }; 36BB6F7B27B27D0D00F224AB /* TuitionCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F7A27B27D0D00F224AB /* TuitionCard.swift */; }; - 36BB6F7D27B356C200F224AB /* PersonDetailedCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F7C27B356C200F224AB /* PersonDetailedCellView.swift */; }; 36BB6F7F27B386D100F224AB /* AddToContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F7E27B386D100F224AB /* AddToContactsView.swift */; }; - 36BB6F8327B39B4300F224AB /* LectureSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F8227B39B4300F224AB /* LectureSearchView.swift */; }; + 36BB6F8327B39B4300F224AB /* LectureSearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F8227B39B4300F224AB /* LectureSearchScreen.swift */; }; 36BB6F8627B39C5300F224AB /* LectureSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F8527B39C5300F224AB /* LectureSearchViewModel.swift */; }; - 36BB6F8A27B3D21200F224AB /* CalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F8927B3D21200F224AB /* CalendarEvent.swift */; }; 36BB6F8D27B3F25A00F224AB /* NSMutableString+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BB6F8C27B3F25A00F224AB /* NSMutableString+Extensions.swift */; }; 36BBE72F27989F8C0018FD3F /* SFSafariViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BBE72E27989F8C0018FD3F /* SFSafariViewWrapper.swift */; }; 36BBE7322798AFE10018FD3F /* News.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BBE7312798AFE10018FD3F /* News.swift */; }; @@ -301,7 +358,6 @@ 08DFB97428664CFC00E357DF /* TuitionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuitionDetailsView.swift; sourceTree = ""; }; 08DFB9762866506900E357DF /* WidgetFrameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetFrameView.swift; sourceTree = ""; }; 08DFB97828666AD900E357DF /* CafeteriaWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CafeteriaWidgetView.swift; sourceTree = ""; }; - 08DFB97C2867800C00E357DF /* CafeteriaWidgetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CafeteriaWidgetViewModel.swift; sourceTree = ""; }; 08DFB97E2867AC9200E357DF /* StudyRoomWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyRoomWidgetView.swift; sourceTree = ""; }; 08DFB9802867ACB600E357DF /* StudyRoomWidgetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyRoomWidgetViewModel.swift; sourceTree = ""; }; 08FAFD14287DC484006A0E27 /* CalendarWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarWidgetView.swift; sourceTree = ""; }; @@ -315,6 +371,30 @@ 08FAFD282898B6C8006A0E27 /* SpatioTemporalStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpatioTemporalStrategy.swift; sourceTree = ""; }; 100803452764E2C50013ED0E /* ProfileToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileToolbar.swift; sourceTree = ""; }; 100803472764E37A0013ED0E /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + 1F04F16D297A9A700085F273 /* CalendarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarService.swift; sourceTree = ""; }; + 1F04F170297AA5F30085F273 /* Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; }; + 1F04F172297AD41B0085F273 /* CalendarEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalendarEvent.swift; sourceTree = ""; }; + 1F04F174297AD4280085F273 /* CalendarScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarScreen.swift; sourceTree = ""; }; + 1F04F178297AED150085F273 /* LectureSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureSearchService.swift; sourceTree = ""; }; + 1F04F17E297BDF1E0085F273 /* PersonSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonSearchService.swift; sourceTree = ""; }; + 1F04F182297C3EF70085F273 /* PersonDetailedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetailedScreen.swift; sourceTree = ""; }; + 1F04F184297C3F990085F273 /* PersonDetailedService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetailedService.swift; sourceTree = ""; }; + 1F04F189297C85120085F273 /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; + 1F04F18B297C85190085F273 /* Confirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Confirmation.swift; sourceTree = ""; }; + 1F183A162979D19000B5D22D /* APIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIError.swift; sourceTree = ""; }; + 1F189E8C29968CE50056BBD8 /* TUMOnlineAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMOnlineAPI.swift; sourceTree = ""; }; + 1F189E8E29968CFC0056BBD8 /* TUMCabeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMCabeAPI.swift; sourceTree = ""; }; + 1F189E9029968D130056BBD8 /* EatAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EatAPI.swift; sourceTree = ""; }; + 1F189E9229968D260056BBD8 /* TUMDevAppAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMDevAppAPI.swift; sourceTree = ""; }; + 1F189E9429968D330056BBD8 /* TUMSexyAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMSexyAPI.swift; sourceTree = ""; }; + 1F189E9629968D490056BBD8 /* MVGAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MVGAPI.swift; sourceTree = ""; }; + 1F189E9929968D790056BBD8 /* TUMOnlineAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMOnlineAPIError.swift; sourceTree = ""; }; + 1F189E9B29968D880056BBD8 /* TUMCabeAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMCabeAPIError.swift; sourceTree = ""; }; + 1F189E9D29968D9B0056BBD8 /* EatAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EatAPIError.swift; sourceTree = ""; }; + 1F189E9F29968DA90056BBD8 /* TUMDevAppAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMDevAppAPIError.swift; sourceTree = ""; }; + 1F189EA129968DB90056BBD8 /* TUMSexyAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMSexyAPIError.swift; sourceTree = ""; }; + 1F189EA329968DE60056BBD8 /* MVGAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MVGAPIError.swift; sourceTree = ""; }; + 1F189EA629968E5C0056BBD8 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; 1F2068DB28FD6E2800DBDF67 /* LoginViewModel+LoginState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoginViewModel+LoginState.swift"; sourceTree = ""; }; 1F2068DD28FD731200DBDF67 /* LoginViewModel+TokenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoginViewModel+TokenState.swift"; sourceTree = ""; }; 1F2068E128FD73C400DBDF67 /* TokenPermissionsViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TokenPermissionsViewModel+State.swift"; sourceTree = ""; }; @@ -325,10 +405,50 @@ 1F4C836628300E79006971C0 /* CafeteriasService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CafeteriasService.swift; sourceTree = ""; }; 1F4C926E2882FD84003DC7D7 /* RoundedCorners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCorners.swift; sourceTree = ""; }; 1F54244E285CA059008363BC /* token-tutorial.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = "token-tutorial.mov"; sourceTree = ""; }; + 1F69CE34297DB732005032CE /* NewsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsService.swift; sourceTree = ""; }; + 1F69CE36297DCA22005032CE /* NewsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsScreen.swift; sourceTree = ""; }; + 1F69CE3B297DCC12005032CE /* MoviesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesScreen.swift; sourceTree = ""; }; + 1F69CE3D297DCC19005032CE /* MovieService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieService.swift; sourceTree = ""; }; + 1F69CE3F297DDCD3005032CE /* StudyRoomDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyRoomDetailsScreen.swift; sourceTree = ""; }; + 1F69CE41297EC94E005032CE /* DishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishService.swift; sourceTree = ""; }; + 1F69CE43297EC97E005032CE /* DishViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishViewModel.swift; sourceTree = ""; }; + 1F69CE45297EC99D005032CE /* DishView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DishView.swift; sourceTree = ""; }; + 1F69CE47297EDEA3005032CE /* TUMSexyScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMSexyScreen.swift; sourceTree = ""; }; + 1F69CE4A297EDEC7005032CE /* TUMSexyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMSexyService.swift; sourceTree = ""; }; + 1F69CE4D297EDF12005032CE /* TUMSexyLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TUMSexyLink.swift; sourceTree = ""; }; + 1F71E7F629E4611000379428 /* NavigaTumRoomFinderMaps.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumRoomFinderMaps.swift; sourceTree = ""; }; + 1F71E7F729E4611000379428 /* NavigaTumNavigationCoordinates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumNavigationCoordinates.swift; sourceTree = ""; }; + 1F71E7F829E4611000379428 /* NavigaTumOverlaysMaps.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumOverlaysMaps.swift; sourceTree = ""; }; + 1F71E7F929E4611000379428 /* NavigaTumNavigationMaps.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumNavigationMaps.swift; sourceTree = ""; }; + 1F71E7FA29E4611000379428 /* NavigaTumNavigationAdditionalProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumNavigationAdditionalProperties.swift; sourceTree = ""; }; + 1F71E7FB29E4611000379428 /* NavigaTumOverlayMap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumOverlayMap.swift; sourceTree = ""; }; + 1F71E7FC29E4611000379428 /* NavigaTumNavigationDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumNavigationDetails.swift; sourceTree = ""; }; + 1F71E7FE29E4611000379428 /* NavigaTumSearchResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumSearchResponse.swift; sourceTree = ""; }; + 1F71E7FF29E4611000379428 /* NavigaTumSearchResponseSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumSearchResponseSection.swift; sourceTree = ""; }; + 1F71E80029E4611000379428 /* NavigaTumNavigationEntity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumNavigationEntity.swift; sourceTree = ""; }; + 1F71E80129E4611000379428 /* NavigaTumNavigationProperty.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumNavigationProperty.swift; sourceTree = ""; }; + 1F71E80229E4611000379428 /* NavigaTumRoomFinderMap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumRoomFinderMap.swift; sourceTree = ""; }; + 1F71E81529E4611E00379428 /* RoomFinderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomFinderService.swift; sourceTree = ""; }; + 1F71E81729E4613500379428 /* NavigaTumDetailsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumDetailsViewModel.swift; sourceTree = ""; }; + 1F71E81829E4613500379428 /* NavigaTumViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumViewModel.swift; sourceTree = ""; }; + 1F71E81C29E4613F00379428 /* NavigaTumDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumDetailsView.swift; sourceTree = ""; }; + 1F71E81D29E4613F00379428 /* NavigaTumDetailsBaseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumDetailsBaseView.swift; sourceTree = ""; }; + 1F71E81E29E4613F00379428 /* NavigaTumMapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumMapView.swift; sourceTree = ""; }; + 1F71E81F29E4613F00379428 /* NavigaTumMapImagesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumMapImagesView.swift; sourceTree = ""; }; + 1F71E82029E4613F00379428 /* NavigaTumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumView.swift; sourceTree = ""; }; + 1F71E82129E4613F00379428 /* NavigaTumListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigaTumListView.swift; sourceTree = ""; }; + 1F71E82C29E464C400379428 /* CafeteriaWidgetViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CafeteriaWidgetViewModel.swift; sourceTree = ""; }; + 1F71E82E29E4667C00379428 /* NavigaTUMAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigaTUMAPI.swift; sourceTree = ""; }; + 1F71E83029E46A1000379428 /* NavigaTUMAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigaTUMAPIError.swift; sourceTree = ""; }; + 1FA538ED297560CD004C70A8 /* MainAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainAPI.swift; sourceTree = ""; }; + 1FACF3F82996A49300A0B8AC /* TUMDevAppAPIOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TUMDevAppAPIOld.swift; sourceTree = ""; }; + 1FACF3FA2996A65700A0B8AC /* MealPlanService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanService.swift; sourceTree = ""; }; + 1FACF3FC2996E34200A0B8AC /* MealPlanScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanScreen.swift; sourceTree = ""; }; 1FAF9F0B284D2ABC000ABE93 /* MapScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapScreenView.swift; sourceTree = ""; }; 1FB82E3328F95776007B1858 /* TokenPermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenPermissionsView.swift; sourceTree = ""; }; 1FB82E3528F96C9E007B1858 /* TokenPermissionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenPermissionsViewModel.swift; sourceTree = ""; }; 1FBFA167285E5B2D00FC1515 /* PanelContentListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanelContentListView.swift; sourceTree = ""; }; + 1FFF9AC5297D31830098E874 /* ProfileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileService.swift; sourceTree = ""; }; 227FBB492762AC440062FEC3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 227FBB4A2762AC4C0062FEC3 /* Campus-iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Campus-iOS.entitlements"; sourceTree = ""; }; 256D0D4227D77A9C00F5EC38 /* MapViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewModel.swift; sourceTree = ""; }; @@ -360,13 +480,11 @@ 36108BCD27A304B5007DC62D /* Cafeteria.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cafeteria.swift; sourceTree = ""; }; 36108BCE27A304B5007DC62D /* MensaMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MensaMenu.swift; sourceTree = ""; }; 36108BCF27A304B5007DC62D /* DishLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DishLabel.swift; sourceTree = ""; }; - 36108BD027A304B5007DC62D /* MensaEnumService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MensaEnumService.swift; sourceTree = ""; }; 36108BD227A304B5007DC62D /* MealPlanView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MealPlanView.swift; sourceTree = ""; }; 36108BD327A304B5007DC62D /* MealPlanViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MealPlanViewModel.swift; sourceTree = ""; }; 36108BD427A304B5007DC62D /* MenuViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MenuViewModel.swift; sourceTree = ""; }; 36108BD627A304B5007DC62D /* MenuView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MenuView.swift; sourceTree = ""; }; 36108BD927A304B5007DC62D /* CafeteriaRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CafeteriaRowView.swift; sourceTree = ""; }; - 36108BDB27A304B5007DC62D /* MapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; 36108BDD27A304B5007DC62D /* MapContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapContentView.swift; sourceTree = ""; }; 36108BDE27A304B5007DC62D /* PanelContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PanelContentView.swift; sourceTree = ""; }; 36108BF327A30516007DC62D /* Movie.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Movie.swift; sourceTree = ""; }; @@ -406,7 +524,6 @@ 3654F35E285168D2008AD5DC /* MapImagesHorizontalScrollingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapImagesHorizontalScrollingView.swift; sourceTree = ""; }; 3654F35F285168D2008AD5DC /* RoomImageMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomImageMapping.swift; sourceTree = ""; }; 3654F360285168D2008AD5DC /* StudyRoomAttribute.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StudyRoomAttribute.swift; sourceTree = ""; }; - 3654F367285169AC008AD5DC /* TUMDevAppAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TUMDevAppAPI.swift; sourceTree = ""; }; 3654F36B2851710E008AD5DC /* RoomFinderViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomFinderViewModel.swift; sourceTree = ""; }; 3654F36C2851710E008AD5DC /* RoomFinderMapViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomFinderMapViewModel.swift; sourceTree = ""; }; 3654F36E2851710E008AD5DC /* FoundRoom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FoundRoom.swift; sourceTree = ""; }; @@ -417,7 +534,7 @@ 3654F3742851710E008AD5DC /* RoomFinderDetailsMapImagesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomFinderDetailsMapImagesView.swift; sourceTree = ""; }; 3654F3752851710E008AD5DC /* RoomFinderDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomFinderDetailsView.swift; sourceTree = ""; }; 3654F37F28517156008AD5DC /* ImageFullScreenView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageFullScreenView.swift; sourceTree = ""; }; - 3654F38328517260008AD5DC /* StudyRoomVIewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StudyRoomVIewModel.swift; sourceTree = ""; }; + 3654F38328517260008AD5DC /* StudyRoomViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StudyRoomViewModel.swift; sourceTree = ""; }; 3654F38528517BB4008AD5DC /* CafeteriaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CafeteriaView.swift; sourceTree = ""; }; 3654F387285185A4008AD5DC /* StudyRoomGroupView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StudyRoomGroupView.swift; sourceTree = ""; }; 3654F38928518640008AD5DC /* StudyRoomDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StudyRoomDetailsView.swift; sourceTree = ""; }; @@ -443,20 +560,19 @@ 36AD5CF127B7FEAD00DAE143 /* TumCalendarStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TumCalendarStyle.swift; sourceTree = ""; }; 36AD5CF327B8C83500DAE143 /* CalendarSingleEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSingleEventView.swift; sourceTree = ""; }; 36AD5CF527B8D97500DAE143 /* LectureDetailsEventInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureDetailsEventInfoView.swift; sourceTree = ""; }; - 36AD5CF727B96AD200DAE143 /* PersonSearchListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonSearchListView.swift; sourceTree = ""; }; - 36AD5CF927B9711B00DAE143 /* LectureSearchListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureSearchListView.swift; sourceTree = ""; }; - 36AD5CFB27B974F100DAE143 /* ProfileMyTumSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileMyTumSection.swift; sourceTree = ""; }; + 36AD5CF727B96AD200DAE143 /* PersonSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonSearchView.swift; sourceTree = ""; }; + 36AD5CF927B9711B00DAE143 /* LectureSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureSearchView.swift; sourceTree = ""; }; + 36AD5CFB27B974F100DAE143 /* TuitionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuitionScreen.swift; sourceTree = ""; }; 36AD5CFD27BA064E00DAE143 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; - 36AF61B927A2FD7700FEBD98 /* EntityImporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EntityImporter.swift; sourceTree = ""; }; - 36AF61BB27A2FD7700FEBD98 /* TUMSexyAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TUMSexyAPI.swift; sourceTree = ""; }; - 36AF61BC27A2FD7700FEBD98 /* EatAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EatAPI.swift; sourceTree = ""; }; - 36AF61BD27A2FD7700FEBD98 /* MVGAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVGAPI.swift; sourceTree = ""; }; - 36AF61BE27A2FD7700FEBD98 /* NetworkingAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkingAPI.swift; sourceTree = ""; }; - 36AF61BF27A2FD7700FEBD98 /* CampusOnlineAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CampusOnlineAPI.swift; sourceTree = ""; }; + 36AF61BB27A2FD7700FEBD98 /* TUMSexyAPIOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TUMSexyAPIOld.swift; sourceTree = ""; }; + 36AF61BC27A2FD7700FEBD98 /* EatAPIOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EatAPIOld.swift; sourceTree = ""; }; + 36AF61BD27A2FD7700FEBD98 /* MVGAPIOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVGAPIOld.swift; sourceTree = ""; }; + 36AF61BE27A2FD7700FEBD98 /* NetworkingAPIOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkingAPIOld.swift; sourceTree = ""; }; + 36AF61BF27A2FD7700FEBD98 /* CampusOnlineAPIOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CampusOnlineAPIOld.swift; sourceTree = ""; }; 36AF61C027A2FD7700FEBD98 /* APIResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIResponse.swift; sourceTree = ""; }; - 36AF61C127A2FD7700FEBD98 /* TUMOnlineAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TUMOnlineAPI.swift; sourceTree = ""; }; + 36AF61C127A2FD7700FEBD98 /* TUMOnlineAPIOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TUMOnlineAPIOld.swift; sourceTree = ""; }; 36AF61C227A2FD7700FEBD98 /* Cache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; - 36AF61C327A2FD7700FEBD98 /* TUMCabeAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TUMCabeAPI.swift; sourceTree = ""; }; + 36AF61C327A2FD7700FEBD98 /* TUMCabeAPIOld.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TUMCabeAPIOld.swift; sourceTree = ""; }; 36AF61C527A2FD7700FEBD98 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 36AF61C627A2FD7700FEBD98 /* APIConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIConstants.swift; sourceTree = ""; }; 36AF61C827A2FD7700FEBD98 /* NetworkingError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkingError.swift; sourceTree = ""; }; @@ -476,7 +592,7 @@ 36BB6F5227AFCCB500F224AB /* PersonDetailedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetailedView.swift; sourceTree = ""; }; 36BB6F5B27AFCDF900F224AB /* PersonSearchViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersonSearchViewModel.swift; sourceTree = ""; }; 36BB6F5D27AFCDFA00F224AB /* Person.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Person.swift; sourceTree = ""; }; - 36BB6F5F27AFCDFA00F224AB /* PersonSearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersonSearchView.swift; sourceTree = ""; }; + 36BB6F5F27AFCDFA00F224AB /* PersonSearchScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersonSearchScreen.swift; sourceTree = ""; }; 36BB6F6327AFCFFB00F224AB /* PersonDetailedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetailedViewModel.swift; sourceTree = ""; }; 36BB6F6527AFD12B00F224AB /* PersonDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetails.swift; sourceTree = ""; }; 36BB6F6727AFD26500F224AB /* Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Organization.swift; sourceTree = ""; }; @@ -487,11 +603,9 @@ 36BB6F7427B1D87200F224AB /* Tuition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tuition.swift; sourceTree = ""; }; 36BB6F7827B26DE300F224AB /* TuitionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuitionView.swift; sourceTree = ""; }; 36BB6F7A27B27D0D00F224AB /* TuitionCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuitionCard.swift; sourceTree = ""; }; - 36BB6F7C27B356C200F224AB /* PersonDetailedCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonDetailedCellView.swift; sourceTree = ""; }; 36BB6F7E27B386D100F224AB /* AddToContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToContactsView.swift; sourceTree = ""; }; - 36BB6F8227B39B4300F224AB /* LectureSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureSearchView.swift; sourceTree = ""; }; + 36BB6F8227B39B4300F224AB /* LectureSearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureSearchScreen.swift; sourceTree = ""; }; 36BB6F8527B39C5300F224AB /* LectureSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureSearchViewModel.swift; sourceTree = ""; }; - 36BB6F8927B3D21200F224AB /* CalendarEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CalendarEvent.swift; path = "Campus-iOS/CalendarComponent/Entity/CalendarEvent.swift"; sourceTree = SOURCE_ROOT; }; 36BB6F8C27B3F25A00F224AB /* NSMutableString+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMutableString+Extensions.swift"; sourceTree = ""; }; 36BBE72E27989F8C0018FD3F /* SFSafariViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSafariViewWrapper.swift; sourceTree = ""; }; 36BBE7312798AFE10018FD3F /* News.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = News.swift; sourceTree = ""; }; @@ -636,6 +750,7 @@ 100803442764E2A90013ED0E /* ProfileComponent */ = { isa = PBXGroup; children = ( + 1FFF9AC4297D317C0098E874 /* Service */, 36BB6F7127B1CD8100F224AB /* ViewModel */, 36BB6F6E27B1196300F224AB /* Entity */, 36BB6F6D27B1195600F224AB /* View */, @@ -643,12 +758,142 @@ path = ProfileComponent; sourceTree = ""; }; + 1F04F16F297AA1E00085F273 /* Old APIs */ = { + isa = PBXGroup; + children = ( + 36AF61C027A2FD7700FEBD98 /* APIResponse.swift */, + 36AF61BB27A2FD7700FEBD98 /* TUMSexyAPIOld.swift */, + 1FACF3F82996A49300A0B8AC /* TUMDevAppAPIOld.swift */, + 36AF61BC27A2FD7700FEBD98 /* EatAPIOld.swift */, + 36AF61BD27A2FD7700FEBD98 /* MVGAPIOld.swift */, + 36AF61BE27A2FD7700FEBD98 /* NetworkingAPIOld.swift */, + 36AF61BF27A2FD7700FEBD98 /* CampusOnlineAPIOld.swift */, + 36AF61C127A2FD7700FEBD98 /* TUMOnlineAPIOld.swift */, + 36AF61C327A2FD7700FEBD98 /* TUMCabeAPIOld.swift */, + ); + path = "Old APIs"; + sourceTree = ""; + }; + 1F04F176297AD42E0085F273 /* Screen */ = { + isa = PBXGroup; + children = ( + 1F04F174297AD4280085F273 /* CalendarScreen.swift */, + ); + path = Screen; + sourceTree = ""; + }; + 1F04F177297AD4350085F273 /* Service */ = { + isa = PBXGroup; + children = ( + 1F04F16D297A9A700085F273 /* CalendarService.swift */, + ); + path = Service; + sourceTree = ""; + }; + 1F04F17A297AED190085F273 /* Service */ = { + isa = PBXGroup; + children = ( + 1F04F178297AED150085F273 /* LectureSearchService.swift */, + ); + path = Service; + sourceTree = ""; + }; + 1F04F17B297B0BC30085F273 /* Screen */ = { + isa = PBXGroup; + children = ( + 36BB6F8227B39B4300F224AB /* LectureSearchScreen.swift */, + ); + path = Screen; + sourceTree = ""; + }; + 1F04F180297BDF250085F273 /* Service */ = { + isa = PBXGroup; + children = ( + 1F04F17E297BDF1E0085F273 /* PersonSearchService.swift */, + ); + path = Service; + sourceTree = ""; + }; + 1F04F181297C33860085F273 /* Screen */ = { + isa = PBXGroup; + children = ( + 36BB6F5F27AFCDFA00F224AB /* PersonSearchScreen.swift */, + ); + path = Screen; + sourceTree = ""; + }; + 1F04F186297C3FA20085F273 /* Service */ = { + isa = PBXGroup; + children = ( + 1F04F184297C3F990085F273 /* PersonDetailedService.swift */, + ); + path = Service; + sourceTree = ""; + }; + 1F04F187297C6BC40085F273 /* Screen */ = { + isa = PBXGroup; + children = ( + 1F04F182297C3EF70085F273 /* PersonDetailedScreen.swift */, + ); + path = Screen; + sourceTree = ""; + }; + 1F04F188297C85030085F273 /* Model */ = { + isa = PBXGroup; + children = ( + 36203E8A2761C6EC00C24658 /* Credentials.swift */, + 1F04F189297C85120085F273 /* Token.swift */, + 1F04F18B297C85190085F273 /* Confirmation.swift */, + ); + path = Model; + sourceTree = ""; + }; + 1F189E8B29968CD70056BBD8 /* APIs */ = { + isa = PBXGroup; + children = ( + 1F189E8C29968CE50056BBD8 /* TUMOnlineAPI.swift */, + 1F189E8E29968CFC0056BBD8 /* TUMCabeAPI.swift */, + 1F189E9029968D130056BBD8 /* EatAPI.swift */, + 1F189E9229968D260056BBD8 /* TUMDevAppAPI.swift */, + 1F189E9429968D330056BBD8 /* TUMSexyAPI.swift */, + 1F189E9629968D490056BBD8 /* MVGAPI.swift */, + 1F71E82E29E4667C00379428 /* NavigaTUMAPI.swift */, + ); + path = APIs; + sourceTree = ""; + }; + 1F189E9829968D620056BBD8 /* APIErrors */ = { + isa = PBXGroup; + children = ( + 1F189E9929968D790056BBD8 /* TUMOnlineAPIError.swift */, + 1F189E9B29968D880056BBD8 /* TUMCabeAPIError.swift */, + 1F189E9D29968D9B0056BBD8 /* EatAPIError.swift */, + 1F189E9F29968DA90056BBD8 /* TUMDevAppAPIError.swift */, + 1F189EA129968DB90056BBD8 /* TUMSexyAPIError.swift */, + 1F189EA329968DE60056BBD8 /* MVGAPIError.swift */, + 1F71E83029E46A1000379428 /* NavigaTUMAPIError.swift */, + ); + path = APIErrors; + sourceTree = ""; + }; + 1F189EA529968E150056BBD8 /* Protocols */ = { + isa = PBXGroup; + children = ( + 1FA538ED297560CD004C70A8 /* MainAPI.swift */, + 1F189EA629968E5C0056BBD8 /* API.swift */, + 1F183A162979D19000B5D22D /* APIError.swift */, + 1F04F170297AA5F30085F273 /* Service.swift */, + ); + path = Protocols; + sourceTree = ""; + }; 1F4C836528300E6F006971C0 /* Service */ = { isa = PBXGroup; children = ( - 36108BD027A304B5007DC62D /* MensaEnumService.swift */, 1F4C836628300E79006971C0 /* CafeteriasService.swift */, 3654F357285167C3008AD5DC /* StudyRoomsService.swift */, + 1F69CE41297EC94E005032CE /* DishService.swift */, + 1FACF3FA2996A65700A0B8AC /* MealPlanService.swift */, ); path = Service; sourceTree = ""; @@ -661,6 +906,118 @@ path = VideoAssets; sourceTree = ""; }; + 1F69CE33297DB729005032CE /* Service */ = { + isa = PBXGroup; + children = ( + 1F69CE34297DB732005032CE /* NewsService.swift */, + ); + path = Service; + sourceTree = ""; + }; + 1F69CE38297DCA2B005032CE /* NewsScreen */ = { + isa = PBXGroup; + children = ( + 1F69CE36297DCA22005032CE /* NewsScreen.swift */, + ); + path = NewsScreen; + sourceTree = ""; + }; + 1F69CE39297DCBFE005032CE /* Service */ = { + isa = PBXGroup; + children = ( + 1F69CE3D297DCC19005032CE /* MovieService.swift */, + ); + path = Service; + sourceTree = ""; + }; + 1F69CE3A297DCC04005032CE /* Screen */ = { + isa = PBXGroup; + children = ( + 1F69CE3B297DCC12005032CE /* MoviesScreen.swift */, + ); + path = Screen; + sourceTree = ""; + }; + 1F69CE49297EDEB5005032CE /* Screen */ = { + isa = PBXGroup; + children = ( + 1F69CE47297EDEA3005032CE /* TUMSexyScreen.swift */, + ); + path = Screen; + sourceTree = ""; + }; + 1F69CE4C297EDECA005032CE /* Service */ = { + isa = PBXGroup; + children = ( + 1F69CE4A297EDEC7005032CE /* TUMSexyService.swift */, + ); + path = Service; + sourceTree = ""; + }; + 1F69CE4F297EDF32005032CE /* Model */ = { + isa = PBXGroup; + children = ( + 1F69CE4D297EDF12005032CE /* TUMSexyLink.swift */, + ); + path = Model; + sourceTree = ""; + }; + 1F71E7F429E4611000379428 /* Model */ = { + isa = PBXGroup; + children = ( + 1F71E7F529E4611000379428 /* Details */, + 1F71E7FB29E4611000379428 /* NavigaTumOverlayMap.swift */, + 1F71E7FC29E4611000379428 /* NavigaTumNavigationDetails.swift */, + 1F71E7FD29E4611000379428 /* Search */, + 1F71E80029E4611000379428 /* NavigaTumNavigationEntity.swift */, + 1F71E80129E4611000379428 /* NavigaTumNavigationProperty.swift */, + 1F71E80229E4611000379428 /* NavigaTumRoomFinderMap.swift */, + ); + path = Model; + sourceTree = ""; + }; + 1F71E7F529E4611000379428 /* Details */ = { + isa = PBXGroup; + children = ( + 1F71E7F629E4611000379428 /* NavigaTumRoomFinderMaps.swift */, + 1F71E7F729E4611000379428 /* NavigaTumNavigationCoordinates.swift */, + 1F71E7F829E4611000379428 /* NavigaTumOverlaysMaps.swift */, + 1F71E7F929E4611000379428 /* NavigaTumNavigationMaps.swift */, + 1F71E7FA29E4611000379428 /* NavigaTumNavigationAdditionalProperties.swift */, + ); + path = Details; + sourceTree = ""; + }; + 1F71E7FD29E4611000379428 /* Search */ = { + isa = PBXGroup; + children = ( + 1F71E7FE29E4611000379428 /* NavigaTumSearchResponse.swift */, + 1F71E7FF29E4611000379428 /* NavigaTumSearchResponseSection.swift */, + ); + path = Search; + sourceTree = ""; + }; + 1F71E81429E4611E00379428 /* Service */ = { + isa = PBXGroup; + children = ( + 1F71E81529E4611E00379428 /* RoomFinderService.swift */, + ); + path = Service; + sourceTree = ""; + }; + 1F71E81B29E4613F00379428 /* ViewNavigaTum */ = { + isa = PBXGroup; + children = ( + 1F71E81C29E4613F00379428 /* NavigaTumDetailsView.swift */, + 1F71E81D29E4613F00379428 /* NavigaTumDetailsBaseView.swift */, + 1F71E81E29E4613F00379428 /* NavigaTumMapView.swift */, + 1F71E81F29E4613F00379428 /* NavigaTumMapImagesView.swift */, + 1F71E82029E4613F00379428 /* NavigaTumView.swift */, + 1F71E82129E4613F00379428 /* NavigaTumListView.swift */, + ); + path = ViewNavigaTum; + sourceTree = ""; + }; 1FFEF086284E417E00ADD201 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -669,6 +1026,14 @@ name = "Recovered References"; sourceTree = ""; }; + 1FFF9AC4297D317C0098E874 /* Service */ = { + isa = PBXGroup; + children = ( + 1FFF9AC5297D31830098E874 /* ProfileService.swift */, + ); + path = Service; + sourceTree = ""; + }; 36108B9C27A3046B007DC62D /* LectureComponent */ = { isa = PBXGroup; children = ( @@ -773,6 +1138,8 @@ 36108BD927A304B5007DC62D /* CafeteriaRowView.swift */, 36108BD227A304B5007DC62D /* MealPlanView.swift */, 36108BD627A304B5007DC62D /* MenuView.swift */, + 1F69CE45297EC99D005032CE /* DishView.swift */, + 1FACF3FC2996E34200A0B8AC /* MealPlanScreen.swift */, ); path = Cafeterias; sourceTree = ""; @@ -782,7 +1149,6 @@ children = ( 3654F382285171F6008AD5DC /* StudyRooms */, 36108BD127A304B5007DC62D /* Cafeterias */, - 36108BDB27A304B5007DC62D /* MapView.swift */, 1FAF9F0B284D2ABC000ABE93 /* MapScreenView.swift */, 36108BDD27A304B5007DC62D /* MapContentView.swift */, 36108BDE27A304B5007DC62D /* PanelContentView.swift */, @@ -797,6 +1163,8 @@ 36108BF127A30516007DC62D /* MoviesComponent */ = { isa = PBXGroup; children = ( + 1F69CE3A297DCC04005032CE /* Screen */, + 1F69CE39297DCBFE005032CE /* Service */, 36108BF227A30516007DC62D /* ViewModel */, 36108BF527A30516007DC62D /* Views */, ); @@ -902,6 +1270,9 @@ children = ( 3616C4CB27902086000A1BC9 /* ViewModel */, 3616C4CA27902075000A1BC9 /* Views */, + 1F69CE49297EDEB5005032CE /* Screen */, + 1F69CE4C297EDECA005032CE /* Service */, + 1F69CE4F297EDF32005032CE /* Model */, ); path = TUMSexyComponent; sourceTree = ""; @@ -925,9 +1296,11 @@ 3616C4D727904BA7000A1BC9 /* NewsComponent */ = { isa = PBXGroup; children = ( + 1F69CE33297DB729005032CE /* Service */, 3629BA2A27A1CEAD0036AC80 /* Views */, - 36BBE7302798AFCC0018FD3F /* Service */, + 36BBE7302798AFCC0018FD3F /* Model */, 3616C4D827904BB5000A1BC9 /* ViewModel */, + 1F69CE38297DCA2B005032CE /* NewsScreen */, ); path = NewsComponent; sourceTree = ""; @@ -943,6 +1316,7 @@ 36203E872761C6EC00C24658 /* LoginComponent */ = { isa = PBXGroup; children = ( + 1F04F188297C85030085F273 /* Model */, 36E9649D277492150055777F /* Service */, 36E9649C277491F10055777F /* ViewModel */, 36E9649B277491E90055777F /* Views */, @@ -997,6 +1371,9 @@ 3654F3692851710E008AD5DC /* RoomFinder */ = { isa = PBXGroup; children = ( + 1F71E81B29E4613F00379428 /* ViewNavigaTum */, + 1F71E81429E4611E00379428 /* Service */, + 1F71E7F429E4611000379428 /* Model */, 3654F36A2851710E008AD5DC /* ViewModel */, 3654F36D2851710E008AD5DC /* Entity */, 3654F36F2851710E008AD5DC /* Views */, @@ -1007,6 +1384,8 @@ 3654F36A2851710E008AD5DC /* ViewModel */ = { isa = PBXGroup; children = ( + 1F71E81729E4613500379428 /* NavigaTumDetailsViewModel.swift */, + 1F71E81829E4613500379428 /* NavigaTumViewModel.swift */, 3654F36B2851710E008AD5DC /* RoomFinderViewModel.swift */, 3654F36C2851710E008AD5DC /* RoomFinderMapViewModel.swift */, ); @@ -1037,13 +1416,14 @@ 3654F381285171D7008AD5DC /* ViewModel */ = { isa = PBXGroup; children = ( - 3654F38328517260008AD5DC /* StudyRoomVIewModel.swift */, + 3654F38328517260008AD5DC /* StudyRoomViewModel.swift */, 36108BD327A304B5007DC62D /* MealPlanViewModel.swift */, 36108BD427A304B5007DC62D /* MenuViewModel.swift */, 1F4C836128300306006971C0 /* MapViewModel.swift */, 1F4C836328300D25006971C0 /* MapViewModel+State.swift */, - 08DFB97C2867800C00E357DF /* CafeteriaWidgetViewModel.swift */, + 1F71E82C29E464C400379428 /* CafeteriaWidgetViewModel.swift */, 08DFB9802867ACB600E357DF /* StudyRoomWidgetViewModel.swift */, + 1F69CE43297EC97E005032CE /* DishViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -1056,6 +1436,7 @@ 3654F38928518640008AD5DC /* StudyRoomDetailsView.swift */, 3654F387285185A4008AD5DC /* StudyRoomGroupView.swift */, 3654F35E285168D2008AD5DC /* MapImagesHorizontalScrollingView.swift */, + 1F69CE3F297DDCD3005032CE /* StudyRoomDetailsScreen.swift */, ); path = StudyRooms; sourceTree = ""; @@ -1169,7 +1550,6 @@ isa = PBXGroup; children = ( 36108C0027A30762007DC62D /* Enums */, - 36AF61B827A2FD7700FEBD98 /* Entity */, 36AF61BA27A2FD7700FEBD98 /* Networking */, 36AF61C427A2FD7700FEBD98 /* Constants */, 36AF61C727A2FD7700FEBD98 /* Errors */, @@ -1179,27 +1559,14 @@ path = Base; sourceTree = ""; }; - 36AF61B827A2FD7700FEBD98 /* Entity */ = { - isa = PBXGroup; - children = ( - 36AF61B927A2FD7700FEBD98 /* EntityImporter.swift */, - ); - path = Entity; - sourceTree = ""; - }; 36AF61BA27A2FD7700FEBD98 /* Networking */ = { isa = PBXGroup; children = ( - 3654F367285169AC008AD5DC /* TUMDevAppAPI.swift */, - 36AF61BB27A2FD7700FEBD98 /* TUMSexyAPI.swift */, - 36AF61BC27A2FD7700FEBD98 /* EatAPI.swift */, - 36AF61BD27A2FD7700FEBD98 /* MVGAPI.swift */, - 36AF61BE27A2FD7700FEBD98 /* NetworkingAPI.swift */, - 36AF61BF27A2FD7700FEBD98 /* CampusOnlineAPI.swift */, - 36AF61C027A2FD7700FEBD98 /* APIResponse.swift */, - 36AF61C127A2FD7700FEBD98 /* TUMOnlineAPI.swift */, + 1F189EA529968E150056BBD8 /* Protocols */, + 1F189E8B29968CD70056BBD8 /* APIs */, + 1F189E9829968D620056BBD8 /* APIErrors */, 36AF61C227A2FD7700FEBD98 /* Cache.swift */, - 36AF61C327A2FD7700FEBD98 /* TUMCabeAPI.swift */, + 1F04F16F297AA1E00085F273 /* Old APIs */, ); path = Networking; sourceTree = ""; @@ -1251,9 +1618,11 @@ 36BB6F5527AFCD7B00F224AB /* PersonDetailedComponent */ = { isa = PBXGroup; children = ( - 36BB6F5827AFCD9C00F224AB /* View */, 36BB6F5727AFCD9300F224AB /* ViewModel */, + 1F04F187297C6BC40085F273 /* Screen */, + 36BB6F5827AFCD9C00F224AB /* View */, 36BB6F5627AFCD8D00F224AB /* Entity */, + 1F04F186297C3FA20085F273 /* Service */, ); path = PersonDetailedComponent; sourceTree = ""; @@ -1281,7 +1650,6 @@ isa = PBXGroup; children = ( 36BB6F5227AFCCB500F224AB /* PersonDetailedView.swift */, - 36BB6F7C27B356C200F224AB /* PersonDetailedCellView.swift */, 36BB6F7E27B386D100F224AB /* AddToContactsView.swift */, ); path = View; @@ -1292,7 +1660,9 @@ children = ( 36BB6F5A27AFCDF900F224AB /* ViewModel */, 36BB6F5C27AFCDFA00F224AB /* Entity */, + 1F04F181297C33860085F273 /* Screen */, 36BB6F5E27AFCDFA00F224AB /* View */, + 1F04F180297BDF250085F273 /* Service */, ); path = PersonSearchComponent; sourceTree = ""; @@ -1316,8 +1686,7 @@ 36BB6F5E27AFCDFA00F224AB /* View */ = { isa = PBXGroup; children = ( - 36BB6F5F27AFCDFA00F224AB /* PersonSearchView.swift */, - 36AD5CF727B96AD200DAE143 /* PersonSearchListView.swift */, + 36AD5CF727B96AD200DAE143 /* PersonSearchView.swift */, ); path = View; sourceTree = ""; @@ -1327,7 +1696,7 @@ children = ( 100803452764E2C50013ED0E /* ProfileToolbar.swift */, 100803472764E37A0013ED0E /* ProfileView.swift */, - 36AD5CFB27B974F100DAE143 /* ProfileMyTumSection.swift */, + 36AD5CFB27B974F100DAE143 /* TuitionScreen.swift */, ); path = View; sourceTree = ""; @@ -1372,7 +1741,9 @@ isa = PBXGroup; children = ( 36BB6F8427B39C3D00F224AB /* ViewModel */, + 1F04F17B297B0BC30085F273 /* Screen */, 36BB6F8127B39B3400F224AB /* View */, + 1F04F17A297AED190085F273 /* Service */, ); path = LectureSearchComponent; sourceTree = ""; @@ -1380,8 +1751,7 @@ 36BB6F8127B39B3400F224AB /* View */ = { isa = PBXGroup; children = ( - 36BB6F8227B39B4300F224AB /* LectureSearchView.swift */, - 36AD5CF927B9711B00DAE143 /* LectureSearchListView.swift */, + 36AD5CF927B9711B00DAE143 /* LectureSearchView.swift */, ); path = View; sourceTree = ""; @@ -1394,13 +1764,13 @@ path = ViewModel; sourceTree = ""; }; - 36BB6F8B27B3D58700F224AB /* Entity */ = { + 36BB6F8B27B3D58700F224AB /* Model */ = { isa = PBXGroup; children = ( - 36BB6F8927B3D21200F224AB /* CalendarEvent.swift */, + 1F04F172297AD41B0085F273 /* CalendarEvent.swift */, 36AD5CF127B7FEAD00DAE143 /* TumCalendarStyle.swift */, ); - path = Entity; + path = Model; sourceTree = ""; }; 36BBE72D27989F6E0018FD3F /* HelperViews */ = { @@ -1413,13 +1783,13 @@ path = HelperViews; sourceTree = ""; }; - 36BBE7302798AFCC0018FD3F /* Service */ = { + 36BBE7302798AFCC0018FD3F /* Model */ = { isa = PBXGroup; children = ( 36BBE7312798AFE10018FD3F /* News.swift */, 36BBE7332798B04D0018FD3F /* NewsSource.swift */, ); - path = Service; + path = Model; sourceTree = ""; }; 36E9649B277491E90055777F /* Views */ = { @@ -1451,7 +1821,6 @@ isa = PBXGroup; children = ( 36FF906E2773BE8100F4C785 /* AuthenticationHandler.swift */, - 36203E8A2761C6EC00C24658 /* Credentials.swift */, ); path = Service; sourceTree = ""; @@ -1459,9 +1828,11 @@ 36E9649E277492AE0055777F /* CalendarComponent */ = { isa = PBXGroup; children = ( - 36BB6F8B27B3D58700F224AB /* Entity */, + 36BB6F8B27B3D58700F224AB /* Model */, 36E964A0277492C00055777F /* ViewModel */, 36E9649F277492B70055777F /* Views */, + 1F04F177297AD4350085F273 /* Service */, + 1F04F176297AD42E0085F273 /* Screen */, ); path = CalendarComponent; sourceTree = ""; @@ -1697,33 +2068,43 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 36BB6F6227AFCDFA00F224AB /* PersonSearchView.swift in Sources */, + 36BB6F6227AFCDFA00F224AB /* PersonSearchScreen.swift in Sources */, 97270F5A27AB2A4900BB25E4 /* Array+Rearrange.swift in Sources */, - 3654F38428517260008AD5DC /* StudyRoomVIewModel.swift in Sources */, + 1F71E82629E4613F00379428 /* NavigaTumView.swift in Sources */, + 3654F38428517260008AD5DC /* StudyRoomViewModel.swift in Sources */, 2F1B2B87286530120023BD9A /* MovieDetailsBasicInfoRowView.swift in Sources */, 36BB6F6627AFD12B00F224AB /* PersonDetails.swift in Sources */, 36108BFD27A30517007DC62D /* MovieDetailedView.swift in Sources */, 3683C3182758117900082930 /* Model.swift in Sources */, + 1F04F179297AED150085F273 /* LectureSearchService.swift in Sources */, 08FAFD24288DF553006A0E27 /* WidgetRecommendation.swift in Sources */, 08DFB96F286647E900E357DF /* WidgetScreen.swift in Sources */, 974D5B9A27E5E9CB00FD7B11 /* GlowBorder.swift in Sources */, 08DFB97328664BC400E357DF /* TuitionWidgetView.swift in Sources */, - 36BB6F8A27B3D21200F224AB /* CalendarEvent.swift in Sources */, 36108BEB27A304B6007DC62D /* CafeteriaRowView.swift in Sources */, + 1F71E80B29E4611000379428 /* NavigaTumSearchResponseSection.swift in Sources */, 08DFB9772866506900E357DF /* WidgetFrameView.swift in Sources */, 366F0E8D27580CFD0091651D /* Persistence.swift in Sources */, 36108BE327A304B5007DC62D /* DishLabel.swift in Sources */, + 1F71E80929E4611000379428 /* NavigaTumNavigationDetails.swift in Sources */, 085DE9C628AB7C530045095F /* AnalyticsController.swift in Sources */, + 1F71E82429E4613F00379428 /* NavigaTumMapView.swift in Sources */, 08441F2B2874E2D00033F5B1 /* WidgetLoadingView.swift in Sources */, 36108BB927A3046B007DC62D /* LecturesViewModel+State.swift in Sources */, 36AF61EE27A2FD7800FEBD98 /* GroupBoxLabelView.swift in Sources */, + 1F69CE3C297DCC12005032CE /* MoviesScreen.swift in Sources */, + 1F69CE48297EDEA3005032CE /* TUMSexyScreen.swift in Sources */, 36108BFB27A30517007DC62D /* MoviesViewModel.swift in Sources */, 36E964AC277499860055777F /* CalendarDisplayView.swift in Sources */, 1F4C926F2882FD85003DC7D7 /* RoundedCorners.swift in Sources */, + 1F71E81A29E4613500379428 /* NavigaTumViewModel.swift in Sources */, + 1F71E80C29E4611000379428 /* NavigaTumNavigationEntity.swift in Sources */, 36FAE365277472EF00628799 /* LoginViewModel.swift in Sources */, 36108BE927A304B5007DC62D /* MenuView.swift in Sources */, 36108C1D27A307FA007DC62D /* GradeView.swift in Sources */, 36AF61DE27A2FD7800FEBD98 /* APIResponse.swift in Sources */, + 1F71E81629E4611E00379428 /* RoomFinderService.swift in Sources */, + 1F69CE4E297EDF12005032CE /* TUMSexyLink.swift in Sources */, 3654F37A2851710E008AD5DC /* RoomFinderDetailsBaseView.swift in Sources */, 3654F388285185A4008AD5DC /* StudyRoomGroupView.swift in Sources */, 0815249428E445030098A2C3 /* Date+Time.swift in Sources */, @@ -1734,40 +2115,50 @@ 36108BC427A3046B007DC62D /* LectureDetailsDetailedInfoRowView.swift in Sources */, 36108C1C27A307FA007DC62D /* GradesView.swift in Sources */, 3654F365285168D2008AD5DC /* RoomImageMapping.swift in Sources */, + 1FFF9AC6297D31830098E874 /* ProfileService.swift in Sources */, 2FCF38B1286C9B9200F10915 /* MovieDetailsDetailedInfoRowView.swift in Sources */, - 36AD5CFC27B974F100DAE143 /* ProfileMyTumSection.swift in Sources */, + 36AD5CFC27B974F100DAE143 /* TuitionScreen.swift in Sources */, 36108BE227A304B5007DC62D /* MensaMenu.swift in Sources */, 36108BC027A3046B007DC62D /* LecturesView.swift in Sources */, 0805DB7928C933AE00712FF2 /* Operators.swift in Sources */, 97F8A79327E641570099EE83 /* AcademicDegree.swift in Sources */, - 36AF61D927A2FD7800FEBD98 /* TUMSexyAPI.swift in Sources */, + 36AF61D927A2FD7800FEBD98 /* TUMSexyAPIOld.swift in Sources */, + 1F189E9329968D260056BBD8 /* TUMDevAppAPI.swift in Sources */, 3654F38E28518B3D008AD5DC /* StudyGroupRowView.swift in Sources */, 3654F358285167C3008AD5DC /* StudyRoomsService.swift in Sources */, 36AD5CF227B7FEAD00DAE143 /* TumCalendarStyle.swift in Sources */, + 1FA538EE297560CD004C70A8 /* MainAPI.swift in Sources */, 36BB6F7F27B386D100F224AB /* AddToContactsView.swift in Sources */, - 36AF61DF27A2FD7800FEBD98 /* TUMOnlineAPI.swift in Sources */, + 1F189EA729968E5C0056BBD8 /* API.swift in Sources */, + 36AF61DF27A2FD7800FEBD98 /* TUMOnlineAPIOld.swift in Sources */, 3654F37B2851710E008AD5DC /* RoomFinderListView.swift in Sources */, 36C70FB32854D2AB0097416E /* PanelContentStudyGroupsListView.swift in Sources */, 36C70FB128538A190097416E /* PanelContentCafeteriasListView.swift in Sources */, - 36BB6F7D27B356C200F224AB /* PersonDetailedCellView.swift in Sources */, 36108BE727A304B5007DC62D /* MenuViewModel.swift in Sources */, - 36AF61E127A2FD7800FEBD98 /* TUMCabeAPI.swift in Sources */, + 36AF61E127A2FD7800FEBD98 /* TUMCabeAPIOld.swift in Sources */, 3654F37D2851710E008AD5DC /* RoomFinderDetailsMapImagesView.swift in Sources */, 36108BB627A3046B007DC62D /* LectureDetailsViewModel+State.swift in Sources */, + 1FACF3F92996A49300A0B8AC /* TUMDevAppAPIOld.swift in Sources */, 36108BBF27A3046B007DC62D /* LectureDetailsService.swift in Sources */, 0805E72C28CC2278003C5CFD /* HashFunction.swift in Sources */, 36982BD827A2739000515847 /* Collapsible.swift in Sources */, 0815249E28E4A6310098A2C3 /* Date+daysBetween.swift in Sources */, 1F2068DE28FD731200DBDF67 /* LoginViewModel+TokenState.swift in Sources */, - 36AD5CFA27B9711B00DAE143 /* LectureSearchListView.swift in Sources */, + 36AD5CFA27B9711B00DAE143 /* LectureSearchView.swift in Sources */, + 1F189E9529968D330056BBD8 /* TUMSexyAPI.swift in Sources */, 1F4C836228300306006971C0 /* MapViewModel.swift in Sources */, + 1F189E9729968D490056BBD8 /* MVGAPI.swift in Sources */, 36108BFF27A30517007DC62D /* MoviesView.swift in Sources */, + 1F71E80729E4611000379428 /* NavigaTumNavigationAdditionalProperties.swift in Sources */, + 1F189E9129968D130056BBD8 /* EatAPI.swift in Sources */, 36AF61E927A2FD7800FEBD98 /* ErrorHandler.swift in Sources */, 36BB6F6027AFCDFA00F224AB /* PersonSearchViewModel.swift in Sources */, 36982BD627A251A700515847 /* NewsCardsHorizontalScrollingView.swift in Sources */, 36AF61E727A2FD7800FEBD98 /* ErrorEmittingViewModifier.swift in Sources */, + 1F71E80629E4611000379428 /* NavigaTumNavigationMaps.swift in Sources */, 3654F362285168D2008AD5DC /* StudyRoomApiResponse.swift in Sources */, 36108BC527A3046B007DC62D /* LectureDetailsDetailedInfoView.swift in Sources */, + 1F04F18A297C85120085F273 /* Token.swift in Sources */, 3654F363285168D2008AD5DC /* StudyRoom.swift in Sources */, 08573BA5287847DC006AC06F /* MapLocation.swift in Sources */, 3629BA3127A1D0AD0036AC80 /* ScrollableCardsViewModifier.swift in Sources */, @@ -1781,20 +2172,25 @@ 3698CBED2761E014001C5735 /* CustomRoundedBorderTextFieldStyle.swift in Sources */, 36108BE627A304B5007DC62D /* MealPlanViewModel.swift in Sources */, 36203E8B2761C6EC00C24658 /* TUMSplashScreen.swift in Sources */, - 36AF61DA27A2FD7800FEBD98 /* EatAPI.swift in Sources */, + 36AF61DA27A2FD7800FEBD98 /* EatAPIOld.swift in Sources */, 36108BEF27A304B6007DC62D /* MapContentView.swift in Sources */, + 1F71E80E29E4611000379428 /* NavigaTumRoomFinderMap.swift in Sources */, + 1F71E80A29E4611000379428 /* NavigaTumSearchResponse.swift in Sources */, 3616C4CF279020D3000A1BC9 /* TUMSexyViewModel.swift in Sources */, + 1F69CE44297EC97E005032CE /* DishViewModel.swift in Sources */, 36AF61EC27A2FD7800FEBD98 /* Error+Category.swift in Sources */, - 36AF61DD27A2FD7800FEBD98 /* CampusOnlineAPI.swift in Sources */, + 1F71E82229E4613F00379428 /* NavigaTumDetailsView.swift in Sources */, + 36AF61DD27A2FD7800FEBD98 /* CampusOnlineAPIOld.swift in Sources */, 36AD5CF427B8C83500DAE143 /* CalendarSingleEventView.swift in Sources */, 36108BBC27A3046B007DC62D /* LectureDetails.swift in Sources */, 36108BC627A3046B007DC62D /* LectureDetailsBasicInfoRowView.swift in Sources */, + 1F189E9E29968D9B0056BBD8 /* EatAPIError.swift in Sources */, 08FAFD20288DEE3B006A0E27 /* Widget.swift in Sources */, 36108BDF27A304B5007DC62D /* MealPlan.swift in Sources */, 36BBE7342798B04D0018FD3F /* NewsSource.swift in Sources */, 3654F38628517BB4008AD5DC /* CafeteriaView.swift in Sources */, 36BB6F8D27B3F25A00F224AB /* NSMutableString+Extensions.swift in Sources */, - 36108BED27A304B6007DC62D /* MapView.swift in Sources */, + 1F04F175297AD4280085F273 /* CalendarScreen.swift in Sources */, 36AF61E827A2FD7800FEBD98 /* AlertErrorHandler.swift in Sources */, 36BB6F5327AFCCB500F224AB /* PersonDetailedView.swift in Sources */, 3654F37E2851710E008AD5DC /* RoomFinderDetailsView.swift in Sources */, @@ -1802,6 +2198,7 @@ 3654F364285168D2008AD5DC /* MapImagesHorizontalScrollingView.swift in Sources */, 36E964A5277493D90055777F /* CalendarViewModel.swift in Sources */, 0805E72428CAABB3003C5CFD /* AnalyticsError.swift in Sources */, + 1F71E82F29E4667C00379428 /* NavigaTUMAPI.swift in Sources */, 3654F361285168D2008AD5DC /* StudyRoomGroup.swift in Sources */, 36BB6F7927B26DE300F224AB /* TuitionView.swift in Sources */, 3654F3782851710E008AD5DC /* FoundRoom.swift in Sources */, @@ -1809,19 +2206,26 @@ 0805DB7728C7F3E600712FF2 /* AnalyticsOptInView.swift in Sources */, 1FAF9F0C284D2ABC000ABE93 /* MapScreenView.swift in Sources */, 36108C1527A307F9007DC62D /* GradesViewModel.swift in Sources */, - 36108BE427A304B5007DC62D /* MensaEnumService.swift in Sources */, + 1F69CE35297DB732005032CE /* NewsService.swift in Sources */, 1F2068E428FD73CB00DBDF67 /* TokenPermissionsViewModel+PermissionType.swift in Sources */, 36108BE127A304B5007DC62D /* Cafeteria.swift in Sources */, + 1F04F183297C3EF70085F273 /* PersonDetailedScreen.swift in Sources */, 36108BE027A304B5007DC62D /* Dish.swift in Sources */, + 1F71E80429E4611000379428 /* NavigaTumNavigationCoordinates.swift in Sources */, 36108BBB27A3046B007DC62D /* LecturesScreen.swift in Sources */, 36BB6F6427AFCFFB00F224AB /* PersonDetailedViewModel.swift in Sources */, 36AF61E427A2FD7800FEBD98 /* NetworkingError.swift in Sources */, - 36BB6F8327B39B4300F224AB /* LectureSearchView.swift in Sources */, + 1FACF3FB2996A65700A0B8AC /* MealPlanService.swift in Sources */, + 36BB6F8327B39B4300F224AB /* LectureSearchScreen.swift in Sources */, 36108BFA27A30517007DC62D /* Movie.swift in Sources */, 36108BB727A3046B007DC62D /* LectureDetailsViewModel.swift in Sources */, 36AF61E027A2FD7800FEBD98 /* Cache.swift in Sources */, + 1F69CE37297DCA22005032CE /* NewsScreen.swift in Sources */, 36AF61F027A2FD7800FEBD98 /* DecoderProtocol.swift in Sources */, + 1F189E9A29968D790056BBD8 /* TUMOnlineAPIError.swift in Sources */, + 1F69CE40297DDCD3005032CE /* StudyRoomDetailsScreen.swift in Sources */, 08FAFD1C288DEDBF006A0E27 /* TimeStrategy.swift in Sources */, + 1F04F171297AA5F40085F273 /* Service.swift in Sources */, 08FAFD17288474FC006A0E27 /* WidgetMapBackgroundView.swift in Sources */, 3698CBEF2761E6CC001C5735 /* TokenConfirmationView.swift in Sources */, 0805E72828CC0954003C5CFD /* AppUsageDataEntity.swift in Sources */, @@ -1829,34 +2233,47 @@ 3616C4CD279020A0000A1BC9 /* TUMSexyView.swift in Sources */, 1F4C836728300E79006971C0 /* CafeteriasService.swift in Sources */, 36108C1E27A307FA007DC62D /* BarChartView.swift in Sources */, - 3654F368285169AC008AD5DC /* TUMDevAppAPI.swift in Sources */, 3683C31A2758118A00082930 /* MockModel.swift in Sources */, + 1F04F16E297A9A700085F273 /* CalendarService.swift in Sources */, + 1F04F17F297BDF1E0085F273 /* PersonSearchService.swift in Sources */, 08DFB9812867ACB600E357DF /* StudyRoomWidgetViewModel.swift in Sources */, 3629BA3327A1E4A90036AC80 /* RoundedCornersShape.swift in Sources */, 1F2068E228FD73C400DBDF67 /* TokenPermissionsViewModel+State.swift in Sources */, + 1F71E82729E4613F00379428 /* NavigaTumListView.swift in Sources */, 3654F37C2851710E008AD5DC /* RoomFinderView.swift in Sources */, + 1F71E80529E4611000379428 /* NavigaTumOverlaysMaps.swift in Sources */, 36BBE72F27989F8C0018FD3F /* SFSafariViewWrapper.swift in Sources */, 08FAFD272898A2B8006A0E27 /* LocationStrategy.swift in Sources */, + 1F183A172979D19000B5D22D /* APIError.swift in Sources */, 1F2068DC28FD6E2800DBDF67 /* LoginViewModel+LoginState.swift in Sources */, 36AF61E627A2FD7800FEBD98 /* View+Error.swift in Sources */, 36BBE7322798AFE10018FD3F /* News.swift in Sources */, 36108C1627A307F9007DC62D /* GradesViewModel+ChartData.swift in Sources */, - 36AD5CF827B96AD200DAE143 /* PersonSearchListView.swift in Sources */, + 1F71E82D29E464C400379428 /* CafeteriaWidgetViewModel.swift in Sources */, + 36AD5CF827B96AD200DAE143 /* PersonSearchView.swift in Sources */, 36108C1727A307F9007DC62D /* GradesViewModel+State.swift in Sources */, + 1F04F173297AD41B0085F273 /* CalendarEvent.swift in Sources */, + 1F189E9C29968D880056BBD8 /* TUMCabeAPIError.swift in Sources */, + 1F189E8F29968CFC0056BBD8 /* TUMCabeAPI.swift in Sources */, 08038F96287430FB0048DAE5 /* WidgetTitleView.swift in Sources */, 36FF906F2773BE8100F4C785 /* AuthenticationHandler.swift in Sources */, 36108BFE27A30517007DC62D /* MovieDetailCellView.swift in Sources */, + 1F69CE46297EC99D005032CE /* DishView.swift in Sources */, + 1F69CE4B297EDEC7005032CE /* TUMSexyService.swift in Sources */, 2FCF38AD286C9B5600F10915 /* MovieDetailsDetailedInfoView.swift in Sources */, 36108BE527A304B5007DC62D /* MealPlanView.swift in Sources */, 36AF61EF27A2FD7800FEBD98 /* LoadingView.swift in Sources */, - 36AF61D827A2FD7800FEBD98 /* EntityImporter.swift in Sources */, + 1F69CE3E297DCC19005032CE /* MovieService.swift in Sources */, 36AF61EA27A2FD7800FEBD98 /* Environment+Error.swift in Sources */, 3654F38A28518640008AD5DC /* StudyRoomDetailsView.swift in Sources */, 36AF61ED27A2FD7800FEBD98 /* FailedView.swift in Sources */, 36108BBE27A3046B007DC62D /* LecturesService.swift in Sources */, 99706870298569E10028D235 /* CrashlyticsService.swift in Sources */, + 1F71E83129E46A1000379428 /* NavigaTUMAPIError.swift in Sources */, 36108BF027A304B6007DC62D /* PanelContentView.swift in Sources */, 36AF61F127A2FD7800FEBD98 /* XMLSerializer.swift in Sources */, + 1F71E81929E4613500379428 /* NavigaTumDetailsViewModel.swift in Sources */, + 1F71E80829E4611000379428 /* NavigaTumOverlayMap.swift in Sources */, 08FAFD1A288DED6F006A0E27 /* WidgetRecommender.swift in Sources */, 08D9535A28E34596007ED2F1 /* Array+Groups.swift in Sources */, 36108C1A27A307FA007DC62D /* Modus.swift in Sources */, @@ -1864,10 +2281,13 @@ 36108BC127A3046B007DC62D /* LectureView.swift in Sources */, 366F0E9027580CFD0091651D /* Campus_iOS.xcdatamodeld in Sources */, 36108BFC27A30517007DC62D /* MovieCard.swift in Sources */, + 1F71E82529E4613F00379428 /* NavigaTumMapImagesView.swift in Sources */, 36BB6F6A27AFD2A100F224AB /* PhoneExtension.swift in Sources */, - 36AF61DC27A2FD7800FEBD98 /* NetworkingAPI.swift in Sources */, + 36AF61DC27A2FD7800FEBD98 /* NetworkingAPIOld.swift in Sources */, + 1F71E82329E4613F00379428 /* NavigaTumDetailsBaseView.swift in Sources */, 08DFB97528664CFC00E357DF /* TuitionDetailsView.swift in Sources */, 0815249628E45C390098A2C3 /* MLModelDataHandler.swift in Sources */, + 1F71E80D29E4611000379428 /* NavigaTumNavigationProperty.swift in Sources */, 3654F366285168D2008AD5DC /* StudyRoomAttribute.swift in Sources */, 1FBFA168285E5B2D00FC1515 /* PanelContentListView.swift in Sources */, 100803462764E2C50013ED0E /* ProfileToolbar.swift in Sources */, @@ -1876,11 +2296,15 @@ 36BB6F6C27AFD2B900F224AB /* Room.swift in Sources */, 0815249828E492070098A2C3 /* RecommenderError.swift in Sources */, 36AF61EB27A2FD7800FEBD98 /* ErrorCategory.swift in Sources */, + 1F04F185297C3F990085F273 /* PersonDetailedService.swift in Sources */, + 1F04F18C297C85190085F273 /* Confirmation.swift in Sources */, + 1F69CE42297EC94E005032CE /* DishService.swift in Sources */, 36203E8C2761C6EC00C24658 /* LoginView.swift in Sources */, 1F33B2ED282B084100C898E4 /* MockGradesViewModel.swift in Sources */, 36E964A7277498540055777F /* CalendarContentView.swift in Sources */, 0815249C28E4A38D0098A2C3 /* CLLocation+isInvalid.swift in Sources */, 36AF61E327A2FD7800FEBD98 /* APIConstants.swift in Sources */, + 1F71E80329E4611000379428 /* NavigaTumRoomFinderMaps.swift in Sources */, 3629BA2C27A1CECA0036AC80 /* NewsView.swift in Sources */, 1FB82E3428F95776007B1858 /* TokenPermissionsView.swift in Sources */, 36BB6F7327B1CD9200F224AB /* ProfileViewModel.swift in Sources */, @@ -1891,7 +2315,7 @@ 08DFB97928666AD900E357DF /* CafeteriaWidgetView.swift in Sources */, 3629BA2E27A1CEFA0036AC80 /* NewsCard.swift in Sources */, 100803482764E37A0013ED0E /* ProfileView.swift in Sources */, - 08DFB97D2867800C00E357DF /* CafeteriaWidgetViewModel.swift in Sources */, + 1F189EA229968DB90056BBD8 /* TUMSexyAPIError.swift in Sources */, 36AD5CF627B8D97500DAE143 /* LectureDetailsEventInfoView.swift in Sources */, 1F4C836428300D25006971C0 /* MapViewModel+State.swift in Sources */, 08DFB97F2867AC9200E357DF /* StudyRoomWidgetView.swift in Sources */, @@ -1900,10 +2324,14 @@ 3654F38C285187C5008AD5DC /* PanelSearchBarView.swift in Sources */, 1FB82E3628F96C9E007B1858 /* TokenPermissionsViewModel.swift in Sources */, 36108C1B27A307FA007DC62D /* GradesService.swift in Sources */, + 1F189E8D29968CE50056BBD8 /* TUMOnlineAPI.swift in Sources */, 36AF61E527A2FD7800FEBD98 /* BackendError.swift in Sources */, 3654F3772851710E008AD5DC /* RoomFinderMapViewModel.swift in Sources */, - 36AF61DB27A2FD7800FEBD98 /* MVGAPI.swift in Sources */, + 36AF61DB27A2FD7800FEBD98 /* MVGAPIOld.swift in Sources */, + 1FACF3FD2996E34200A0B8AC /* MealPlanScreen.swift in Sources */, 366F0E8427580CFB0091651D /* App.swift in Sources */, + 1F189EA029968DA90056BBD8 /* TUMDevAppAPIError.swift in Sources */, + 1F189EA429968DE60056BBD8 /* MVGAPIError.swift in Sources */, 36FF90652773BB8200F4C785 /* Extensions.swift in Sources */, 36108C1427A307F9007DC62D /* GradeColor.swift in Sources */, 36108BBD27A3046B007DC62D /* Lecture.swift in Sources */, diff --git a/Campus-iOS/AnalyticsComponent/AnalyticsController.swift b/Campus-iOS/AnalyticsComponent/AnalyticsController.swift index 8a037fca..9ba5ebde 100644 --- a/Campus-iOS/AnalyticsComponent/AnalyticsController.swift +++ b/Campus-iOS/AnalyticsComponent/AnalyticsController.swift @@ -33,6 +33,7 @@ struct AnalyticsController { print("Info: app usage data upload is disabled.") return + /* if !didOptIn { return } @@ -89,5 +90,6 @@ struct AnalyticsController { request.setValue(postToken, forHTTPHeaderField: "Authorization") let (_, _) = try await URLSession.shared.data(for: request) + */ } } diff --git "a/Campus-iOS/AnalyticsCom\303\274o/AnalyticsController.swift" "b/Campus-iOS/AnalyticsCom\303\274o/AnalyticsController.swift" deleted file mode 100644 index 10b07be8..00000000 --- "a/Campus-iOS/AnalyticsCom\303\274o/AnalyticsController.swift" +++ /dev/null @@ -1,81 +0,0 @@ -// -// Analytics.swift -// Campus-iOS -// -// Created by Robyn Kölle on 16.08.22. -// - -import Foundation -import MapKit -import SwiftUI - -struct AnalyticsController { - - @AppStorage("analyticsOptIn") private static var didOptIn = false - - static func store(entry: AppUsageData) { - if let _ = try? AppUsageDataEntity(data: entry, context: PersistenceController.shared.container.viewContext) { - PersistenceController.shared.save() - } - } - - static func upload(entry: AppUsageData) async throws { - - if !didOptIn { - return - } - - guard let postToken = Bundle.main.object(forInfoDictionaryKey: "ANALYTICS_POST_TOKEN") as? String, !postToken.isEmpty, - let analyticsApi = Bundle.main.object(forInfoDictionaryKey: "ANALYTICS_API") as? String else { - return - } - - guard var components = URLComponents(string: "https://" + analyticsApi) else { - return - } - - /* Query items */ - - guard let deviceIdentifier = await UIDevice.current.identifierForVendor?.uuidString else { - return - } - - guard let startDate = entry.getStartTime(), let endDate = entry.getEndTime(), let view = entry.getView() else { - return - } - - let latitude = entry.getLatitude() ?? AppUsageData.invalidLocation - let longitude = entry.getLongitude() ?? AppUsageData.invalidLocation - - let hashedId = HashFunction.sha256(deviceIdentifier) - let formatter = DateFormatter() - formatter.dateFormat = "YY-MM-dd HH-mm-ss" - let startTime = formatter.string(from: startDate) - let endTime = formatter.string(from: endDate) - - components.queryItems = [ - URLQueryItem(name: "user_id", value: hashedId), - URLQueryItem(name: "latitude", value: String(latitude)), - URLQueryItem(name: "longitude", value: String(longitude)), - URLQueryItem(name: "start_time", value: startTime), - URLQueryItem(name: "end_time", value: endTime), - URLQueryItem(name: "view", value: view.rawValue) - ] - - guard let url = components.url else { - return - } - -#if targetEnvironment(simulator) - print("🟢 Query items:") - print(components.queryItems ?? []) - return -#endif - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue(postToken, forHTTPHeaderField: "Authorization") - - let (_, _) = try await URLSession.shared.data(for: request) - } -} diff --git "a/Campus-iOS/AnalyticsCom\303\274o/AnalyticsError.swift" "b/Campus-iOS/AnalyticsCom\303\274o/AnalyticsError.swift" deleted file mode 100644 index e6986036..00000000 --- "a/Campus-iOS/AnalyticsCom\303\274o/AnalyticsError.swift" +++ /dev/null @@ -1,12 +0,0 @@ -// -// AnalyticsError.swift -// Campus-iOS -// -// Created by Robyn Kölle on 09.09.22. -// - -import Foundation - -enum AnalyticsError: Error { - case missingValues -} diff --git "a/Campus-iOS/AnalyticsCom\303\274o/AppUsageData.swift" "b/Campus-iOS/AnalyticsCom\303\274o/AppUsageData.swift" deleted file mode 100644 index a8fa7019..00000000 --- "a/Campus-iOS/AnalyticsCom\303\274o/AppUsageData.swift" +++ /dev/null @@ -1,125 +0,0 @@ -// -// AppUsageData.swift -// Campus-iOS -// -// Created by Robyn Kölle on 09.09.22. -// - -import Foundation -import MapKit -import Combine - -/// A wrapper for the usage data we collect for specific views inside the app. -/// Persists the data when explicitly calling `exitView`, or when the app enters the background, or when a sheet blocks the respective view. -/// Instantiate as a `@State` object inside the view, and call `visitView` in `.onAppear` or `.task`. -/// Call `didExitView` in `.onDisappear`. -class AppUsageData { - - /* CoreData's double values (for latitude, longitude) are not optional. - * However, we still want to store the other data when we cannot get the location. - * Thus we symbolize invalid locations with an impossible latitude / longitude value in the CoreData entity. */ - static let invalidLocation: Double = 200 - - private var view: CampusAppView? - private var latitude: Double? - private var longitude: Double? - private var startTime: Date? - private var endTime: Date? - - // To set the end timestamp, and persist the data when the app enters the background. - private var didEnterBackgroundListener: AnyCancellable? - - // To set the start timestamp (etc.) when the app wakes up. - private var wakeUpListener: AnyCancellable? - - private var sheetActiveListener: AnyCancellable? - - private var sheetInactiveListener: AnyCancellable? - - func visitView(view: CampusAppView) { - - self.startTime = Date() - self.view = view - - if let location = CLLocationManager().location { - self.latitude = location.coordinate.latitude - self.longitude = location.coordinate.longitude - } else { - self.latitude = nil - self.longitude = nil - } - - self.didEnterBackgroundListener = NotificationCenter.default - .publisher(for: UIApplication.willResignActiveNotification) - .sink { _ in self.didEnterBackground(currentView: view) } - - self.sheetActiveListener = NotificationCenter.default - .publisher(for: Notification.Name.tcaSheetBecameActiveNotification) - .sink { _ in self.didOpenSheet(currentView: view) } - } - - /// Call this function when exiting a view, e.g. `onDisappear`. - public func didExitView() { - didEnterBackgroundListener?.cancel() - wakeUpListener?.cancel() - sheetActiveListener?.cancel() - sheetInactiveListener?.cancel() - - commit() - } - - private func didEnterBackground(currentView: CampusAppView) { - didEnterBackgroundListener?.cancel() - sheetActiveListener?.cancel() - sheetInactiveListener?.cancel() - - wakeUpListener = NotificationCenter.default - .publisher(for: UIApplication.didBecomeActiveNotification) - .sink { _ in - self.visitView(view: currentView) - } - - commit() - } - - private func didOpenSheet(currentView: CampusAppView) { - - didEnterBackgroundListener?.cancel() - wakeUpListener?.cancel() - sheetActiveListener?.cancel() - - sheetInactiveListener = NotificationCenter.default - .publisher(for: Notification.Name.tcaSheetBecameInactiveNotification) - .sink { _ in - self.visitView(view: currentView) - } - - commit() - } - - private func commit() { - self.endTime = Date() - AnalyticsController.store(entry: self) - Task { try? await AnalyticsController.upload(entry: self) } - } - - public func getView() -> CampusAppView? { - return self.view - } - - public func getLatitude() -> Double? { - return self.latitude - } - - public func getLongitude() -> Double? { - return self.longitude - } - - public func getStartTime() -> Date? { - return self.startTime - } - - public func getEndTime() -> Date? { - return self.endTime - } -} diff --git "a/Campus-iOS/AnalyticsCom\303\274o/AppUsageDataEntity.swift" "b/Campus-iOS/AnalyticsCom\303\274o/AppUsageDataEntity.swift" deleted file mode 100644 index 95f1d806..00000000 --- "a/Campus-iOS/AnalyticsCom\303\274o/AppUsageDataEntity.swift" +++ /dev/null @@ -1,28 +0,0 @@ -// -// AppUsageDataEntity.swift -// Campus-iOS -// -// Created by Robyn Kölle on 10.09.22. -// - -import Foundation -import CoreData - -extension AppUsageDataEntity { - - convenience init(data: AppUsageData, context: NSManagedObjectContext) throws { - - guard let view = data.getView()?.rawValue, - let startTime = data.getStartTime(), - let endTime = data.getEndTime() else { - throw AnalyticsError.missingValues - } - - self.init(context: context) - self.view = view - self.startTime = startTime - self.endTime = endTime - self.latitude = data.getLatitude() ?? AppUsageData.invalidLocation - self.longitude = data.getLongitude() ?? AppUsageData.invalidLocation - } -} diff --git "a/Campus-iOS/AnalyticsCom\303\274o/HashFunction.swift" "b/Campus-iOS/AnalyticsCom\303\274o/HashFunction.swift" deleted file mode 100644 index 656dc19d..00000000 --- "a/Campus-iOS/AnalyticsCom\303\274o/HashFunction.swift" +++ /dev/null @@ -1,20 +0,0 @@ -// -// HashFunction.swift -// Campus-iOS -// -// Created by Robyn Kölle on 10.09.22. -// - -import Foundation -import CryptoKit - -struct HashFunction { - - // Source: - // https://www.hackingwithswift.com/example-code/cryptokit/how-to-calculate-the-sha-hash-of-a-string-or-data-instance - static func sha256(_ string: String) -> String { - let inputData = Data(string.utf8) - let hashed = SHA256.hash(data: inputData) - return hashed.compactMap { String(format: "%02x", $0) }.joined() - } -} diff --git "a/Campus-iOS/AnalyticsCom\303\274o/Secrets.xcconfig" "b/Campus-iOS/AnalyticsCom\303\274o/Secrets.xcconfig" deleted file mode 100644 index d851db4c..00000000 --- "a/Campus-iOS/AnalyticsCom\303\274o/Secrets.xcconfig" +++ /dev/null @@ -1,12 +0,0 @@ -// -// Secrets.xcconfig -// Campus-iOS -// -// Created by Robyn Kölle on 10.09.22. -// - -// Configuration settings file format documentation can be found at: -// https://help.apple.com/xcode/#/dev745c5c974 - -ANALYTICS_API = -ANALYTICS_POST_TOKEN = diff --git a/Campus-iOS/App.swift b/Campus-iOS/App.swift index 5efdf8e5..7045d8f8 100644 --- a/Campus-iOS/App.swift +++ b/Campus-iOS/App.swift @@ -11,6 +11,7 @@ import KVKCalendar import Firebase @main +@MainActor struct CampusApp: App { @StateObject var model: Model = Model() @@ -46,13 +47,29 @@ struct CampusApp: App { }) .environmentObject(model) .environment(\.managedObjectContext, persistenceController.container.viewContext) + .task { + guard let credentials = model.loginController.credentials else { + model.isUserAuthenticated = false + model.isLoginSheetPresented = true + return + } + + switch credentials { + case .noTumID: + model.isUserAuthenticated = false + model.isLoginSheetPresented = false + case .tumID(tumID: _, token: _), .tumIDAndKey(tumID: _, token: _, key: _): + model.isUserAuthenticated = true + model.isLoginSheetPresented = false + } + } } } func tabViewComponent() -> some View { TabView(selection: $selectedTab) { NavigationView { - CalendarContentView( + CalendarScreen( model: model, refresh: $model.isUserAuthenticated ) diff --git a/Campus-iOS/Base/Enums/Enums.swift b/Campus-iOS/Base/Enums/Enums.swift index d7eb5836..c35e9e89 100644 --- a/Campus-iOS/Base/Enums/Enums.swift +++ b/Campus-iOS/Base/Enums/Enums.swift @@ -31,7 +31,22 @@ enum Gender: Decodable, Hashable { } } -enum ContactInfo { +enum ContactInfo: Identifiable { + var id: String { + switch self { + case .phone(let phone): + return phone + case .mobilePhone(let mobile): + return mobile + case .fax(let fax): + return fax + case .additionalInfo(let info): + return info + case .homepage(let homepage): + return homepage + } + } + case phone(String) case mobilePhone(String) case fax(String) diff --git a/Campus-iOS/Base/Networking/APIErrors/EatAPIError.swift b/Campus-iOS/Base/Networking/APIErrors/EatAPIError.swift new file mode 100644 index 00000000..d111c5ae --- /dev/null +++ b/Campus-iOS/Base/Networking/APIErrors/EatAPIError.swift @@ -0,0 +1,37 @@ +// +// EatAPIError.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation + +enum EatAPIError: APIError, LocalizedError { + case unknown(String) + + enum CodingKeys: String, CodingKey { + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let error = try container.decode(String.self, forKey: .message) + + switch error { + default: + self = .unknown(error) + } + } + + init(message: String) { + self = .unknown(message) + } + + public var errorDescription: String? { + switch self { + case let .unknown(message): + return "\("Unkonw error".localized): \(message)" + } + } +} diff --git a/Campus-iOS/Base/Networking/APIErrors/MVGAPIError.swift b/Campus-iOS/Base/Networking/APIErrors/MVGAPIError.swift new file mode 100644 index 00000000..e240661f --- /dev/null +++ b/Campus-iOS/Base/Networking/APIErrors/MVGAPIError.swift @@ -0,0 +1,37 @@ +// +// MVGAPIError.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation + +enum MVGAPIError: APIError, LocalizedError { + case unknown(String) + + enum CodingKeys: String, CodingKey { + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let error = try container.decode(String.self, forKey: .message) + + switch error { + default: + self = .unknown(error) + } + } + + init(message: String) { + self = .unknown(message) + } + + public var errorDescription: String? { + switch self { + case let .unknown(message): + return "\("Unkonw error".localized): \(message)" + } + } +} diff --git a/Campus-iOS/Base/Networking/APIErrors/NavigaTUMAPIError.swift b/Campus-iOS/Base/Networking/APIErrors/NavigaTUMAPIError.swift new file mode 100644 index 00000000..280a03df --- /dev/null +++ b/Campus-iOS/Base/Networking/APIErrors/NavigaTUMAPIError.swift @@ -0,0 +1,37 @@ +// +// NAvigaTUMAPIError.swift +// Campus-iOS +// +// Created by David Lin on 10.04.23. +// + +import Foundation + +enum NavigaTUMAPIError: APIError, LocalizedError { + case unknown(String) + + enum CodingKeys: String, CodingKey { + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let error = try container.decode(String.self, forKey: .message) + + switch error { + default: + self = .unknown(error) + } + } + + init(message: String) { + self = .unknown(message) + } + + public var errorDescription: String? { + switch self { + case let .unknown(message): + return "\("Unkonw error".localized): \(message)" + } + } +} diff --git a/Campus-iOS/Base/Networking/APIErrors/TUMCabeAPIError.swift b/Campus-iOS/Base/Networking/APIErrors/TUMCabeAPIError.swift new file mode 100644 index 00000000..bc4b762b --- /dev/null +++ b/Campus-iOS/Base/Networking/APIErrors/TUMCabeAPIError.swift @@ -0,0 +1,37 @@ +// +// TUMCabeAPIError.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation + +enum TUMCabeAPIError: APIError, LocalizedError { + case unknown(String) + + enum CodingKeys: String, CodingKey { + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let error = try container.decode(String.self, forKey: .message) + + switch error { + default: + self = .unknown(error) + } + } + + init(message: String) { + self = .unknown(message) + } + + public var errorDescription: String? { + switch self { + case let .unknown(message): + return "\("Unkonw error".localized): \(message)" + } + } +} diff --git a/Campus-iOS/Base/Networking/APIErrors/TUMDevAppAPIError.swift b/Campus-iOS/Base/Networking/APIErrors/TUMDevAppAPIError.swift new file mode 100644 index 00000000..75158695 --- /dev/null +++ b/Campus-iOS/Base/Networking/APIErrors/TUMDevAppAPIError.swift @@ -0,0 +1,37 @@ +// +// TUMDevAppAPI.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation + +enum TUMDevAppAPIError: APIError, LocalizedError { + case unknown(String) + + enum CodingKeys: String, CodingKey { + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let error = try container.decode(String.self, forKey: .message) + + switch error { + default: + self = .unknown(error) + } + } + + init(message: String) { + self = .unknown(message) + } + + public var errorDescription: String? { + switch self { + case let .unknown(message): + return "\("Unkonw error".localized): \(message)" + } + } +} diff --git a/Campus-iOS/Base/Networking/APIErrors/TUMOnlineAPIError.swift b/Campus-iOS/Base/Networking/APIErrors/TUMOnlineAPIError.swift new file mode 100644 index 00000000..68f785bf --- /dev/null +++ b/Campus-iOS/Base/Networking/APIErrors/TUMOnlineAPIError.swift @@ -0,0 +1,66 @@ +// +// TUMOnlineAPIError.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation + +enum TUMOnlineAPIError: APIError, LocalizedError { + case noPermission + case tokenNotConfirmed + case invalidToken + case unknown(String) + + enum CodingKeys: String, CodingKey { + case message = "message" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let error = try container.decode(String.self, forKey: .message) + + switch error { + case let str where str.contains("Keine Rechte für Funktion"): + self = .noPermission + case "Token ist nicht bestätigt!": + self = .tokenNotConfirmed + case "Token ist ungültig!": + self = .invalidToken + default: + self = .unknown(error) + } + } + + init(message: String) { + self = .unknown(message) + } + + public var errorDescription: String? { + switch self { + case .noPermission: + return "No Permission".localized + case .tokenNotConfirmed: + return "Token not confirmed".localized + case .invalidToken: + return "Token invalid".localized + case let .unknown(message): + return "\("Unkonw error".localized): \(message)" + + } + } + + public var recoverySuggestion: String? { + switch self { + case .noPermission: + return "Make sure to enable the right permissions for your token.".localized + case .tokenNotConfirmed: + return "Go to TUMonline and confirm your token.".localized + case .invalidToken: + return "Try creating a new token.".localized + default: + return nil + } + } +} diff --git a/Campus-iOS/Base/Networking/APIErrors/TUMSexyAPIError.swift b/Campus-iOS/Base/Networking/APIErrors/TUMSexyAPIError.swift new file mode 100644 index 00000000..09356b20 --- /dev/null +++ b/Campus-iOS/Base/Networking/APIErrors/TUMSexyAPIError.swift @@ -0,0 +1,37 @@ +// +// TUMSexyAPIError.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation + +enum TUMSexyAPIError: APIError, LocalizedError { + case unknown(String) + + enum CodingKeys: String, CodingKey { + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let error = try container.decode(String.self, forKey: .message) + + switch error { + default: + self = .unknown(error) + } + } + + init(message: String) { + self = .unknown(message) + } + + public var errorDescription: String? { + switch self { + case let .unknown(message): + return "\("Unkonw error".localized): \(message)" + } + } +} diff --git a/Campus-iOS/Base/Networking/APIs/EatAPI.swift b/Campus-iOS/Base/Networking/APIs/EatAPI.swift new file mode 100644 index 00000000..0bdbe9a4 --- /dev/null +++ b/Campus-iOS/Base/Networking/APIs/EatAPI.swift @@ -0,0 +1,46 @@ +// +// EatAPI.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation +import Alamofire + +enum EatAPI: API { + case canteens + case languages + case labels + case all + case all_ref + case menu(location: String, year: Int = Date().year, week: Int = Date().weekOfYear) + + static var baseURL: String = "https://tum-dev.github.io/eat-api/" + + static var baseHeaders: Alamofire.HTTPHeaders = [] + + static var error: APIError.Type = EatAPIError.self + + var paths: String { + switch self { + case .canteens: return "enums/canteens.json" + case .languages: return "enums/languages.json" + case .labels: return "enums/labels.json" + case .all: return "all.json" + case .all_ref: return "all_ref.json" + case let .menu(location, year, week): return "\(location)/\(year)/\(String(format: "%02d", week)).json" + } + } + + var parameters: [String : String] { [:] } + + var needsAuth: Bool { false } + + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable { + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .formatted(DateFormatter.yyyyMMdd) + + return try jsonDecoder.decode(type, from: data) + } +} diff --git a/Campus-iOS/Base/Networking/APIs/MVGAPI.swift b/Campus-iOS/Base/Networking/APIs/MVGAPI.swift new file mode 100644 index 00000000..f4b7cff7 --- /dev/null +++ b/Campus-iOS/Base/Networking/APIs/MVGAPI.swift @@ -0,0 +1,55 @@ +// +// MVGAPI.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation +import Alamofire + +enum MVGAPI: API { + case nearby(latitude: String, longitude: String) + case departure(id: Int) + case station(name: String) + case id(id: Int) + case interruptions + + static var baseURL: String = "https://www.mvg.de/" + + static let apiKey = "5af1beca494712ed38d313714d4caff6" + static var baseHeaders: Alamofire.HTTPHeaders = ["X-MVG-Authorization-Key": apiKey] + + static var error: APIError.Type = MVGAPIError.self + + var paths: String { + switch self { + case .nearby: return "fahrinfo/api/location/nearby" + case .departure(let id): return "fahrinfo/api/departure/\(id)" + case .station: return "fahrinfo/api/location/queryWeb" + case .id: return "fahrinfo/api/location/query" + case .interruptions: return ".rest/betriebsaenderungen/api/interruption" + } + } + + var parameters: [String : String] { + switch self { + case .station(name: let name): + return ["q": name] + case .id(id: let id): + return ["q": String(id)] + case .departure(id: _): + return ["footway": String(0)] + case .nearby(latitude: let latitude, longitude: let longitude): + return ["latitude": latitude, "longitude": longitude] + default: + return [:] + } + } + + var needsAuth: Bool { false } + + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable { + return try JSONDecoder().decode(type, from: data) + } +} diff --git a/Campus-iOS/Base/Networking/APIs/NavigaTUMAPI.swift b/Campus-iOS/Base/Networking/APIs/NavigaTUMAPI.swift new file mode 100644 index 00000000..65994ad9 --- /dev/null +++ b/Campus-iOS/Base/Networking/APIs/NavigaTUMAPI.swift @@ -0,0 +1,50 @@ +// +// NavigaTUMAPI.swift +// Campus-iOS +// +// Created by David Lin on 10.04.23. +// + +import Foundation +import Alamofire + +enum NavigaTUMAPI: API { + case search(query: String) + case details(id: String, language: String) + case images(id: String) + case overlayImages(id: String) + + static var baseURL: String = "https://nav.tum.de/" + + static var baseHeaders: Alamofire.HTTPHeaders = [] + + static var error: APIError.Type = NavigaTUMAPIError.self + + var paths: String { + switch self { + case .search: return "api/search" + case .details(let id, _): return "api/get" + "/" + id + case .images(let id): return "cdn/maps/roomfinder" + "/" + id + case .overlayImages(let id): return "cdn/maps/overlay" + "/" + id + } + } + + var parameters: [String : String] { + switch self { + case .search(let query): return ["q": query] + case .details(_, let language): return ["lang": language] + case .images(_): return [:] + case .overlayImages(_): return [:] + } + } + + var needsAuth: Bool { + switch self { + case .search, .details, .images, .overlayImages: return false + } + } + + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable { + return try JSONDecoder().decode(type, from: data) + } +} diff --git a/Campus-iOS/Base/Networking/TUMCabeAPI.swift b/Campus-iOS/Base/Networking/APIs/TUMCabeAPI.swift similarity index 74% rename from Campus-iOS/Base/Networking/TUMCabeAPI.swift rename to Campus-iOS/Base/Networking/APIs/TUMCabeAPI.swift index 2159737f..40638e9a 100644 --- a/Campus-iOS/Base/Networking/TUMCabeAPI.swift +++ b/Campus-iOS/Base/Networking/APIs/TUMCabeAPI.swift @@ -1,15 +1,16 @@ // // TUMCabeAPI.swift -// TUM Campus App +// Campus-iOS // -// Created by Tim Gymnich on 1/4/19. -// Copyright © 2019 TUM. All rights reserved. +// Created by David Lin on 10.02.23. // +import Foundation import Alamofire import UIKit -enum TUMCabeAPI: URLRequestConvertible { +enum TUMCabeAPI: API { + // Different data types of data which determine the path, parameters and if authentication is needed. case movie case cafeteria case news(source: String) @@ -30,14 +31,14 @@ enum TUMCabeAPI: URLRequestConvertible { case ticketPurchase case stripeKey - static let baseURLString = "https://app.tum.de/api" - static let serverTrustPolicies: [String: ServerTrustEvaluating] = ["app.tum.de" : PinnedCertificatesTrustEvaluator()] + static let baseURL = "https://app.tum.de/api/" static let baseHeaders: HTTPHeaders = ["X-DEVICE-ID": UIDevice.current.identifierForVendor?.uuidString ?? "not available", "X-APP-VERSION": Bundle.main.version, "X-APP-BUILD": Bundle.main.build, "X-OS-VERSION": UIDevice.current.systemVersion,] + static var error: APIError.Type = TUMCabeAPIError.self - var path: String { + var paths: String { switch self { case .movie: return "kino" case .cafeteria: return "mensen" @@ -45,7 +46,7 @@ enum TUMCabeAPI: URLRequestConvertible { case .newsSources: return "news/sources" case .newsAlert: return "news/alert" case .roomSearch(let room): return "roomfinder/room/search/\(room.addingPercentEncoding(withAllowedCharacters: .afURLQueryAllowed) ?? "")" - case .roomMaps(let room): return "roomfinder/room/availableMaps/\(room)" + case .roomMaps(let room): return "roomfinder/room/availableMaps/\(room.addingPercentEncoding(withAllowedCharacters: .afURLQueryAllowed) ?? "")" case .roomCoordinates(let room): return "roomfinder/room/coordinates/\(room)" case .defaultMap(let room): return "roomfinder/room/defaultMap/\(room)" case .mapImage(let room, let id): return "roomfinder/room/map/\(room)/\(id)" @@ -61,17 +62,25 @@ enum TUMCabeAPI: URLRequestConvertible { } } - var method: HTTPMethod { - switch self { - default: return .get - } + var parameters: [String : String] { + return [:] } - static var requiresAuth: [String] = [] + var needsAuth: Bool { + // No authentication needed + return false + } + + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable { + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .formatted(DateFormatter.yyyyMMddhhmmss) + + return try jsonDecoder.decode(type, from: data) + } func asURLRequest() throws -> URLRequest { - let url = try TUMCabeAPI.baseURLString.asURL() - let urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method, headers: TUMCabeAPI.baseHeaders) + let url = try Self.baseURL.asURL() + let urlRequest = try URLRequest(url: url.appendingPathComponent(paths), method: .get, headers: Self.baseHeaders) return urlRequest } } diff --git a/Campus-iOS/Base/Networking/APIs/TUMDevAppAPI.swift b/Campus-iOS/Base/Networking/APIs/TUMDevAppAPI.swift new file mode 100644 index 00000000..54f5fbb7 --- /dev/null +++ b/Campus-iOS/Base/Networking/APIs/TUMDevAppAPI.swift @@ -0,0 +1,41 @@ +// +// TUMDevAppAPI.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation +import Alamofire + +enum TUMDevAppAPI: API { + case room(roomNr: Int) + case rooms + + static var baseURL: String = "https://www.devapp.it.tum.de/" + + static var baseHeaders: Alamofire.HTTPHeaders = [] + + static var error: APIError.Type = TUMDevAppAPIError.self + + var paths: String { + switch self { + case .room, .rooms: return "iris/ris_api.php" + } + } + + var parameters: [String : String] { + switch self { + case .room(roomNr: let roomNr): + return ["format": "json", "raum": String(roomNr)] + case .rooms: + return ["format": "json"] + } + } + + var needsAuth: Bool { false } + + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable { + return try JSONDecoder().decode(type, from: data) + } +} diff --git a/Campus-iOS/Base/Networking/APIs/TUMOnlineAPI.swift b/Campus-iOS/Base/Networking/APIs/TUMOnlineAPI.swift new file mode 100644 index 00000000..3fcc4810 --- /dev/null +++ b/Campus-iOS/Base/Networking/APIs/TUMOnlineAPI.swift @@ -0,0 +1,117 @@ +// +// TUMOnlineAPI.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation +import Alamofire +import XMLCoder +import UIKit + +enum TUMOnlineAPI: API { + // Different data types of data which determine the path, parameters and if authentication is needed. + case personSearch(search: String) + case tokenRequest(tumID: String, tokenName: String?) + case tokenConfirmation + case tuitionStatus + case calendar + case personDetails(identNumber: String) + case personalLectures + case personalGrades + case lectureSearch(search: String) + case lectureDetails(lvNr: String) + case identify + case secretUpload + case profileImage(personGroup: String, id: String) + + + static let baseURL: String = "https://campus.tum.de/tumonline/" + + static let baseHeaders: Alamofire.HTTPHeaders = [] + + static var error: APIError.Type = TUMOnlineAPIError.self + + var paths: String { + switch self { + case .personSearch: return "wbservicesbasic.personenSuche" + case .tokenRequest: return "wbservicesbasic.requestToken" + case .tokenConfirmation: return "wbservicesbasic.isTokenConfirmed" + case .tuitionStatus: return "wbservicesbasic.studienbeitragsstatus" + case .calendar: return "wbservicesbasic.kalender" + case .personDetails: return "wbservicesbasic.personenDetails" + case .personalLectures: return "wbservicesbasic.veranstaltungenEigene" + case .personalGrades: return "wbservicesbasic.noten" + case .lectureSearch: return "wbservicesbasic.veranstaltungenSuche" + case .lectureDetails: return "wbservicesbasic.veranstaltungenDetails" + case .identify: return "wbservicesbasic.id" + case .secretUpload: return "wbservicesbasic.secretUpload" + case .profileImage: return "visitenkarte.showImage" + } + } + + var parameters: [String : String] { + switch self { + case .personSearch(search: let search): + return ["pSuche": search] + case .tokenRequest(tumID: let tumID, tokenName: let tokenName): + let tokenName = tokenName ?? "TCA - \(UIDevice.current.name)" + return ["pUsername" : tumID, "pTokenName" : tokenName] + case .personDetails(identNumber: let identNumber): + return ["pIdentNr": identNumber] + case .lectureSearch(search: let search): + return ["pSuche": search] + case .lectureDetails(lvNr: let lvNr): + return ["pLVNr": lvNr] + case .profileImage(personGroup: let personGroup, id: let id): + return ["pPersonenGruppe": personGroup, "pPersonenId": id] + default: + return [:] + } + } + + var needsAuth: Bool { + switch self { + case .personSearch(search: _), + .tokenConfirmation, + .tuitionStatus, + .calendar, + .personDetails(identNumber: _), + .personalLectures, + .personalGrades, + .lectureSearch(search: _), + .lectureDetails(_), + .identify: return true + default: + return false + } + } + + var dateDecodingStrategy: DateFormatter { + switch self { + case .calendar : + return DateFormatter.yyyyMMddhhmmss + default : + return DateFormatter.yyyyMMdd + } + } + + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable { + let xmlDecoder = XMLDecoder() + xmlDecoder.dateDecodingStrategy = .formatted(self.dateDecodingStrategy) + + return try xmlDecoder.decode(type, from: data) + } + + struct Response: Decodable { + public var row: [T] + } + + struct CalendarResponse: Decodable { + // This is needed because for .calendar the response is not "rowset" and "row", instead it is "events" and "event" + public var event: [CalendarEvent] + } + + static let imageCache = Cache(totalCostLimit: 500_000, countLimit: 1_000, entryLifetime: 10 * 60) +} diff --git a/Campus-iOS/Base/Networking/APIs/TUMSexyAPI.swift b/Campus-iOS/Base/Networking/APIs/TUMSexyAPI.swift new file mode 100644 index 00000000..31df30e4 --- /dev/null +++ b/Campus-iOS/Base/Networking/APIs/TUMSexyAPI.swift @@ -0,0 +1,29 @@ +// +// TUMSexyAPI.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation +import Alamofire + +enum TUMSexyAPI: API { + case standard + + static var baseURL: String = "https://json.tum.sexy/" + + static var baseHeaders: Alamofire.HTTPHeaders = [] + + static var error: APIError.Type = TUMSexyAPIError.self + + var paths: String { "" } + + var parameters: [String : String] { [:] } + + var needsAuth: Bool { false } + + func decode(_ type: T.Type, from data: Data) throws -> T where T : Decodable { + return try JSONDecoder().decode(type, from: data) + } +} diff --git a/Campus-iOS/Base/Networking/CampusOnlineAPI.swift b/Campus-iOS/Base/Networking/CampusOnlineAPI.swift index 1c3310b0..3c0d874f 100644 --- a/Campus-iOS/Base/Networking/CampusOnlineAPI.swift +++ b/Campus-iOS/Base/Networking/CampusOnlineAPI.swift @@ -23,9 +23,9 @@ struct CampusOnlineAPI: NetworkingAPI { // Maximum size of cache: 500kB, Maximum cache entries: 1000, Lifetime: 10min static let cache = Cache(totalCostLimit: 500_000, countLimit: 1_000, entryLifetime: 10 * 60) - static func makeRequest(endpoint: APIConstants, token: String? = nil, forcedRefresh: Bool = false) async throws -> T { + static func makeRequest(endpoint: APIConstants, token: String? = nil, forcedRefresh: Bool? = false) async throws -> T { // Check cache first - if !forcedRefresh, + if !(forcedRefresh ?? false), let data = cache.value(forKey: endpoint.fullRequestURL), let typedData = data as? T { return typedData diff --git a/Campus-iOS/Base/Networking/EatAPI.swift b/Campus-iOS/Base/Networking/EatAPI.swift deleted file mode 100644 index bffb46a3..00000000 --- a/Campus-iOS/Base/Networking/EatAPI.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// EatAPI.swift -// Campus-iOS -// -// Created by August Wittgenstein on 22.12.21. -// - -import Alamofire -import Foundation -import CoreLocation - -enum EatAPI: URLRequestConvertible { - case canteens - case languages - case labels - case all - case all_ref - case menu(location: String, year: Int = Date().year, week: Int = Date().weekOfYear) - - static let baseURLString = "https://tum-dev.github.io/eat-api/" - - var method: HTTPMethod { - switch self { - default: - return .get - } - } - - var path: String { - switch self { - case .canteens: return "enums/canteens.json" - case .languages: return "enums/languages.json" - case .labels: return "enums/labels.json" - case .all: return "all.json" - case .all_ref: return "all_ref.json" - case let .menu(location, year, week): return "\(location)/\(year)/\(String(format: "%02d", week)).json" - } - } - - // MARK: URLRequestConvertible - func asURLRequest() throws -> URLRequest { - let url = try EatAPI.baseURLString.asURL() - let urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method) - return urlRequest - } - - static let decoder = JSONDecoder() - - // Maximum size of cache: 500kB, Maximum cache entries: 1000, Lifetime: 10min - static let cache = Cache(totalCostLimit: 500_000, countLimit: 1_000, entryLifetime: 10 * 60) - - static func fetchCafeterias(forcedRefresh: Bool) async throws -> [Cafeteria] { - - let fullRequestURL = baseURLString + self.canteens.path - - if !forcedRefresh, let rawCafeterias = cache.value(forKey: baseURLString + self.canteens.path), let cafeterias = rawCafeterias as? [Cafeteria] { - print("Canteen data from cache") - return cafeterias - } else { - print("Canteen data from server") - // Fetch new data and store in cache. - var cafeteriaData: Data - do { - cafeteriaData = try await AF.request(self.canteens).serializingData().value - } catch { - print(error) - throw NetworkingError.deviceIsOffline - } - - var cafeteriasWithoutQueue = [Cafeteria]() - do { - cafeteriasWithoutQueue = try decoder.decode([Cafeteria].self, from: cafeteriaData) - } catch { - print(error) - throw error - } - - - - // Requesting the queue data if there is an API for the cafeteria. - var cafeterias = cafeteriasWithoutQueue - - for i in cafeterias.indices { - var queueData: Data - if let queue = cafeterias[i].queueStatusApi { - print("NAME " + cafeterias[i].name) - do { - queueData = try await AF.request(queue).serializingData().value - } catch { - print(error) - throw NetworkingError.deviceIsOffline - } - - do { - cafeterias[i].queue = try decoder.decode(Queue.self, from: queueData) - } catch { - throw error - } - } - } - - // Write value to cache - cache.setValue(cafeterias, forKey: fullRequestURL, cost: cafeterias.count) - return cafeterias - } - } -} diff --git a/Campus-iOS/Base/Networking/MVGAPI.swift b/Campus-iOS/Base/Networking/MVGAPI.swift deleted file mode 100644 index fa4a6a14..00000000 --- a/Campus-iOS/Base/Networking/MVGAPI.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// MVGAPI.swift -// TUM Campus App -// -// Created by Tim Gymnich on 2/23/19. -// Copyright © 2019 TUM. All rights reserved. -// - -import Foundation -import Alamofire - -enum MVGAPI: URLRequestConvertible { - case nearby(latitude: String, longitude: String) - case departure(id: Int) - case station(name: String) - case id(id: Int) - case interruptions - - static let baseURL = "https://www.mvg.de" - static let apiKey = "5af1beca494712ed38d313714d4caff6" - static let baseHeaders: HTTPHeaders = ["X-MVG-Authorization-Key": apiKey] - - var path: String { - switch self { - case .nearby: return "fahrinfo/api/location/nearby" - case .departure(let id): return "fahrinfo/api/departure/\(id)" - case .station: return "fahrinfo/api/location/queryWeb" - case .id: return "fahrinfo/api/location/query" - case .interruptions: return ".rest/betriebsaenderungen/api/interruption" - } - } - - var method: HTTPMethod { - switch self { - default: return .get - } - } - - static var requiresAuth: [String] = [] - - func asURLRequest() throws -> URLRequest { - let url = try MVGAPI.baseURL.asURL() - var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method) - - switch self { - - case .station(let name): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["q": name]) - case .id(let id): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["q": id]) - case .departure: - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["footway": 0]) - case .nearby(let latitude, let longitude): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["latitude": latitude, "longitude": longitude]) - default: - break - } - - return urlRequest - } -} diff --git a/Campus-iOS/Base/Networking/NetworkingAPI.swift b/Campus-iOS/Base/Networking/NetworkingAPI.swift index eab4f628..f151d479 100644 --- a/Campus-iOS/Base/Networking/NetworkingAPI.swift +++ b/Campus-iOS/Base/Networking/NetworkingAPI.swift @@ -14,5 +14,5 @@ protocol NetworkingAPI { static var decoder: DecoderType { get } static var cache: Cache { get } - static func makeRequest(endpoint: APIConstants, token: String?, forcedRefresh: Bool) async throws -> T + static func makeRequest(endpoint: APIConstants, token: String?, forcedRefresh: Bool?) async throws -> T } diff --git a/Campus-iOS/Base/Networking/Old APIs/APIResponse.swift b/Campus-iOS/Base/Networking/Old APIs/APIResponse.swift new file mode 100644 index 00000000..ca204757 --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/APIResponse.swift @@ -0,0 +1,54 @@ +// +// APIResponse.swift +// TUM Campus App +// +// Created by Tim Gymnich on 2/27/19. +// Copyright © 2019 TUM. All rights reserved. +// + +import Foundation +import FirebaseCrashlytics + +//struct APIResponse: Decodable { +// var response: ResponseType +// +// init(from decoder: Decoder) throws { +// if let error = try? ErrorType(from: decoder) { +// throw error +// } else { +// let response = try ResponseType(from: decoder) +// self.response = response +// } +// } +//} + +//struct TUMOnlineAPIResponse: Decodable { +// var rows: [T]? +// +// enum CodingKeys: String, CodingKey { +// case rows = "row" +// } +// +// init(from decoder: Decoder) throws { +// let container = try decoder.container(keyedBy: CodingKeys.self) +// self.rows = try container.decode([Throwable].self, forKey: .rows).compactMap { +// do { +// return try $0.result.get() +// } +// catch { +// Crashlytics.crashlytics().record(error: error) +// return nil +// } +// } +// } +//} +// +//struct Throwable: Decodable { +// let result: Result +// +// init(from decoder: Decoder) throws { +// result = Result(catching: { try T(from: decoder) }) +// } +//} + + diff --git a/Campus-iOS/Base/Networking/Old APIs/CampusOnlineAPIOld.swift b/Campus-iOS/Base/Networking/Old APIs/CampusOnlineAPIOld.swift new file mode 100644 index 00000000..77a6569b --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/CampusOnlineAPIOld.swift @@ -0,0 +1,119 @@ +// +// CampusOnlineAPI.swift +// Campus-iOS +// +// Created by Philipp Zagar on 22.12.21. +// + +import Foundation +import Alamofire +import XMLCoder + +//struct CampusOnlineAPI: NetworkingAPI { +// static let decoder: XMLDecoder = { +// let decoder = XMLDecoder() +// +// let dateFormatter = DateFormatter() +// dateFormatter.dateFormat = "yyyy-MM-dd" +// decoder.dateDecodingStrategy = .formatted(dateFormatter) +// +// return decoder +// }() +// +// // Maximum size of cache: 500kB, Maximum cache entries: 1000, Lifetime: 10min +// static let cache = Cache(totalCostLimit: 500_000, countLimit: 1_000, entryLifetime: 10 * 60) +// +// static func makeRequest(endpoint: APIConstants, token: String? = nil, forcedRefresh: Bool = false) async throws -> T { +// // Check cache first +// if !forcedRefresh, +// let data = cache.value(forKey: endpoint.fullRequestURL), +// let typedData = data as? T { +// return typedData +// // Otherwise make the request +// } else { +// var data: Data +// do { +// data = try await endpoint.asRequest(token: token).serializingData().value +// } catch { +// print(error) +// throw NetworkingError.deviceIsOffline +// } +// +// // Check this first cause otherwise no error is thrown by the XMLDecoder +// if let error = try? Self.decoder.decode(Error.self, from: data) { +// print(error) +// throw error +// } +// +// do { +// let decodedData = try Self.decoder.decode(T.self, from: data) +// +// // Write value to cache +// cache.setValue(decodedData, forKey: endpoint.fullRequestURL, cost: data.count) +// +// return decodedData +// } catch { +// print(error) +// throw Error.unknown(error.localizedDescription) +// } +// } +// } +// +// enum Error: APIError { +// case noPermission +// case tokenNotConfirmed +// case invalidToken +// case unknown(String) +// +// enum CodingKeys: String, CodingKey { +// case message = "message" +// } +// +// init(from decoder: Decoder) throws { +// let container = try decoder.container(keyedBy: CodingKeys.self) +// let error = try container.decode(String.self, forKey: .message) +// +// switch error { +// case let str where str.contains("Keine Rechte für Funktion"): +// self = .noPermission +// case "Token ist nicht bestätigt!": +// self = .tokenNotConfirmed +// case "Token ist ungültig!": +// self = .invalidToken +// default: +// self = .unknown(error) +// } +// } +// +// init(message: String) { +// self = .unknown(message) +// } +// +// public var errorDescription: String? { +// switch self { +// case .noPermission: +// return "No Permission".localized +// case .tokenNotConfirmed: +// return "Token not confirmed".localized +// case .invalidToken: +// return "Token invalid".localized +// case let .unknown(message): +// return "Unknown error".localized + ": \(message)" +// +// } +// } +// +// public var recoverySuggestion: String? { +// switch self { +// case .noPermission: +// return "Make sure to enable the right permissions for your token." +// case .tokenNotConfirmed: +// return "Go to TUMonline and confirm your token." +// case .invalidToken: +// return "Try creating a new token." +// default: +// return nil +// } +// } +// } +//} diff --git a/Campus-iOS/Base/Networking/Old APIs/EatAPIOld.swift b/Campus-iOS/Base/Networking/Old APIs/EatAPIOld.swift new file mode 100644 index 00000000..597124de --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/EatAPIOld.swift @@ -0,0 +1,106 @@ +// +// EatAPI.swift +// Campus-iOS +// +// Created by August Wittgenstein on 22.12.21. +// + +import Alamofire +import Foundation +import CoreLocation + +//enum EatAPI: URLRequestConvertible { +// case canteens +// case languages +// case labels +// case all +// case all_ref +// case menu(location: String, year: Int = Date().year, week: Int = Date().weekOfYear) +// +// static let baseURLString = "https://tum-dev.github.io/eat-api/" +// +// var method: HTTPMethod { +// switch self { +// default: +// return .get +// } +// } +// +// var path: String { +// switch self { +// case .canteens: return "enums/canteens.json" +// case .languages: return "enums/languages.json" +// case .labels: return "enums/labels.json" +// case .all: return "all.json" +// case .all_ref: return "all_ref.json" +// case let .menu(location, year, week): return "\(location)/\(year)/\(String(format: "%02d", week)).json" +// } +// } +// +// // MARK: URLRequestConvertible +// func asURLRequest() throws -> URLRequest { +// let url = try EatAPI.baseURLString.asURL() +// let urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method) +// return urlRequest +// } +// +// static let decoder = JSONDecoder() +// +// // Maximum size of cache: 500kB, Maximum cache entries: 1000, Lifetime: 10min +// static let cache = Cache(totalCostLimit: 500_000, countLimit: 1_000, entryLifetime: 10 * 60) +// +// static func fetchCafeterias(forcedRefresh: Bool) async throws -> [Cafeteria] { +// +// let fullRequestURL = baseURLString + self.canteens.path +// +// if !forcedRefresh, let rawCafeterias = cache.value(forKey: baseURLString + self.canteens.path), let cafeterias = rawCafeterias as? [Cafeteria] { +// +// return cafeterias +// } else { +// // Fetch new data and store in cache. +// var cafeteriaData: Data +// do { +// cafeteriaData = try await AF.request(self.canteens).serializingData().value +// } catch { +// print(error) +// throw NetworkingError.deviceIsOffline +// } +// +// var cafeteriasWithoutQueue = [Cafeteria]() +// do { +// cafeteriasWithoutQueue = try decoder.decode([Cafeteria].self, from: cafeteriaData) +// } catch { +// print(error) +// throw error +// } +// +// +// +// // Requesting the queue data if there is an API for the cafeteria. +// var cafeterias = cafeteriasWithoutQueue +// +// for i in cafeterias.indices { +// var queueData: Data +// if let queue = cafeterias[i].queueStatusApi { +// print("NAME " + cafeterias[i].name) +// do { +// queueData = try await AF.request(queue).serializingData().value +// } catch { +// print(error) +// throw NetworkingError.deviceIsOffline +// } +// +// do { +// cafeterias[i].queue = try decoder.decode(Queue.self, from: queueData) +// } catch { +// throw error +// } +// } +// } +// +// // Write value to cache +// cache.setValue(cafeterias, forKey: fullRequestURL, cost: cafeterias.count) +// return cafeterias +// } +// } +//} diff --git a/Campus-iOS/Base/Networking/Old APIs/MVGAPIOld.swift b/Campus-iOS/Base/Networking/Old APIs/MVGAPIOld.swift new file mode 100644 index 00000000..58922958 --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/MVGAPIOld.swift @@ -0,0 +1,61 @@ +// +// MVGAPI.swift +// TUM Campus App +// +// Created by Tim Gymnich on 2/23/19. +// Copyright © 2019 TUM. All rights reserved. +// + +import Foundation +import Alamofire + +//enum MVGAPI: URLRequestConvertible { +// case nearby(latitude: String, longitude: String) +// case departure(id: Int) +// case station(name: String) +// case id(id: Int) +// case interruptions +// +// static let baseURL = "https://www.mvg.de" +// static let apiKey = "5af1beca494712ed38d313714d4caff6" +// static let baseHeaders: HTTPHeaders = ["X-MVG-Authorization-Key": apiKey] +// +// var path: String { +// switch self { +// case .nearby: return "fahrinfo/api/location/nearby" +// case .departure(let id): return "fahrinfo/api/departure/\(id)" +// case .station: return "fahrinfo/api/location/queryWeb" +// case .id: return "fahrinfo/api/location/query" +// case .interruptions: return ".rest/betriebsaenderungen/api/interruption" +// } +// } +// +// var method: HTTPMethod { +// switch self { +// default: return .get +// } +// } +// +// static var requiresAuth: [String] = [] +// +// func asURLRequest() throws -> URLRequest { +// let url = try MVGAPI.baseURL.asURL() +// var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method) +// +// switch self { +// +// case .station(let name): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["q": name]) +// case .id(let id): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["q": id]) +// case .departure: +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["footway": 0]) +// case .nearby(let latitude, let longitude): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["latitude": latitude, "longitude": longitude]) +// default: +// break +// } +// +// return urlRequest +// } +//} diff --git a/Campus-iOS/Base/Networking/Old APIs/NetworkingAPIOld.swift b/Campus-iOS/Base/Networking/Old APIs/NetworkingAPIOld.swift new file mode 100644 index 00000000..65288555 --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/NetworkingAPIOld.swift @@ -0,0 +1,18 @@ +// +// NetworkingAPI.swift +// Campus-iOS +// +// Created by Philipp Zagar on 22.12.21. +// + +import Foundation +import Combine + +//protocol NetworkingAPI { +// // Renaming to `DecoderType` as we otherwise have a conflict between the `Decoder` associatedtype of `Decodable` and the `Decoder` associatedtype of `NetworkingAPI` +// associatedtype DecoderType: TopLevelDecoder +// static var decoder: DecoderType { get } +// static var cache: Cache { get } +// +// static func makeRequest(endpoint: APIConstants, token: String?, forcedRefresh: Bool) async throws -> T +//} diff --git a/Campus-iOS/Base/Networking/Old APIs/TUMCabeAPIOld.swift b/Campus-iOS/Base/Networking/Old APIs/TUMCabeAPIOld.swift new file mode 100644 index 00000000..df10fa6c --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/TUMCabeAPIOld.swift @@ -0,0 +1,77 @@ +// +// TUMCabeAPI.swift +// TUM Campus App +// +// Created by Tim Gymnich on 1/4/19. +// Copyright © 2019 TUM. All rights reserved. +// + +import Alamofire +import UIKit + +//enum TUMCabeAPI: URLRequestConvertible { +// case movie +// case cafeteria +// case news(source: String) +// case newsSources +// case newsAlert +// case roomSearch(query: String) +// case roomMaps(room: String) +// case roomCoordinates(room: String) +// case mapImage(room: String, id: Int) +// case defaultMap(room: String) +// case registerDevice(publicKey: String) +// case events +// case myEvents +// case ticketTypes(event: Int) +// case ticketStats(event: Int) +// case ticketReservation +// case ticketReservationCancellation +// case ticketPurchase +// case stripeKey +// +// static let baseURLString = "https://app.tum.de/api" +// static let serverTrustPolicies: [String: ServerTrustEvaluating] = ["app.tum.de" : PinnedCertificatesTrustEvaluator()] +// static let baseHeaders: HTTPHeaders = ["X-DEVICE-ID": UIDevice.current.identifierForVendor?.uuidString ?? "not available", +// "X-APP-VERSION": Bundle.main.version, +// "X-APP-BUILD": Bundle.main.build, +// "X-OS-VERSION": UIDevice.current.systemVersion,] +// +// var path: String { +// switch self { +// case .movie: return "kino" +// case .cafeteria: return "mensen" +// case .news(let source): return "news/\(source)/getAll" +// case .newsSources: return "news/sources" +// case .newsAlert: return "news/alert" +// case .roomSearch(let room): return "roomfinder/room/search/\(room.addingPercentEncoding(withAllowedCharacters: .afURLQueryAllowed) ?? "")" +// case .roomMaps(let room): return "roomfinder/room/availableMaps/\(room)" +// case .roomCoordinates(let room): return "roomfinder/room/coordinates/\(room)" +// case .defaultMap(let room): return "roomfinder/room/defaultMap/\(room)" +// case .mapImage(let room, let id): return "roomfinder/room/map/\(room)/\(id)" +// case .registerDevice(let publicKey): return "device/register/\(publicKey)" +// case .events: return "event/list" +// case .myEvents: return "event/ticket/my" +// case .ticketTypes(let event): return "event/ticket/type/\(event)" +// case .ticketStats(let event): return "event/ticket/status/\(event)" +// case .ticketReservation: return "event/ticket/reserve" +// case .ticketReservationCancellation: return "event/ticket/reserve/cancel" +// case .ticketPurchase: return "event/ticket/payment/stripe/purchase" +// case .stripeKey: return "event/ticket/payment/stripe/ephemeralkey" +// } +// } +// +// var method: HTTPMethod { +// switch self { +// default: return .get +// } +// } +// +// static var requiresAuth: [String] = [] +// +// func asURLRequest() throws -> URLRequest { +// let url = try TUMCabeAPI.baseURLString.asURL() +// let urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method, headers: TUMCabeAPI.baseHeaders) +// return urlRequest +// } +//} diff --git a/Campus-iOS/Base/Networking/Old APIs/TUMDevAppAPIOld.swift b/Campus-iOS/Base/Networking/Old APIs/TUMDevAppAPIOld.swift new file mode 100644 index 00000000..f42db8cf --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/TUMDevAppAPIOld.swift @@ -0,0 +1,81 @@ +// +// TUMDevAppAPI.swift +// Campus-iOS +// +// Created by Milen Vitanov on 05.05.22. +// + +import Foundation +import Alamofire +import CoreLocation + +//enum TUMDevAppAPI: URLRequestConvertible { +// case room(roomNr: Int) +// case rooms +// +// static let baseURL = "https://www.devapp.it.tum.de" +// +// var path: String { +// switch self { +// case .room, .rooms: return "iris/ris_api.php" +// } +// } +// +// var method: HTTPMethod { +// switch self { +// default: return .get +// } +// } +// +// static var requiresAuth: [String] = [] +// +// func asURLRequest() throws -> URLRequest { +// let url = try TUMDevAppAPI.baseURL.asURL() +// var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method) +// +// switch self { +// case .room(let roomNr): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["format": "json", "raum": roomNr]) +// case .rooms: +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["format": "json"]) +// } +// +// return urlRequest +// } +// +// // Maximum size of cache: 500kB, Maximum cache entries: 1000, Lifetime: 10min +// static let cache = Cache(totalCostLimit: 500_000, countLimit: 1_000, entryLifetime: 10 * 60) +// +// static func fetchStudyRooms(forcedRefresh: Bool) async throws -> StudyRoomApiRespose { +// +// let fullRequestURL = baseURL + self.rooms.path +// +// if !forcedRefresh, let rawStudyRoomsResponse = cache.value(forKey: baseURL + self.rooms.path), let studyRoomsResponse = rawStudyRoomsResponse as? StudyRoomApiRespose { +// print("Study rooms data from cache") +// return studyRoomsResponse +// } else { +// print("Study rooms data from server") +// // Fetch new data and store in cache. +// var studyRoomsData: Data +// do { +// studyRoomsData = try await AF.request(self.rooms).serializingData().value +// } catch { +// print(error) +// throw NetworkingError.deviceIsOffline +// } +// +// var studyRoomsResponse = StudyRoomApiRespose() +// do { +// studyRoomsResponse = try JSONDecoder().decode(StudyRoomApiRespose.self, from: studyRoomsData) +// } catch { +// print(error) +// throw error +// } +// +// // Write value to cache +// cache.setValue(studyRoomsResponse, forKey: fullRequestURL) +// +// return studyRoomsResponse +// } +// } +//} diff --git a/Campus-iOS/Base/Networking/Old APIs/TUMOnlineAPIOld.swift b/Campus-iOS/Base/Networking/Old APIs/TUMOnlineAPIOld.swift new file mode 100644 index 00000000..879cdeef --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/TUMOnlineAPIOld.swift @@ -0,0 +1,93 @@ +// +// TUMOnlineAPI.swift +// TUM Campus App +// +// Created by Tim Gymnich on 1/3/19. +// Copyright © 2019 TUM. All rights reserved. +// + +import UIKit.UIDevice +import Alamofire + +//enum TUMOnlineAPI: URLRequestConvertible { +// case personSearch(search: String) +// case tokenRequest(tumID: String, tokenName: String?) +// case tokenConfirmation +// case tuitionStatus +// case calendar +// case personDetails(identNumber: String) +// case personalLectures +// case personalGrades +// case lectureSearch(search: String) +// case lectureDetails(lvNr: String) +// case identify +// case secretUpload +// case profileImage(personGroup: String, id: String) +// +// static let baseURLString = "https://campus.tum.de/tumonline" +// +// var method: HTTPMethod { +// switch self { +// default: +// return .get +// } +// } +// +// var path: String { +// switch self { +// case .personSearch: return "wbservicesbasic.personenSuche" +// case .tokenRequest: return "wbservicesbasic.requestToken" +// case .tokenConfirmation: return "wbservicesbasic.isTokenConfirmed" +// case .tuitionStatus: return "wbservicesbasic.studienbeitragsstatus" +// case .calendar: return "wbservicesbasic.kalender" +// case .personDetails: return "wbservicesbasic.personenDetails" +// case .personalLectures: return "wbservicesbasic.veranstaltungenEigene" +// case .personalGrades: return "wbservicesbasic.noten" +// case .lectureSearch: return "wbservicesbasic.veranstaltungenSuche" +// case .lectureDetails: return "wbservicesbasic.veranstaltungenDetails" +// case .identify: return "wbservicesbasic.id" +// case .secretUpload: return "wbservicesbasic.secretUpload" +// case .profileImage: return "visitenkarte.showImage?pPersonenGruppe=3&pPersonenId=9C4E2144041FAB5D" +// } +// } +// +// static var requiresAuth: [String] = [ +// "wbservicesbasic.personenSuche", +// "wbservicesbasic.isTokenConfirmed", +// "wbservicesbasic.studienbeitragsstatus", +// "wbservicesbasic.kalender", +// "wbservicesbasic.personenDetails", +// "wbservicesbasic.veranstaltungenEigene", +// "wbservicesbasic.noten", +// "wbservicesbasic.veranstaltungenSuche", +// "wbservicesbasic.veranstaltungenDetails", +// "wbservicesbasic.id", +// ] +// +// +// // MARK: URLRequestConvertible +// +// func asURLRequest() throws -> URLRequest { +// let url = try TUMOnlineAPI.baseURLString.asURL() +// var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method) +// +// switch self { +// case let .personSearch(search): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pSuche": search]) +// case let .tokenRequest(tumID, tokenName): +// let tokenName = tokenName ?? "TCA - \(UIDevice.current.name)" +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pUsername" : tumID, "pTokenName" : tokenName]) +// case let .personDetails(identNumber): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pIdentNr": identNumber]) +// case let .lectureSearch(search): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pSuche": search]) +// case let .lectureDetails(lvNr): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pLVNr": lvNr]) +// case let .profileImage(personGroup, id): +// urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pPersonenGruppe": personGroup, "pPersonenId": id]) +// default: +// break +// } +// return urlRequest +// } +//} diff --git a/Campus-iOS/Base/Networking/Old APIs/TUMSexyAPIOld.swift b/Campus-iOS/Base/Networking/Old APIs/TUMSexyAPIOld.swift new file mode 100644 index 00000000..85788992 --- /dev/null +++ b/Campus-iOS/Base/Networking/Old APIs/TUMSexyAPIOld.swift @@ -0,0 +1,29 @@ +// +// TUMSexyAPI.swift +// TUM Campus App +// +// Created by Tim Gymnich on 2/23/19. +// Copyright © 2019 TUM. All rights reserved. +// + +import Alamofire +import Foundation + +//struct TUMSexyAPI: URLRequestConvertible { +// static let baseURLString = "https://json.tum.sexy" +// +// var method: HTTPMethod { +// switch self { +// default: return .get +// } +// } +// +// static var requiresAuth: [String] = [] +// +// func asURLRequest() throws -> URLRequest { +// let url = try TUMSexyAPI.baseURLString.asURL() +// let urlRequest = try URLRequest(url: url, method: method) +// return urlRequest +// } +// +//} diff --git a/Campus-iOS/Base/Networking/Protocols/API.swift b/Campus-iOS/Base/Networking/Protocols/API.swift new file mode 100644 index 00000000..118c833d --- /dev/null +++ b/Campus-iOS/Base/Networking/Protocols/API.swift @@ -0,0 +1,100 @@ +// +// API.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation +import Alamofire +import XMLCoder +import UIKit + +protocol API { + // The base URL will be the entry point for fetching the data. + static var baseURL: String { get } + // If the API requires headers, otherwise declare it as an empty array. + static var baseHeaders: HTTPHeaders { get } + // Type of error to handle errors properly for each API. + static var error: APIError.Type { get } + + // This property should return the respective path for each data type + var paths: String { get } + // The different parameters used for each data type if they are needed, otherwise return an empty dict [:]. + var parameters: [String: String] { get } + // Indicates which data types can only be fetched with authentication + var needsAuth: Bool { get } + + // Returns the baseURL combinded with the relative paths. This is typically used in the asReqeust(token:) method. + var basePathsURL: String { get } + // Returns the basePathURL combined with all the parameters. This is typically used as an identifier for the cache. + var basePathsParametersURL: String { get } + + + /// Produces the final request depending considering if a data type needs authentication. + /// + /// ``` + /// let api = CampusOnline.personalLectures + /// let token = "1234" + /// let request = api.asRequest(token) // A data request used to fetch the data + /// + /// do { + /// let data = try await request.serializingData.value + /// } catch { + /// print("Error occured fetching data: \(String(describing: error))") + /// } + /// ``` + /// + /// - Parameters: + /// - token: The token used to authenticate. + /// - Returns: An `Alamofire.DataRequest` depending on the `token`. + func asRequest(token: String?) -> DataRequest + + /// Uses a decoder (either JSON or XML depending on the API) to decode the fetched data. + /// + /// ``` + /// let data = ... + /// let api = CampusOnline.personalGrades + /// do { + /// let decodedData = try api.decode(type: Grades.self, from: data) + /// } catch { + /// print("Error occurred while decoding: \(String(describing: error))") + /// } + /// ``` + /// + /// - Parameters: + /// - type: Generic data type, which conforms to `Decodable`. + /// - data: The data to be decoded into `type`. + /// - Throws: Throws decoding error if decoding failed. + /// - Returns: The data in the decoded data format. + func decode(_ type: T.Type, from data: Data) throws -> T +} + +extension API { + var basePathsURL: String { + Self.baseURL + self.paths + } + + var basePathsParametersURL: String { + if parameters.isEmpty { + return basePathsURL + } else { + return basePathsURL + "?" + parameters.flatMap({ key, value in + key + "=" + value + }) + } + } + + func asRequest(token: String?) -> Alamofire.DataRequest { + let finalParameters = self.needsAuth ? self.parameters.merging(["pToken": token ?? ""], uniquingKeysWith: { (current, _) in current }) : self.parameters + + return AF.request(self.basePathsURL, parameters: finalParameters, headers: Self.baseHeaders).cacheResponse(using: ResponseCacher(behavior: .cache)) + } +} + +enum APIState { + case na + case loading + case success(data: T) + case failed(error: Error) +} diff --git a/Campus-iOS/Base/Networking/Protocols/APIError.swift b/Campus-iOS/Base/Networking/Protocols/APIError.swift new file mode 100644 index 00000000..5b02b3a9 --- /dev/null +++ b/Campus-iOS/Base/Networking/Protocols/APIError.swift @@ -0,0 +1,12 @@ +// +// APIErrors.swift +// Campus-iOS +// +// Created by David Lin on 19.01.23. +// + +import Foundation + +protocol APIError: Error, Decodable { + init(message: String) +} diff --git a/Campus-iOS/Base/Networking/Protocols/MainAPI.swift b/Campus-iOS/Base/Networking/Protocols/MainAPI.swift new file mode 100644 index 00000000..904227c2 --- /dev/null +++ b/Campus-iOS/Base/Networking/Protocols/MainAPI.swift @@ -0,0 +1,73 @@ +// +// MainAPI.swift +// Campus-iOS +// +// Created by David Lin on 16.01.23. +// + +import Foundation +import Alamofire +import XMLCoder + + +enum MainAPI { + // Maximum size of cache: 500kB, Maximum cache entries: 1000, Lifetime: 10min + static let cache = Cache(totalCostLimit: 500_000, countLimit: 1_000, entryLifetime: 10 * 60) + + /// Returns a generic value of type `T` fetched from the API or the cache. + /// + /// ``` + /// print(hello("world")) // "Hello, world!" + /// ``` + /// This method uses the specified `endpoint` to make a request, i.e. fetch the data, decode it and to check if any given error occured. + /// If the cache is stil valid (lifetime not expired yet) and the `forcedRefresh` is `false` then data is not fetched from the `endpoint`. + /// Instead the data is retrieved from the cache. + /// + /// > Warning: Some APIs need a token for authentication purposes. + /// + /// - Parameters: + /// - endpoint: An value conforming to the `API`protocol. + /// - token: A string representing the authentication token + /// - forcedRefresh + /// - Throws: Depending on the error, either the a networking or decoding error occurred. + /// - Returns: The retrieved data in as of generic type `T`. + static func makeRequest(endpoint: S, token: String? = nil, forcedRefresh: Bool = false) async throws -> T { + // Check cache first + if !forcedRefresh, let data = cache.value(forKey: endpoint.basePathsParametersURL), let typedData = data as? T { + return typedData + // Otherwise make the request + } else { + var data: Data + do { + data = try await endpoint.asRequest(token: token).serializingData().value + /* + //For debugging; print only for certain types, i.e. Profile responses. + if T.self is TUMOnlineAPI.Response.Type { + print("\(String(data: data, encoding: .utf8))") + } + */ + } catch { + print(error) + throw NetworkingError.deviceIsOffline + } + + if let error = try? endpoint.decode(S.error, from: data) { + print(error) + throw error + } + + do { + // Decode data from the respective endpoint. + let decodedData = try endpoint.decode(T.self, from: data) + // Write value to cache + cache.setValue(decodedData, forKey: endpoint.basePathsParametersURL, cost: data.count) + + return decodedData + + } catch { + print(error) + throw S.error.init(message: error.localizedDescription) + } + } + } +} diff --git a/Campus-iOS/Base/Networking/Protocols/Service.swift b/Campus-iOS/Base/Networking/Protocols/Service.swift new file mode 100644 index 00000000..623690cd --- /dev/null +++ b/Campus-iOS/Base/Networking/Protocols/Service.swift @@ -0,0 +1,18 @@ +// +// ServiceProtocols.swift +// Campus-iOS +// +// Created by David Lin on 20.01.23. +// + +import Foundation + +protocol ServiceTokenProtocol { + associatedtype T : Decodable + func fetch(token: String, forcedRefresh: Bool) async throws -> [T] +} + +protocol ServiceProtocol { + associatedtype T : Decodable + func fetch(forcedRefresh: Bool) async throws -> [T] +} diff --git a/Campus-iOS/Base/Networking/TUMDevAppAPI.swift b/Campus-iOS/Base/Networking/TUMDevAppAPI.swift deleted file mode 100644 index df40f374..00000000 --- a/Campus-iOS/Base/Networking/TUMDevAppAPI.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// TUMDevAppAPI.swift -// Campus-iOS -// -// Created by Milen Vitanov on 05.05.22. -// - -import Foundation -import Alamofire -import CoreLocation - -enum TUMDevAppAPI: URLRequestConvertible { - case room(roomNr: Int) - case rooms - - static let baseURL = "https://www.devapp.it.tum.de" - - var path: String { - switch self { - case .room, .rooms: return "iris/ris_api.php" - } - } - - var method: HTTPMethod { - switch self { - default: return .get - } - } - - static var requiresAuth: [String] = [] - - func asURLRequest() throws -> URLRequest { - let url = try TUMDevAppAPI.baseURL.asURL() - var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method) - - switch self { - case .room(let roomNr): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["format": "json", "raum": roomNr]) - case .rooms: - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["format": "json"]) - } - - return urlRequest - } - - // Maximum size of cache: 500kB, Maximum cache entries: 1000, Lifetime: 10min - static let cache = Cache(totalCostLimit: 500_000, countLimit: 1_000, entryLifetime: 10 * 60) - - static func fetchStudyRooms(forcedRefresh: Bool) async throws -> StudyRoomApiRespose { - - let fullRequestURL = baseURL + self.rooms.path - - if !forcedRefresh, let rawStudyRoomsResponse = cache.value(forKey: baseURL + self.rooms.path), let studyRoomsResponse = rawStudyRoomsResponse as? StudyRoomApiRespose { - print("Study rooms data from cache") - return studyRoomsResponse - } else { - print("Study rooms data from server") - // Fetch new data and store in cache. - var studyRoomsData: Data - do { - studyRoomsData = try await AF.request(self.rooms).serializingData().value - } catch { - print(error) - throw NetworkingError.deviceIsOffline - } - - var studyRoomsResponse = StudyRoomApiRespose() - do { - studyRoomsResponse = try JSONDecoder().decode(StudyRoomApiRespose.self, from: studyRoomsData) - } catch { - print(error) - throw error - } - - // Write value to cache - cache.setValue(studyRoomsResponse, forKey: fullRequestURL) - - return studyRoomsResponse - } - } -} diff --git a/Campus-iOS/Base/Networking/TUMOnlineAPI.swift b/Campus-iOS/Base/Networking/TUMOnlineAPI.swift deleted file mode 100644 index 838d38f1..00000000 --- a/Campus-iOS/Base/Networking/TUMOnlineAPI.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// TUMOnlineAPI.swift -// TUM Campus App -// -// Created by Tim Gymnich on 1/3/19. -// Copyright © 2019 TUM. All rights reserved. -// - -import UIKit.UIDevice -import Alamofire - -enum TUMOnlineAPI: URLRequestConvertible { - case personSearch(search: String) - case tokenRequest(tumID: String, tokenName: String?) - case tokenConfirmation - case tuitionStatus - case calendar - case personDetails(identNumber: String) - case personalLectures - case personalGrades - case lectureSearch(search: String) - case lectureDetails(lvNr: String) - case identify - case secretUpload - case profileImage(personGroup: String, id: String) - - static let baseURLString = "https://campus.tum.de/tumonline" - - var method: HTTPMethod { - switch self { - default: - return .get - } - } - - var path: String { - switch self { - case .personSearch: return "wbservicesbasic.personenSuche" - case .tokenRequest: return "wbservicesbasic.requestToken" - case .tokenConfirmation: return "wbservicesbasic.isTokenConfirmed" - case .tuitionStatus: return "wbservicesbasic.studienbeitragsstatus" - case .calendar: return "wbservicesbasic.kalender" - case .personDetails: return "wbservicesbasic.personenDetails" - case .personalLectures: return "wbservicesbasic.veranstaltungenEigene" - case .personalGrades: return "wbservicesbasic.noten" - case .lectureSearch: return "wbservicesbasic.veranstaltungenSuche" - case .lectureDetails: return "wbservicesbasic.veranstaltungenDetails" - case .identify: return "wbservicesbasic.id" - case .secretUpload: return "wbservicesbasic.secretUpload" - case .profileImage: return "visitenkarte.showImage?pPersonenGruppe=3&pPersonenId=9C4E2144041FAB5D" - } - } - - static var requiresAuth: [String] = [ - "wbservicesbasic.personenSuche", - "wbservicesbasic.isTokenConfirmed", - "wbservicesbasic.studienbeitragsstatus", - "wbservicesbasic.kalender", - "wbservicesbasic.personenDetails", - "wbservicesbasic.veranstaltungenEigene", - "wbservicesbasic.noten", - "wbservicesbasic.veranstaltungenSuche", - "wbservicesbasic.veranstaltungenDetails", - "wbservicesbasic.id", - ] - - - // MARK: URLRequestConvertible - - func asURLRequest() throws -> URLRequest { - let url = try TUMOnlineAPI.baseURLString.asURL() - var urlRequest = try URLRequest(url: url.appendingPathComponent(path), method: method) - - switch self { - case let .personSearch(search): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pSuche": search]) - case let .tokenRequest(tumID, tokenName): - let tokenName = tokenName ?? "TCA - \(UIDevice.current.name)" - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pUsername" : tumID, "pTokenName" : tokenName]) - case let .personDetails(identNumber): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pIdentNr": identNumber]) - case let .lectureSearch(search): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pSuche": search]) - case let .lectureDetails(lvNr): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pLVNr": lvNr]) - case let .profileImage(personGroup, id): - urlRequest = try URLEncoding.default.encode(urlRequest, with: ["pPersonenGruppe": personGroup, "pPersonenId": id]) - default: - break - } - return urlRequest - } -} diff --git a/Campus-iOS/Base/Networking/TUMSexyAPI.swift b/Campus-iOS/Base/Networking/TUMSexyAPI.swift deleted file mode 100644 index 740e03ed..00000000 --- a/Campus-iOS/Base/Networking/TUMSexyAPI.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// TUMSexyAPI.swift -// TUM Campus App -// -// Created by Tim Gymnich on 2/23/19. -// Copyright © 2019 TUM. All rights reserved. -// - -import Alamofire -import Foundation - -struct TUMSexyAPI: URLRequestConvertible { - static let baseURLString = "https://json.tum.sexy" - - var method: HTTPMethod { - switch self { - default: return .get - } - } - - static var requiresAuth: [String] = [] - - func asURLRequest() throws -> URLRequest { - let url = try TUMSexyAPI.baseURLString.asURL() - let urlRequest = try URLRequest(url: url, method: method) - return urlRequest - } - -} diff --git a/Campus-iOS/CalendarComponent/Entity/CalendarEvent.swift b/Campus-iOS/CalendarComponent/Model/CalendarEvent.swift similarity index 98% rename from Campus-iOS/CalendarComponent/Entity/CalendarEvent.swift rename to Campus-iOS/CalendarComponent/Model/CalendarEvent.swift index 23c4a4c9..2988f3af 100644 --- a/Campus-iOS/CalendarComponent/Entity/CalendarEvent.swift +++ b/Campus-iOS/CalendarComponent/Model/CalendarEvent.swift @@ -11,14 +11,14 @@ import UIKit // XMLDecoder cannot use [Event].self so we have to wrap the events in Calendar.self. This is probably a bug in parsing the root node. struct CalendarAPIResponse: Decodable { - var events: [CalendarEvent]? + var events: [CalendarEvent] enum CodingKeys: String, CodingKey { case events = "event" } } -struct CalendarEvent: Identifiable, Equatable, Entity { +struct CalendarEvent: Decodable, Identifiable, Equatable { var descriptionText: String? var endDate: Date? var id: Int64 diff --git a/Campus-iOS/CalendarComponent/Entity/TumCalendarStyle.swift b/Campus-iOS/CalendarComponent/Model/TumCalendarStyle.swift similarity index 100% rename from Campus-iOS/CalendarComponent/Entity/TumCalendarStyle.swift rename to Campus-iOS/CalendarComponent/Model/TumCalendarStyle.swift diff --git a/Campus-iOS/CalendarComponent/Screen/CalendarScreen.swift b/Campus-iOS/CalendarComponent/Screen/CalendarScreen.swift new file mode 100644 index 00000000..7fafb2ed --- /dev/null +++ b/Campus-iOS/CalendarComponent/Screen/CalendarScreen.swift @@ -0,0 +1,82 @@ +// +// CalendarScreen.swift +// Campus-iOS +// +// Created by David Lin on 20.01.23. +// + +import SwiftUI + +struct CalendarScreen: View { + @StateObject var vm: CalendarViewModel + @Binding var refresh: Bool + + init(model: Model, refresh: Binding) { + self._vm = StateObject(wrappedValue: + CalendarViewModel( + model: model, + service: CalendarService() + ) + ) + self._refresh = refresh + } + + var body: some View { + Group { + switch vm.state { + case .success(let events): + VStack { + CalendarContentView( + model: self.vm.model, events: events + ) + .refreshable { + await vm.getCalendar(forcedRefresh: true) + } + } + case .loading, .na: + LoadingView(text: "Fetching Calendar") + case .failed(let error): + FailedView( + errorDescription: error.localizedDescription, + retryClosure: vm.getCalendar + ) + } + } + .task { + await vm.getCalendar() + } + // Refresh whenever user authentication status changes + .onChange(of: self.refresh) { _ in + Task { + await vm.getCalendar() + } + } + // As LoginView is just a sheet displayed in front of the GradeScreen + // Listen to changes on the token, then fetch the grades + .onChange(of: self.vm.model.token ?? "") { _ in + Task { + await vm.getCalendar() + } + } + .alert( + "Error while fetching Grades", + isPresented: $vm.hasError, + presenting: vm.state) { detail in + Button("Retry") { + Task { + await vm.getCalendar(forcedRefresh: true) + } + } + + Button("Cancel", role: .cancel) { } + } message: { detail in + if case let .failed(error) = detail { + if let apiError = error as? TUMOnlineAPIError { + Text(apiError.errorDescription ?? "TUMOnlineAPI Error") + } else { + Text(error.localizedDescription) + } + } + } + } +} diff --git a/Campus-iOS/CalendarComponent/Service/CalendarService.swift b/Campus-iOS/CalendarComponent/Service/CalendarService.swift new file mode 100644 index 00000000..94fb8889 --- /dev/null +++ b/Campus-iOS/CalendarComponent/Service/CalendarService.swift @@ -0,0 +1,16 @@ +// +// CalendarService.swift +// Campus-iOS +// +// Created by David Lin on 20.01.23. +// + +import Foundation + +struct CalendarService: ServiceTokenProtocol { + func fetch(token: String, forcedRefresh: Bool = false) async throws -> [CalendarEvent] { + let response: TUMOnlineAPI.CalendarResponse = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.calendar, token: token, forcedRefresh: forcedRefresh) + + return response.event + } +} diff --git a/Campus-iOS/CalendarComponent/ViewModel/CalendarViewModel.swift b/Campus-iOS/CalendarComponent/ViewModel/CalendarViewModel.swift index 8a7643f2..eed01188 100644 --- a/Campus-iOS/CalendarComponent/ViewModel/CalendarViewModel.swift +++ b/Campus-iOS/CalendarComponent/ViewModel/CalendarViewModel.swift @@ -8,56 +8,53 @@ import Foundation import XMLCoder +@MainActor class CalendarViewModel: ObservableObject { - typealias ImporterType = Importer - private static let endpoint = TUMOnlineAPI.calendar - - @Published var events: [CalendarEvent] = [] + @Published var state: APIState<[CalendarEvent]> = .na + @Published var hasError: Bool = false let model: Model - var state: State = .na + let service: CalendarService - init(model: Model) { + init(model: Model, service: CalendarService) { self.model = model - fetch() + self.service = service } - - func fetch(callback: @escaping (Result) -> Void = {_ in }) { - if(self.model.isUserAuthenticated) { - let importer = ImporterType(endpoint: Self.endpoint, predicate: nil, dateDecodingStrategy: .formatted(.yyyyMMddhhmmss)) - DispatchQueue.main.async { - importer.performFetch(handler: { result in - switch result { - case .success(let storage): - self.events = storage.events?.filter( { $0.status != "CANCEL" } ).sorted(by: { - guard let dateOne = $0.startDate, let dateTwo = $1.startDate else { - return false - } - return dateOne > dateTwo - }) ?? [] - - if let _ = storage.events { - callback(.success(true)) - } else { - callback(.failure(CampusOnlineAPI.Error.noPermission)) - } - case .failure(let error): - self.state = .failed(error: error) - } - }) - } + func getCalendar(forcedRefresh: Bool = false) async { + if !forcedRefresh { + self.state = .loading + } + self.hasError = false + + guard let token = self.model.token else { + self.state = .failed(error: NetworkingError.unauthorized) + self.hasError = true + return + } + + do { + let events = try await service.fetch(token: token, forcedRefresh: forcedRefresh) - } else { - self.events = [] + self.state = .success( + data: events.filter( { $0.status != "CANCEL" } ).sorted {$0.startDate ?? .distantPast > $1.startDate ?? .distantPast}) + } catch { + self.state = .failed(error: error) + self.hasError = true } } var eventsByDate: [Date? : [CalendarEvent]] { - let sortedEvents = events.sorted { $0.startDate ?? Date() < $1.startDate ?? Date() } - let filteredEvents = sortedEvents.filter { Date() <= $0.startDate ?? Date() } - let dictionary = Dictionary(grouping: filteredEvents, by: { $0.startDate?.removeTimeStamp }) - return dictionary + if case .success(let data) = state { + let sortedEvents = data.sorted { $0.startDate ?? Date() < $1.startDate ?? Date() } + let filteredEvents = sortedEvents.filter { Date() <= $0.startDate ?? Date() } + let dictionary = Dictionary(grouping: filteredEvents, by: { $0.startDate?.removeTimeStamp }) + + return dictionary + + } else { + return [:] + } } } diff --git a/Campus-iOS/CalendarComponent/Views/CalendarContentView.swift b/Campus-iOS/CalendarComponent/Views/CalendarContentView.swift index 54176ec3..5db1f781 100644 --- a/Campus-iOS/CalendarComponent/Views/CalendarContentView.swift +++ b/Campus-iOS/CalendarComponent/Views/CalendarContentView.swift @@ -9,20 +9,15 @@ import SwiftUI import KVKCalendar struct CalendarContentView: View { - - @StateObject var viewModel: CalendarViewModel - @Binding var refresh: Bool @AppStorage("calendarWeekDays") var calendarWeekDays: Int = 7 @State var selectedType: CalendarType = .week @State var selectedEventID: String? @State var isTodayPressed: Bool = false @State private var data = AppUsageData() - - init(model: Model, refresh: Binding) { - self._viewModel = StateObject(wrappedValue: CalendarViewModel(model: model)) - self._refresh = refresh - } + + let model: Model + var events: [CalendarEvent] = [] var body: some View { VStack { @@ -31,19 +26,19 @@ struct CalendarContentView: View { switch self.selectedType { case .week: CalendarDisplayView( - events: self.viewModel.events.map({ $0.kvkEvent }), + events: self.events.map({ $0.kvkEvent }), type: .week, selectedEventID: self.$selectedEventID, frame: Self.getSafeAreaFrame(geometry: geo), todayPressed: self.$isTodayPressed, calendarWeekDays: UInt(calendarWeekDays)) case .day: CalendarDisplayView( - events: self.viewModel.events.map({ $0.kvkEvent }), + events: self.events.map({ $0.kvkEvent }), type: .day, selectedEventID: self.$selectedEventID, frame: Self.getSafeAreaFrame(geometry: geo), todayPressed: self.$isTodayPressed, calendarWeekDays: UInt(calendarWeekDays)) case .month: CalendarDisplayView( - events: self.viewModel.events.map({ $0.kvkEvent }), + events: self.events.map({ $0.kvkEvent }), type: .month, selectedEventID: self.$selectedEventID, frame: Self.getSafeAreaFrame(geometry: geo), todayPressed: self.$isTodayPressed, calendarWeekDays: UInt(calendarWeekDays)) @@ -52,16 +47,12 @@ struct CalendarContentView: View { } } } - // Refresh whenever user authentication status changes - .onChange(of: self.refresh) { _ in - self.viewModel.fetch() - } .sheet(item: self.$selectedEventID) { eventId in - let chosenEvent = self.viewModel.events + let chosenEvent = self.events .first(where: { $0.id.description == eventId }) CalendarSingleEventView( viewModel: LectureDetailsViewModel( - model: viewModel.model, + model: model, service: LectureDetailsService(), // Yes, it is a really hacky solution... lecture: Lecture(id: UInt64(chosenEvent?.lvNr ?? "") ?? 0, lvNumber: UInt64(chosenEvent?.lvNr ?? "") ?? 0, title: "", duration: "", stp_sp_sst: "", eventTypeDefault: "", eventTypeTag: "", semesterYear: "", semesterType: "", semester: "", semesterID: "", organisationNumber: 0, organisation: "", organisationTag: "", speaker: "") @@ -106,7 +97,7 @@ struct CalendarContentView: View { } } ToolbarItemGroup(placement: .navigationBarTrailing) { - ProfileToolbar(model: viewModel.model) + ProfileToolbar(model: model) } } .task { @@ -128,7 +119,7 @@ struct CalendarContentView_Previews: PreviewProvider { static var previews: some View { CalendarContentView( model: MockModel(), - refresh: .constant(false) + events: [] ) } } diff --git a/Campus-iOS/CalendarComponent/Views/CalendarWidgetView.swift b/Campus-iOS/CalendarComponent/Views/CalendarWidgetView.swift index bddb41d3..0357da8f 100644 --- a/Campus-iOS/CalendarComponent/Views/CalendarWidgetView.swift +++ b/Campus-iOS/CalendarComponent/Views/CalendarWidgetView.swift @@ -18,7 +18,7 @@ struct CalendarWidgetView: View { @Binding var refresh: Bool init(model: Model, size: WidgetSize, refresh: Binding = .constant(false)) { - self._viewModel = StateObject(wrappedValue: CalendarViewModel(model: model)) // Fetches in init. + self._viewModel = StateObject(wrappedValue: CalendarViewModel(model: model, service: CalendarService())) self._size = State(initialValue: size) self.initialSize = size self.model = model @@ -45,7 +45,12 @@ struct CalendarWidgetView: View { ) .onChange(of: refresh) { _ in if showDetails { return } - viewModel.fetch() + Task { + await viewModel.getCalendar() + } + } + .task { + await viewModel.getCalendar() } .onTapGesture { showDetails.toggle() @@ -53,7 +58,7 @@ struct CalendarWidgetView: View { .sheet(isPresented: $showDetails) { VStack { Spacer().frame(height: 10) - CalendarContentView(model: model, refresh: .constant(false)) + CalendarScreen(model: self.model, refresh: .constant(false)) } } .expandable(size: $size, initialSize: initialSize, scale: $scale) diff --git a/Campus-iOS/Campus-iOS/Base.lproj/Localizable.strings b/Campus-iOS/Campus-iOS/Base.lproj/Localizable.strings index ed06fb86..93aa1990 100644 --- a/Campus-iOS/Campus-iOS/Base.lproj/Localizable.strings +++ b/Campus-iOS/Campus-iOS/Base.lproj/Localizable.strings @@ -43,7 +43,7 @@ "No Links" = "No Links"; "Cafeteria Map" = "Cafeteria Map"; "Search Rooms" = "Search Rooms"; -"Room Finder" = "Room Finder"; +"Roomfinder" = "Roomfinder"; "Unable to find room" = "Unable to find room '%@'"; "No Menu" = "No Menu"; "List View" = "List View"; @@ -126,6 +126,8 @@ "Lecture Search" = "Lecture Search"; "Unable to find lecture" = "Unable to find lecture"; "Choose Speaker" = "Choose Speaker"; +"The search query must be at least 4 characters." = "The search query must be at least 4 characters."; +"Fetching Lectures" = "Fetching Lectures"; // Grades "Fetching Grades" = "Fetching Grades"; @@ -172,6 +174,7 @@ "Room" = "Room"; "Detail" = "Detail"; "Obergeschoß" = "Obergeschoß"; +"Room Details" = "Room Details"; // Map "Search ..." = "Search ..."; diff --git a/Campus-iOS/Campus-iOS/de.lproj/Localizable.strings b/Campus-iOS/Campus-iOS/de.lproj/Localizable.strings index 01d54482..29a06e5a 100644 --- a/Campus-iOS/Campus-iOS/de.lproj/Localizable.strings +++ b/Campus-iOS/Campus-iOS/de.lproj/Localizable.strings @@ -43,7 +43,7 @@ "No Links" = "Keine Links"; "Cafeteria Map" = "Mensa Landkarte"; "Search Rooms" = "Räume suchen"; -"Room Finder" = "Room Finder"; +"Roomfinder" = "Roomfinder"; "Unable to find room" = "Raum '%@' konnte nicht gefunden werden"; "No Menu" = "Kein Menü"; "List View" = "Listenansicht"; @@ -120,6 +120,8 @@ "Lecture Search" = "Vorlesungssuche"; "Unable to find lecture" = "Vorlesung kann nicht gefunden werden"; "Choose Speaker" = "Wähle Vortragenden"; +"The search query must be at least 4 characters." = "Die Suchanfrage muss aus mindestens 4 Zeichen bestehen."; +"Fetching Lectures" = "Lade Vorlesungen"; // Grades "Fetching Grades" = "Lade Noten"; @@ -161,11 +163,12 @@ "Unknown" = "Unbekannt"; // Roomfinder -"Roomfinder" = "Raum Finder"; +"Roomfinder" = "Roomfinder"; "Building" = "Gebäude"; "Room" = "Raum"; "Detail" = "Detail"; "Floor" = "Obergeschoß"; +"Room Details" = "Raum Details"; // Map "Search ..." = "Suchen ..."; diff --git a/Campus-iOS/Extensions/Extensions.swift b/Campus-iOS/Extensions/Extensions.swift index e4ef730c..f3ccadaa 100644 --- a/Campus-iOS/Extensions/Extensions.swift +++ b/Campus-iOS/Extensions/Extensions.swift @@ -18,16 +18,6 @@ extension Bundle { var userAgent: String { "TCA iOS \(version)/\(build)" } } -extension Session { - static let defaultSession: Session = { - let adapterAndRetrier = Interceptor(adapter: AuthenticationHandler(), retrier: AuthenticationHandler()) - let cacher = ResponseCacher(behavior: .cache) -// let trustManager = ServerTrustManager(evaluators: TUMCabeAPI.serverTrustPolicies) - let manager = Session(interceptor: adapterAndRetrier, redirectHandler: ForceHTTPSRedirectHandler(), cachedResponseHandler: cacher) - return manager - }() -} - extension DataRequest { @discardableResult public func responseXML(queue: DispatchQueue = .main, diff --git a/Campus-iOS/GradesComponent/Model/Grade.swift b/Campus-iOS/GradesComponent/Model/Grade.swift index 1aa84948..9dc852ba 100644 --- a/Campus-iOS/GradesComponent/Model/Grade.swift +++ b/Campus-iOS/GradesComponent/Model/Grade.swift @@ -7,58 +7,47 @@ import Foundation -// As XMLDecoding is complete BS -typealias Grade = GradeComponents.Row - -enum GradeComponents { - struct RowSet: Decodable { - public var row: [Row] +struct Grade: Decodable, Identifiable { + // Create own identifier as there isn't one + public var id: String { + date.formatted() + "-" + lvNumber } - - struct Row: Identifiable { - // Create own identifier as there isn't one - public var id: String { - date.formatted() + "-" + lvNumber - } - public var date: Date - public var lvNumber: String - public var semester: String - public var title: String - public var examiner: String - public var grade: String - public var examType: String - public var modus: String - public var studyID: String - public var studyDesignation: String - public var studyNumber: UInt64 - - var modusShort: String { - switch self.modus { - case "Schriftlich": return "Written".localized - case "Beurteilt/immanenter Prüfungscharakter": return "Graded".localized - case "Schriftlich und Mündlich": return "Written/Oral".localized - case "Mündlich": return "Oral".localized - default: return "Unknown".localized - } - } - - enum CodingKeys: String, CodingKey { - case date = "datum" - case lvNumber = "lv_nummer" - case semester = "lv_semester" - case title = "lv_titel" - case examiner = "pruefer_nachname" - case grade = "uninotenamekurz" - case examType = "exam_typ_name" - case modus = "modus" - case studyID = "studienidentifikator" - case studyDesignation = "studienbezeichnung" - case studyNumber = "st_studium_nr" + public var date: Date + public var lvNumber: String + public var semester: String + public var title: String + public var examiner: String + public var grade: String + public var examType: String + public var modus: String + public var studyID: String + public var studyDesignation: String + public var studyNumber: UInt64 + + var modusShort: String { + switch self.modus { + case "Schriftlich": return "Written".localized + case "Beurteilt/immanenter Prüfungscharakter": return "Graded".localized + case "Schriftlich und Mündlich": return "Written/Oral".localized + case "Mündlich": return "Oral".localized + default: return "Unknown".localized } } -} - -extension Grade: Decodable { + + enum CodingKeys: String, CodingKey { + case date = "datum" + case lvNumber = "lv_nummer" + case semester = "lv_semester" + case title = "lv_titel" + case examiner = "pruefer_nachname" + case grade = "uninotenamekurz" + case examType = "exam_typ_name" + case modus = "modus" + case studyID = "studienidentifikator" + case studyDesignation = "studienbezeichnung" + case studyNumber = "st_studium_nr" + } + // Need for a custom Decoder implementation as the XMLCoder library isn't able to handle missing Date properties and the entire decoding fails in case of a non-existing Date value init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -80,6 +69,20 @@ extension Grade: Decodable { studyDesignation = try container.decode(String.self, forKey: .studyDesignation) studyNumber = try container.decode(UInt64.self, forKey: .studyNumber) } + + internal init(date: Date, lvNumber: String, semester: String, title: String, examiner: String, grade: String, examType: String, modus: String, studyID: String, studyDesignation: String, studyNumber: UInt64) { + self.date = date + self.lvNumber = lvNumber + self.semester = semester + self.title = title + self.examiner = examiner + self.grade = grade + self.examType = examType + self.modus = modus + self.studyID = studyID + self.studyDesignation = studyDesignation + self.studyNumber = studyNumber + } } extension Grade { diff --git a/Campus-iOS/GradesComponent/Screen/GradesScreen.swift b/Campus-iOS/GradesComponent/Screen/GradesScreen.swift index 0433e3f4..a5762755 100644 --- a/Campus-iOS/GradesComponent/Screen/GradesScreen.swift +++ b/Campus-iOS/GradesComponent/Screen/GradesScreen.swift @@ -54,7 +54,7 @@ struct GradesScreen: View { } // As LoginView is just a sheet displayed in front of the GradeScreen // Listen to changes on the token, then fetch the grades - .onChange(of: self.vm.token ?? "") { _ in + .onChange(of: self.vm.model.token ?? "") { _ in Task { await vm.getGrades() } @@ -72,8 +72,8 @@ struct GradesScreen: View { Button("Cancel", role: .cancel) { } } message: { detail in if case let .failed(error) = detail { - if let campusOnlineError = error as? CampusOnlineAPI.Error { - Text(campusOnlineError.errorDescription ?? "CampusOnline Error") + if let apiError = error as? TUMOnlineAPIError { + Text(apiError.errorDescription ?? "TUMOnlineAPI Error") } else { Text(error.localizedDescription) } diff --git a/Campus-iOS/GradesComponent/Service/GradesService.swift b/Campus-iOS/GradesComponent/Service/GradesService.swift index 4d625808..683af8be 100644 --- a/Campus-iOS/GradesComponent/Service/GradesService.swift +++ b/Campus-iOS/GradesComponent/Service/GradesService.swift @@ -8,20 +8,9 @@ import Foundation import Alamofire -protocol GradesServiceProtocol { - func fetch(token: String, forcedRefresh: Bool) async throws -> [Grade] -} - -struct GradesService: GradesServiceProtocol { +struct GradesService: ServiceTokenProtocol { func fetch(token: String, forcedRefresh: Bool = false) async throws -> [Grade] { - let response: GradeComponents.RowSet = - try await - CampusOnlineAPI - .makeRequest( - endpoint: Constants.API.CampusOnline.personalGrades, - token: token, - forcedRefresh: forcedRefresh - ) + let response: TUMOnlineAPI.Response = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.personalGrades, token: token, forcedRefresh: forcedRefresh) return response.row } diff --git a/Campus-iOS/GradesComponent/ViewModel/GradesViewModel+State.swift b/Campus-iOS/GradesComponent/ViewModel/GradesViewModel+State.swift index 263b43b5..69946ec8 100644 --- a/Campus-iOS/GradesComponent/ViewModel/GradesViewModel+State.swift +++ b/Campus-iOS/GradesComponent/ViewModel/GradesViewModel+State.swift @@ -7,11 +7,11 @@ import Foundation -extension GradesViewModel { - enum State { - case na - case loading - case success(data: [Grade]) - case failed(error: Error) - } -} +//extension GradesViewModel { +// enum State { +// case na +// case loading +// case success(data: [Grade]) +// case failed(error: Error) +// } +//} diff --git a/Campus-iOS/GradesComponent/ViewModel/GradesViewModel.swift b/Campus-iOS/GradesComponent/ViewModel/GradesViewModel.swift index d6826dee..f4d5920d 100644 --- a/Campus-iOS/GradesComponent/ViewModel/GradesViewModel.swift +++ b/Campus-iOS/GradesComponent/ViewModel/GradesViewModel.swift @@ -14,22 +14,11 @@ protocol GradesViewModelProtocol: ObservableObject { @MainActor class GradesViewModel: GradesViewModelProtocol { - @Published var state: State = .na + @Published var state: APIState<[Grade]> = .na @Published var hasError: Bool = false - private let model: Model - private let service: GradesServiceProtocol - - var token: String? { - switch self.model.loginController.credentials { - case .none, .noTumID: - return nil - case .tumID(_, let token): - return token - case .tumIDAndKey(_, let token, _): - return token - } - } + let model: Model + private let service: GradesService var gradesByDegreeAndSemester: [(String, [(String, [Grade])])] { guard case .success(let data) = self.state else { @@ -85,7 +74,7 @@ class GradesViewModel: GradesViewModelProtocol { } } - init(model: Model, service: GradesServiceProtocol) { + init(model: Model, service: GradesService) { self.model = model self.service = service } @@ -96,7 +85,7 @@ class GradesViewModel: GradesViewModelProtocol { } self.hasError = false - guard let token = self.token else { + guard let token = self.model.token else { self.state = .failed(error: NetworkingError.unauthorized) self.hasError = true return diff --git a/Campus-iOS/GradesComponent/ViewModel/MockGradesViewModel.swift b/Campus-iOS/GradesComponent/ViewModel/MockGradesViewModel.swift index 9fe4cb80..67ab06e0 100644 --- a/Campus-iOS/GradesComponent/ViewModel/MockGradesViewModel.swift +++ b/Campus-iOS/GradesComponent/ViewModel/MockGradesViewModel.swift @@ -16,7 +16,7 @@ class MockGradesViewModel: GradesViewModel { ("1630 17 030", [("Wintersemester 2020/21", Grade.dummyData20W)]) ] - override init(model: Model, service: GradesServiceProtocol) { + override init(model: Model, service: GradesService) { super.init(model: model, service: service) self.state = .success(data: Grade.dummyDataAll) diff --git a/Campus-iOS/GradesComponent/Views/GradeWidgetView.swift b/Campus-iOS/GradesComponent/Views/GradeWidgetView.swift index 2adf75bd..15eb7af3 100644 --- a/Campus-iOS/GradesComponent/Views/GradeWidgetView.swift +++ b/Campus-iOS/GradesComponent/Views/GradeWidgetView.swift @@ -17,7 +17,9 @@ struct GradeWidgetView: View { @Binding var refresh: Bool init(model: Model, size: WidgetSize, refresh: Binding = .constant(false)) { +// self._viewModel = StateObject(wrappedValue: GradesViewModel(model: model, service: GradesService())) self._viewModel = StateObject(wrappedValue: GradesViewModel(model: model, service: GradesService())) + self.size = size self.initialSize = size self._refresh = refresh @@ -70,9 +72,9 @@ struct SimpleGradeWidgetContent: View { .foregroundColor(.clear) .frame(maxHeight: .infinity) .overlay { - if let grade, let gradeString = grade.grade { + if let grade { GeometryReader { g in - Text(gradeString.isEmpty ? "tbd" : gradeString) + Text(grade.grade.isEmpty ? "tbd" : grade.grade) .bold() .font(.system(size: g.size.height * 0.75)) .foregroundColor(GradesViewModel.GradeColor.color(for: grade)) diff --git a/Campus-iOS/GradesComponent/Views/GradesView.swift b/Campus-iOS/GradesComponent/Views/GradesView.swift index 6826efcc..07fc5651 100644 --- a/Campus-iOS/GradesComponent/Views/GradesView.swift +++ b/Campus-iOS/GradesComponent/Views/GradesView.swift @@ -9,6 +9,7 @@ import SwiftUI import SwiftUICharts struct GradesView: View { + @StateObject var vm: GradesViewModel @State private var data = AppUsageData() diff --git a/Campus-iOS/HelperViews/ImageFullScreenView.swift b/Campus-iOS/HelperViews/ImageFullScreenView.swift index ee784c5c..2a2066b0 100644 --- a/Campus-iOS/HelperViews/ImageFullScreenView.swift +++ b/Campus-iOS/HelperViews/ImageFullScreenView.swift @@ -24,8 +24,7 @@ struct ImageFullScreenView: View { }) .gesture(MagnificationGesture().onChanged { val in self.scale = val.magnitude - } - ) + }) } } diff --git a/Campus-iOS/LectureComponent/Model/Lecture.swift b/Campus-iOS/LectureComponent/Model/Lecture.swift index e98dbc2a..344a925d 100644 --- a/Campus-iOS/LectureComponent/Model/Lecture.swift +++ b/Campus-iOS/LectureComponent/Model/Lecture.swift @@ -1,5 +1,5 @@ // -// APIConstants.swift +// LectureComponents.swift // Campus-iOS // // Created by Philipp Zagar on 21.12.21. @@ -7,68 +7,60 @@ import Foundation -// As XMLDecoding is complete BS -typealias Lecture = LectureComponents.Row - -enum LectureComponents { - struct RowSet: Decodable { - public var row: [Row] - } - - struct Row: Decodable, Identifiable, Equatable { - public var id: UInt64 - public var lvNumber: UInt64 - public var title: String - public var duration: String - public var stp_sp_sst: String - public var eventTypeDefault: String - public var eventTypeTag: String - public var semesterYear: String - public var semesterType: String - public var semester: String - public var semesterID: String - public var organisationNumber: UInt64 - public var organisation: String - public var organisationTag: String - public var speaker: String - - public var eventType: String { - switch self.eventTypeDefault { - case "Vorlesung": - return "Lecture".localized - case "Tutorium", "Übung": - return "Exercise".localized - case "Praktikum": - return "Practical course".localized - case "Seminar": - return "Seminar".localized - case "Vorlesung mit integrierten Übungen": - return "Lecture with integrated Exercises".localized - default: - return "" - } - } - - enum CodingKeys: String, CodingKey { - case id = "stp_sp_nr" - case lvNumber = "stp_lv_nr" - case title = "stp_sp_titel" - case duration = "dauer_info" - case stp_sp_sst = "stp_sp_sst" - case eventTypeDefault = "stp_lv_art_name" - case eventTypeTag = "stp_lv_art_kurz" - case semesterYear = "sj_name" - case semesterType = "semester" - case semester = "semester_name" - case semesterID = "semester_id" - case organisationNumber = "org_nr_betreut" - case organisation = "org_name_betreut" - case organisationTag = "org_kennung_betreut" - case speaker = "vortragende_mitwirkende" +struct Lecture: Decodable, Identifiable, Equatable { + public var id: UInt64 + public var lvNumber: UInt64 + public var title: String + public var duration: String + public var stp_sp_sst: String + public var eventTypeDefault: String + public var eventTypeTag: String + public var semesterYear: String + public var semesterType: String + public var semester: String + public var semesterID: String + public var organisationNumber: UInt64 + public var organisation: String + public var organisationTag: String + public var speaker: String + + public var eventType: String { + switch self.eventTypeDefault { + case "Vorlesung": + return "Lecture".localized + case "Tutorium", "Übung": + return "Exercise".localized + case "Praktikum": + return "Practical course".localized + case "Seminar": + return "Seminar".localized + case "Vorlesung mit integrierten Übungen": + return "Lecture with integrated Exercises".localized + default: + return "" } } + + enum CodingKeys: String, CodingKey { + case id = "stp_sp_nr" + case lvNumber = "stp_lv_nr" + case title = "stp_sp_titel" + case duration = "dauer_info" + case stp_sp_sst = "stp_sp_sst" + case eventTypeDefault = "stp_lv_art_name" + case eventTypeTag = "stp_lv_art_kurz" + case semesterYear = "sj_name" + case semesterType = "semester" + case semester = "semester_name" + case semesterID = "semester_id" + case organisationNumber = "org_nr_betreut" + case organisation = "org_name_betreut" + case organisationTag = "org_kennung_betreut" + case speaker = "vortragende_mitwirkende" + } } + extension Lecture { static let dummyData: [Lecture] = [ Lecture(id: 950396293, lvNumber: 90049615, title: "Practical course - Program optimization with LLVM (IN0012, IN2106, IN4236)", duration: "6", stp_sp_sst: "6", eventTypeDefault: "Praktikum", eventTypeTag: "PR", semesterYear: "2018/19", semesterType: "W", semester: "Wintersemester 2018/19", semesterID: "18W", organisationNumber: 15427, organisation: "Informatik 2 - Lehrstuhl für Sprachen und Beschreibungsstrukturen in der Informatik (Prof. Seidl)", organisationTag: "TUINI02", speaker: "Seidl H [L], Petter M"), diff --git a/Campus-iOS/LectureComponent/Model/LectureDetails.swift b/Campus-iOS/LectureComponent/Model/LectureDetails.swift index 53f961e1..ffa7b1a9 100644 --- a/Campus-iOS/LectureComponent/Model/LectureDetails.swift +++ b/Campus-iOS/LectureComponent/Model/LectureDetails.swift @@ -7,94 +7,86 @@ import Foundation -// As XMLDecoding is complete BS -typealias LectureDetails = LectureDetailsComponents.Row - -enum LectureDetailsComponents { - struct RowSet: Decodable { - public var row: [Row] +struct LectureDetails: Decodable, Identifiable { + let id: UInt64 + let lvNumber: UInt64 + let title: String + let duration: String + let stp_sp_sst: String + let eventTypeDefault: String + let eventTypeTag: String + let semester: String + let semesterType: String + let semesterID: String + let semesterYear: String + let organisationNumber: UInt64 + let organisation: String + let organisationTag: String + let speaker: String + let courseContents: String? + let requirements: String? + let courseObjective: String? + let teachingMethod: String? + let anmeld_lv: String? + let firstScheduledDate: String? + let examinationMode: String? + let studienbehelfe: String? + let note: String? + let curriculumURL: URL? + let scheduledDatesURL: URL? + let examDateURL: URL? + + var speakerArray: [String] { + self.speaker.split(separator: ",").map({ $0.trimmingCharacters(in: .whitespaces) }) } - - struct Row: Decodable, Identifiable { - let id: UInt64 - let lvNumber: UInt64 - let title: String - let duration: String - let stp_sp_sst: String - let eventTypeDefault: String - let eventTypeTag: String - let semester: String - let semesterType: String - let semesterID: String - let semesterYear: String - let organisationNumber: UInt64 - let organisation: String - let organisationTag: String - let speaker: String - let courseContents: String? - let requirements: String? - let courseObjective: String? - let teachingMethod: String? - let anmeld_lv: String? - let firstScheduledDate: String? - let examinationMode: String? - let studienbehelfe: String? - let note: String? - let curriculumURL: URL? - let scheduledDatesURL: URL? - let examDateURL: URL? - - var speakerArray: [String] { - self.speaker.split(separator: ",").map({ $0.trimmingCharacters(in: .whitespaces) }) - } - - public var eventType: String { - switch self.eventTypeDefault { - case "Vorlesung": - return "Lecture".localized - case "Tutorium": - return "Exercise".localized - case "Praktikum": - return "Practice".localized - case "Vorlesung mit integrierten Übungen": - return "Lecture with integrated Exercises".localized - default: - return "" - } - } - - enum CodingKeys: String, CodingKey { - case id = "stp_sp_nr" - case lvNumber = "stp_lv_nr" - case title = "stp_sp_titel" - case duration = "dauer_info" - case stp_sp_sst = "stp_sp_sst" - case eventTypeDefault = "stp_lv_art_name" - case eventTypeTag = "stp_lv_art_kurz" - case semesterYear = "sj_name" - case semesterType = "semester" - case semester = "semester_name" - case semesterID = "semester_id" - case organisationNumber = "org_nr_betreut" - case organisation = "org_name_betreut" - case organisationTag = "org_kennung_betreut" - case speaker = "vortragende_mitwirkende" - case courseContents = "lehrinhalt" - case requirements = "voraussetzung_lv" - case courseObjective = "lehrziel" - case teachingMethod = "lehrmethode" - case anmeld_lv - case firstScheduledDate = "ersttermin" - case examinationMode = "pruefmodus" - case studienbehelfe - case note = "anmerkung" - case curriculumURL = "stellung_im_stp_url" - case scheduledDatesURL = "termine_url" - case examDateURL = "pruef_termine_url" + + public var eventType: String { + switch self.eventTypeDefault { + case "Vorlesung": + return "Lecture".localized + case "Tutorium": + return "Exercise".localized + case "Praktikum": + return "Practice".localized + case "Vorlesung mit integrierten Übungen": + return "Lecture with integrated Exercises".localized + default: + return "" } } + + enum CodingKeys: String, CodingKey { + case id = "stp_sp_nr" + case lvNumber = "stp_lv_nr" + case title = "stp_sp_titel" + case duration = "dauer_info" + case stp_sp_sst = "stp_sp_sst" + case eventTypeDefault = "stp_lv_art_name" + case eventTypeTag = "stp_lv_art_kurz" + case semesterYear = "sj_name" + case semesterType = "semester" + case semester = "semester_name" + case semesterID = "semester_id" + case organisationNumber = "org_nr_betreut" + case organisation = "org_name_betreut" + case organisationTag = "org_kennung_betreut" + case speaker = "vortragende_mitwirkende" + case courseContents = "lehrinhalt" + case requirements = "voraussetzung_lv" + case courseObjective = "lehrziel" + case teachingMethod = "lehrmethode" + case anmeld_lv + case firstScheduledDate = "ersttermin" + case examinationMode = "pruefmodus" + case studienbehelfe + case note = "anmerkung" + case curriculumURL = "stellung_im_stp_url" + case scheduledDatesURL = "termine_url" + case examDateURL = "pruef_termine_url" + } } + extension LectureDetails { static let dummyData: LectureDetails = .init( id: 1234, diff --git a/Campus-iOS/LectureComponent/Service/LectureDetailsService.swift b/Campus-iOS/LectureComponent/Service/LectureDetailsService.swift index c31831d8..d0fa24dd 100644 --- a/Campus-iOS/LectureComponent/Service/LectureDetailsService.swift +++ b/Campus-iOS/LectureComponent/Service/LectureDetailsService.swift @@ -15,11 +15,11 @@ protocol LectureDetailsServiceProtocol { struct LectureDetailsService: LectureDetailsServiceProtocol { func fetch(token: String, lvNr: UInt64, forcedRefresh: Bool = false) async throws -> LectureDetails { - let response: LectureDetailsComponents.RowSet = + let response: TUMOnlineAPI.Response = try await - CampusOnlineAPI + MainAPI .makeRequest( - endpoint: Constants.API.CampusOnline.lectureDetails(lvNr: String(lvNr)), + endpoint: TUMOnlineAPI.lectureDetails(lvNr: String(lvNr)), token: token, forcedRefresh: forcedRefresh ) diff --git a/Campus-iOS/LectureComponent/Service/LecturesService.swift b/Campus-iOS/LectureComponent/Service/LecturesService.swift index 3f8ac040..bd399095 100644 --- a/Campus-iOS/LectureComponent/Service/LecturesService.swift +++ b/Campus-iOS/LectureComponent/Service/LecturesService.swift @@ -15,14 +15,9 @@ protocol LecturesServiceProtocol { struct LecturesService: LecturesServiceProtocol { func fetch(token: String, forcedRefresh: Bool = false) async throws -> [Lecture] { - let response: LectureComponents.RowSet = + let response: TUMOnlineAPI.Response = try await - CampusOnlineAPI - .makeRequest( - endpoint: Constants.API.CampusOnline.personalLectures, - token: token, - forcedRefresh: forcedRefresh - ) + MainAPI.makeRequest(endpoint: TUMOnlineAPI.personalLectures, token: token, forcedRefresh: forcedRefresh) return response.row } diff --git a/Campus-iOS/LectureComponent/Views/LectureDetailsViews/LectureDetailsBasicInfoView.swift b/Campus-iOS/LectureComponent/Views/LectureDetailsViews/LectureDetailsBasicInfoView.swift index b99a17cc..bb1172bf 100644 --- a/Campus-iOS/LectureComponent/Views/LectureDetailsViews/LectureDetailsBasicInfoView.swift +++ b/Campus-iOS/LectureComponent/Views/LectureDetailsViews/LectureDetailsBasicInfoView.swift @@ -14,13 +14,13 @@ struct LectureDetailsBasicInfoView: View { @State private var chosenSpeaker = "" var lectureDetails: LectureDetails + @StateObject var personVM: PersonSearchViewModel + let model: Model - var viewModelPersonSearch: PersonSearchViewModel { - let viewModel = PersonSearchViewModel() - if self.chosenSpeaker.count > 3 { - viewModel.fetch(searchString: self.chosenSpeaker) - } - return viewModel + init(model: Model, lectureDetails: LectureDetails) { + self.model = model + self._personVM = StateObject(wrappedValue: PersonSearchViewModel(model: model, service: PersonSearchService())) + self.lectureDetails = lectureDetails } var actionSheetButtons: [ActionSheet.Button] { @@ -41,9 +41,9 @@ struct LectureDetailsBasicInfoView: View { .padding(.bottom, 10) ) { NavigationLink(isActive: self.$navigationLinkActive, destination: { - PersonSearchView(viewModel: self.viewModelPersonSearch, searchText: self.chosenSpeaker) + PersonSearchScreen(model: model, findPerson: chosenSpeaker) }) { - EmptyView() + EmptyView().onAppear{print("empty view")} } VStack(alignment: .leading, spacing: 8) { @@ -94,6 +94,6 @@ struct LectureDetailsBasicInfoView: View { struct LectureDetailsBasicInfoView_Previews: PreviewProvider { static var previews: some View { - LectureDetailsBasicInfoView(lectureDetails: LectureDetails.dummyData) + LectureDetailsBasicInfoView(model: Model(), lectureDetails: LectureDetails.dummyData) } } diff --git a/Campus-iOS/LectureComponent/Views/LectureDetailsViews/LectureDetailsEventInfoView.swift b/Campus-iOS/LectureComponent/Views/LectureDetailsViews/LectureDetailsEventInfoView.swift index 09df0bee..90bb5443 100644 --- a/Campus-iOS/LectureComponent/Views/LectureDetailsViews/LectureDetailsEventInfoView.swift +++ b/Campus-iOS/LectureComponent/Views/LectureDetailsViews/LectureDetailsEventInfoView.swift @@ -47,7 +47,9 @@ struct LectureDetailsEventInfoView: View { ) HStack { Spacer() - NavigationLink(destination: RoomFinderView(model: viewModel.model, viewModel: RoomFinderViewModel(), searchText: extract(room: self.location))) { + NavigationLink(destination: NavigaTumView(model: viewModel.model, searchText: extract(room: self.location)) + .navigationTitle(Text("Roomfinder")) + .navigationBarTitleDisplayMode(.large)) { HStack { Text("Open in RoomFinder") Image(systemName: "arrow.right.circle") diff --git a/Campus-iOS/LectureComponent/Views/LecturesDetailView.swift b/Campus-iOS/LectureComponent/Views/LecturesDetailView.swift index 673f9ea5..14ed8101 100644 --- a/Campus-iOS/LectureComponent/Views/LecturesDetailView.swift +++ b/Campus-iOS/LectureComponent/Views/LecturesDetailView.swift @@ -23,7 +23,7 @@ struct LecturesDetailView: View { LectureDetailsEventInfoView(viewModel: viewModel, event: event) } - LectureDetailsBasicInfoView(lectureDetails: lectureDetails) + LectureDetailsBasicInfoView(model: viewModel.model, lectureDetails: lectureDetails) LectureDetailsDetailedInfoView(lectureDetails: lectureDetails) diff --git a/Campus-iOS/LectureSearchComponent/Screen/LectureSearchScreen.swift b/Campus-iOS/LectureSearchComponent/Screen/LectureSearchScreen.swift new file mode 100644 index 00000000..66f61815 --- /dev/null +++ b/Campus-iOS/LectureSearchComponent/Screen/LectureSearchScreen.swift @@ -0,0 +1,76 @@ +// +// LectureSearchView.swift +// Campus-iOS +// +// Created by Milen Vitanov on 09.02.22. +// + +import SwiftUI + +struct LectureSearchScreen: View { + @StateObject var vm: LectureSearchViewModel + @State var searchText = "" + + init(model: Model) { + self._vm = StateObject(wrappedValue: LectureSearchViewModel(model: model, service: LectureSearchService())) + } + + var body: some View { + Group { + switch vm.state { + case .success(let lectures): + VStack { + LectureSearchView(model: vm.model, lectures: lectures) + .background(Color(.systemGroupedBackground)) + } + case .loading: + if searchText.count > 3 { + LoadingView(text: "Fetching Lectures") + } else { + Text("The search query must be at least 4 characters.") + } + case .failed(let error): + FailedView( + errorDescription: error.localizedDescription, + retryClosure: {_ in await vm.getLectures(for: self.searchText, forcedRefresh: true)} + ) + case .na: + if searchText.count > 0 && searchText.count <= 3 { + Text("The search query must be at least 4 characters.") + } + } + } + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) + .onChange(of: self.searchText) { query in + Task { + await vm.getLectures(for: query, forcedRefresh: true) + } + } + .alert( + "Error while fetching Lectures", + isPresented: $vm.hasError, + presenting: vm.state) { detail in + Button("Retry") { + Task { + await vm.getLectures(for: self.searchText, forcedRefresh: true) + } + } + + Button("Cancel", role: .cancel) { } + } message: { detail in + if case let .failed(error) = detail { + if let apiError = error as? TUMOnlineAPIError { + Text(apiError.errorDescription ?? "TUMOnlineAPI Error") + } else { + Text(error.localizedDescription) + } + } + } + } +} + +//struct LectureSearchView_Previews: PreviewProvider { +// static var previews: some View { +// LectureSearchView(model: MockModel()) +// } +//} diff --git a/Campus-iOS/LectureSearchComponent/Service/LectureSearchService.swift b/Campus-iOS/LectureSearchComponent/Service/LectureSearchService.swift new file mode 100644 index 00000000..6651966f --- /dev/null +++ b/Campus-iOS/LectureSearchComponent/Service/LectureSearchService.swift @@ -0,0 +1,16 @@ +// +// LectureSearchService.swift +// Campus-iOS +// +// Created by David Lin on 20.01.23. +// + +import Foundation + +struct LectureSearchService { + func fetch(for query: String, token: String, forcedRefresh: Bool) async throws -> [Lecture] { + let response : TUMOnlineAPI.Response = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.lectureSearch(search: query), token: token, forcedRefresh: forcedRefresh) + + return response.row + } +} diff --git a/Campus-iOS/LectureSearchComponent/View/LectureSearchListView.swift b/Campus-iOS/LectureSearchComponent/View/LectureSearchListView.swift deleted file mode 100644 index e3e4765b..00000000 --- a/Campus-iOS/LectureSearchComponent/View/LectureSearchListView.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// LectureSearchListView.swift -// Campus-iOS -// -// Created by Milen Vitanov on 13.02.22. -// - -import SwiftUI - -struct LectureSearchListView: View { - @StateObject var model: Model - @Environment(\.isSearching) private var isSearching - @ObservedObject var viewModel: LectureSearchViewModel - - var body: some View { - List { - ForEach(self.viewModel.result) { lecture in - NavigationLink( - destination: LectureDetailsScreen(model: self.model, lecture: lecture) - .navigationBarTitleDisplayMode(.inline) - ) { - HStack { - Text(lecture.title) - Spacer() - Text(lecture.eventType) - .foregroundColor(Color(.secondaryLabel)) - } - } - } - if viewModel.errorMessage != "" { - VStack { - Spacer() - Text(self.viewModel.errorMessage).foregroundColor(.gray) - Spacer() - } - } - } - .onChange(of: isSearching) { newValue in - if !newValue { - self.viewModel.result = [] - } - } - } -} - -struct LectureSearchListView_Previews: PreviewProvider { - static var previews: some View { - LectureSearchListView(model: MockModel(), viewModel: LectureSearchViewModel()) - } -} diff --git a/Campus-iOS/LectureSearchComponent/View/LectureSearchView.swift b/Campus-iOS/LectureSearchComponent/View/LectureSearchView.swift index 17c6d86f..6edd3a3d 100644 --- a/Campus-iOS/LectureSearchComponent/View/LectureSearchView.swift +++ b/Campus-iOS/LectureSearchComponent/View/LectureSearchView.swift @@ -1,32 +1,37 @@ // -// LectureSearchView.swift +// LectureSearchListView.swift // Campus-iOS // -// Created by Milen Vitanov on 09.02.22. +// Created by Milen Vitanov on 13.02.22. // import SwiftUI struct LectureSearchView: View { - @StateObject var model: Model - @ObservedObject var viewModel = LectureSearchViewModel() - @State var searchText = "" + let model: Model + let lectures: [Lecture] var body: some View { - LectureSearchListView(model: self.model, viewModel: self.viewModel) - .background(Color(.systemGroupedBackground)) - .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) - .onChange(of: self.searchText) { searchValue in - if searchValue.count > 3 { - self.viewModel.fetch(searchString: searchValue) + List { + ForEach(lectures) { lecture in + NavigationLink( + destination: LectureDetailsScreen(model: self.model, lecture: lecture) + .navigationBarTitleDisplayMode(.inline) + ) { + HStack { + Text(lecture.title) + Spacer() + Text(lecture.eventType) + .foregroundColor(Color(.secondaryLabel)) + } } } - .animation(.default, value: self.viewModel.result) + } } } -struct LectureSearchView_Previews: PreviewProvider { +struct LectureSearchListView_Previews: PreviewProvider { static var previews: some View { - LectureSearchView(model: MockModel()) + LectureSearchView(model: Model(), lectures: []) } } diff --git a/Campus-iOS/LectureSearchComponent/ViewModel/LectureSearchViewModel.swift b/Campus-iOS/LectureSearchComponent/ViewModel/LectureSearchViewModel.swift index 783ee1da..e019d0f9 100644 --- a/Campus-iOS/LectureSearchComponent/ViewModel/LectureSearchViewModel.swift +++ b/Campus-iOS/LectureSearchComponent/ViewModel/LectureSearchViewModel.swift @@ -11,30 +11,44 @@ import Foundation import Alamofire import XMLCoder +@MainActor class LectureSearchViewModel: ObservableObject { - @Published var result: [Lecture] = [] - @Published var errorMessage: String = "" + @Published var state: APIState<[Lecture]> = .na + @Published var hasError: Bool = false - private let sessionManager = Session.defaultSession + let model: Model + let service: LectureSearchService - func fetch(searchString: String) { - // activate only when more than 3 characters + init(model: Model, service: LectureSearchService) { + self.model = model + self.service = service + } + + func getLectures(for query: String, forcedRefresh: Bool) async { + guard query.count > 3 else { + // Since requests under 4 char is not allowed + return + } - let endpoint = TUMOnlineAPI.lectureSearch(search: searchString) - sessionManager.cancelAllRequests() - let request = sessionManager.request(endpoint) - request.responseDecodable(of: TUMOnlineAPIResponse.self, decoder: XMLDecoder()) { [weak self] response in - guard !request.isCancelled else { - // cancelAllRequests doesn't seem to cancel all requests, so better check for this explicitly - return - } - self?.result = response.value?.rows ?? [] + if !forcedRefresh { + self.state = .loading + } + self.hasError = false - if let result = self?.result, result.isEmpty { - self?.errorMessage = NSString(format: "Unable to find lecture".localized as NSString, searchString) as String - } else { - self?.errorMessage = "" - } + guard let token = self.model.token else { + self.state = .failed(error: NetworkingError.unauthorized) + self.hasError = true + return + } + + do { + self.state = .success( + data: try await service.fetch(for: query, token: token, forcedRefresh: forcedRefresh) + ) + + } catch { + self.state = .failed(error: error) + self.hasError = true } } } diff --git a/Campus-iOS/LoginComponent/Model/Confirmation.swift b/Campus-iOS/LoginComponent/Model/Confirmation.swift new file mode 100644 index 00000000..650a9c5f --- /dev/null +++ b/Campus-iOS/LoginComponent/Model/Confirmation.swift @@ -0,0 +1,16 @@ +// +// Confirmation.swift +// Campus-iOS +// +// Created by David Lin on 21.01.23. +// + +import Foundation + +struct Confirmation: Decodable { + let value: Bool + + private enum CodingKeys: String, CodingKey { + case value = "" + } +} diff --git a/Campus-iOS/LoginComponent/Service/Credentials.swift b/Campus-iOS/LoginComponent/Model/Credentials.swift similarity index 100% rename from Campus-iOS/LoginComponent/Service/Credentials.swift rename to Campus-iOS/LoginComponent/Model/Credentials.swift diff --git a/Campus-iOS/LoginComponent/Model/Token.swift b/Campus-iOS/LoginComponent/Model/Token.swift new file mode 100644 index 00000000..440bbc8a --- /dev/null +++ b/Campus-iOS/LoginComponent/Model/Token.swift @@ -0,0 +1,16 @@ +// +// Token.swift +// Campus-iOS +// +// Created by David Lin on 21.01.23. +// + +import Foundation + +struct Token: Decodable { + let value: String + + private enum CodingKeys: String, CodingKey { + case value = "" + } +} diff --git a/Campus-iOS/LoginComponent/Service/AuthenticationHandler.swift b/Campus-iOS/LoginComponent/Service/AuthenticationHandler.swift index addfc92e..f1b22657 100644 --- a/Campus-iOS/LoginComponent/Service/AuthenticationHandler.swift +++ b/Campus-iOS/LoginComponent/Service/AuthenticationHandler.swift @@ -33,15 +33,7 @@ enum LoginError: LocalizedError { } } -/// Handles authentication for TUMOnline, TUMCabe and the MVGAPI -final class AuthenticationHandler: RequestAdapter, RequestRetrier { - typealias Completion = (Result) -> Void - - private let lock = NSLock() - private let sessionManager = Session() - private var isRefreshing = false - private var requestsToRetry: [(RetryResult) -> Void] = [] - +class AuthenticationHandler { private static let keychain = Keychain(service: "de.tum.campusapp") .synchronizable(true) .accessibility(.afterFirstUnlock) @@ -55,151 +47,54 @@ final class AuthenticationHandler: RequestAdapter, RequestRetrier { return Credentials.noTumID } - guard let data = AuthenticationHandler.keychain[data: "credentials"] else { return nil } + guard let data = Self.keychain[data: "credentials"] else { return nil } return try? PropertyListDecoder().decode(Credentials.self, from: data) } set { if let newValue = newValue { let data = try! PropertyListEncoder().encode(newValue) - AuthenticationHandler.keychain[data: "credentials"] = data + Self.keychain[data: "credentials"] = data } else { - AuthenticationHandler.keychain[data: "credentials"] = nil - } - } - } - - // MARK: - RequestAdapter - - func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { - var urlRequest = urlRequest - guard let urlString = urlRequest.url?.absoluteString else { return completion(.success(urlRequest)) } - var pToken: String? - - switch credentials { - case .tumID(_, let token)?, - .tumIDAndKey(_, let token, _)?: - pToken = token - default: - break - } - - switch urlString { - case urlString where TUMOnlineAPI.requiresAuth.contains { urlString.contains($0) }: - guard let pToken = pToken else { return completion(.failure(LoginError.missingToken)) } - do { - let encodedRequest = try URLEncoding.default.encode(urlRequest, with: ["pToken": pToken]) - return completion(.success(encodedRequest)) - } catch let error { - CrashlyticsService.log(error) - return completion(.failure(error)) + Self.keychain[data: "credentials"] = nil } - case urlString where TUMCabeAPI.requiresAuth.contains { urlString.contains($0)}: - return completion(.success(urlRequest)) - case urlString where urlString.hasPrefix(MVGAPI.baseURL): - urlRequest.addValue(MVGAPI.apiKey, forHTTPHeaderField: "X-MVG-Authorization-Key") - return completion(.success(urlRequest)) - default: - return completion(.success(urlRequest)) } } - // MARK: - RequestRetrier - - func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) { - lock.lock() ; defer { lock.unlock() } - - requestsToRetry.append(completion) - - guard isRefreshing else { - completion(.doNotRetry) - return - } - - let tumID: String - switch credentials { - case .none, - .noTumID?: - completion(.doNotRetry) - return - case .tumID(let id,_)?, - .tumIDAndKey(let id,_,_)?: - tumID = id - } - - createToken(tumID: tumID) { [weak self] result in - guard let strongSelf = self else { return } - strongSelf.lock.lock() ; defer { strongSelf.lock.unlock() } - - switch result { - case .success(let token): - // Auth succeeded retry failed request. - switch strongSelf.credentials { - case .none: strongSelf.credentials = .tumID(tumID: tumID, token: token) - case .noTumID?: strongSelf.credentials = .tumID(tumID: tumID, token: token) - case .tumID(let tumID, _)?: strongSelf.credentials = .tumID(tumID: tumID, token: token) - case .tumIDAndKey(let tumID, _, let key)?: strongSelf.credentials = .tumIDAndKey(tumID: tumID, token: token, key: key) - } - - default: - // Auth failed don't retry. - break - } - - strongSelf.requestsToRetry.forEach { $0(.retry) } - strongSelf.requestsToRetry.removeAll() + func createToken(tumID: String, completion: @escaping (Result) -> Void) async { + do { + let tokenName = "TCA - \(await UIDevice.current.name)" + + let token: Token = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.tokenRequest(tumID: tumID, tokenName: tokenName), forcedRefresh: true) + print(token.value) + self.credentials = Credentials.tumID(tumID: tumID, token: token.value) + completion(.success(token.value)) + } catch { + print(error) + completion(.failure(LoginError.serverError(message: error.localizedDescription))) } } - - func createToken(tumID: String, completion: @escaping Completion) { - guard !isRefreshing else { return } - isRefreshing = true - let tokenName = "TCA - \(UIDevice.current.name)" - - sessionManager.request(TUMOnlineAPI.tokenRequest(tumID: tumID, tokenName: tokenName)) - .validate(statusCode: 200..<300) - .validate(contentType: ["text/xml"]) - .responseXML { [weak self] xml in - guard let strongSelf = self else { return } - guard let newToken = xml.value?["token"].element?.text else { - strongSelf.isRefreshing = false - if let error = xml.error { - return completion(.failure(error)) - } else if let errorMessage = xml.value?["error"]["message"].element?.text { - return completion(.failure(LoginError.serverError(message: errorMessage))) - } - return completion(.failure(LoginError.unknown)) - } - strongSelf.credentials = Credentials.tumID(tumID: tumID, token: newToken) - strongSelf.isRefreshing = false - #if !targetEnvironment(macCatalyst) - Analytics.logEvent("token_created", parameters: nil) - #endif - completion(.success(newToken)) + + func confirmToken() async -> Result { + guard let credentials else { + return .failure(TUMOnlineAPIError.invalidToken) } - } - - func confirmToken(callback: @escaping (Result) -> Void) { - let sessionManager: Session = Session.defaultSession - + switch credentials { - case .none: callback(.failure(LoginError.missingToken)) - case .noTumID?: callback(.failure(LoginError.missingToken)) - case .tumID?, - .tumIDAndKey?: - sessionManager.request(TUMOnlineAPI.tokenConfirmation) - .validate(statusCode: 200..<300) - .validate(contentType: ["text/xml"]) - .responseXML { xml in - if let error = xml.error { - callback(.failure(error)) - } else if xml.value?["confirmed"].element?.text == "true" { - callback(.success(true)) - } else if xml.value?["confirmed"].element?.text == "false" { - callback(.failure(TUMOnlineAPIError.tokenNotConfirmed)) + case .noTumID: + return .failure(LoginError.missingToken) + case .tumID(tumID: _, token: let token), .tumIDAndKey(tumID: _, token: let token, key: _): + do { + let confirmation: Confirmation = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.tokenConfirmation, token: token, forcedRefresh: true) + + if confirmation.value { + return .success(true) } else { - callback(.failure(LoginError.unknown)) + return .failure(TUMOnlineAPIError.tokenNotConfirmed) } - } + } catch { + print(error.localizedDescription) + return .failure(LoginError.serverError(message: error.localizedDescription)) + } } } @@ -207,7 +102,7 @@ final class AuthenticationHandler: RequestAdapter, RequestRetrier { #if !targetEnvironment(macCatalyst) Analytics.logEvent("logout", parameters: nil) #endif - // deletes uthenticationHandler.keychain[data: "credentials"] + // deletes authenticationHandler.keychain[data: "credentials"] credentials = nil } @@ -217,25 +112,4 @@ final class AuthenticationHandler: RequestAdapter, RequestRetrier { #endif credentials = .noTumID } - -} - - -final class ForceHTTPSRedirectHandler: RedirectHandler { - func task(_ task: URLSessionTask, - willBeRedirectedTo request: URLRequest, - for response: HTTPURLResponse, - completion: @escaping (URLRequest?) -> Void) { - - guard let url = request.url else { return completion(request) } - - if url.scheme == "http" { - let modifiedURL = url.absoluteString.replacingOccurrences(of: "http", with: "https") - var modifiedRequest = request - modifiedRequest.url = URL(string: modifiedURL) - return completion(modifiedRequest) - } - - return completion(request) - } } diff --git a/Campus-iOS/LoginComponent/ViewModel/LoginViewModel.swift b/Campus-iOS/LoginComponent/ViewModel/LoginViewModel.swift index cf5b02d1..0c71a3e0 100644 --- a/Campus-iOS/LoginComponent/ViewModel/LoginViewModel.swift +++ b/Campus-iOS/LoginComponent/ViewModel/LoginViewModel.swift @@ -11,6 +11,7 @@ import SwiftUI import FirebaseAnalytics #endif +@MainActor class LoginViewModel: ObservableObject { @Published var firstTextField = "" @Published var numbersTextField = "" @@ -18,7 +19,7 @@ class LoginViewModel: ObservableObject { @Published var alertMessage = "" private static let hapticFeedbackGenerator = UINotificationFeedbackGenerator() - weak var model: Model? + var model: Model var loginController = AuthenticationHandler() var isContinueEnabled: Bool { @@ -33,51 +34,52 @@ class LoginViewModel: ObservableObject { return "\(firstTextField)\(numbersTextField)\(secondTextField)" } - init(model: Model?) { + init(model: Model) { self.model = model } - func loginWithContinue(callback: @escaping (Result) -> Void) { + func loginWithContinue(callback: @escaping (Result) -> Void) async { guard let tumID = tumID else { callback(.failure(LoginError.serverError(message: "No TUM ID"))) return } - loginController.createToken(tumID: tumID) { [weak self] result in + + await loginController.createToken(tumID: tumID, completion: { result in switch result { case .success: - self?.alertMessage = "" + DispatchQueue.main.async { + self.alertMessage = "" + } callback(.success(true)) case let .failure(error): - self?.alertMessage = error.localizedDescription + self.alertMessage = error.localizedDescription callback(.failure(error)) } - } + }) } func loginWithContinueWithoutTumID() { loginController.skipLogin() } - func checkAuthorization(callback: @escaping (Result) -> Void) { - loginController.confirmToken() { [weak self] result in - switch result { + func checkAuthorization(callback: @escaping (Result) -> Void) async { + switch await model.loginController.confirmToken() { case .success: #if !targetEnvironment(macCatalyst) Analytics.logEvent("token_confirmed", parameters: nil) #endif - //wself?.model?.isLoginSheetPresented = false - self?.model?.isUserAuthenticated = true - self?.model?.showProfile = false - self?.model?.loadProfile() - - Self.hapticFeedbackGenerator.notificationOccurred(.success) - + + DispatchQueue.main.async { + self.model.isUserAuthenticated = true + self.model.showProfile = false + } + callback(.success(true)) - case let .failure(error): - self?.model?.isUserAuthenticated = false - self?.alertMessage = error.localizedDescription + case .failure(let error): + self.model.isUserAuthenticated = false + self.alertMessage = error.localizedDescription + callback(.failure(error)) - } } } } diff --git a/Campus-iOS/LoginComponent/ViewModel/TokenPermissionsViewModel.swift b/Campus-iOS/LoginComponent/ViewModel/TokenPermissionsViewModel.swift index e5624b8a..3b5ba904 100644 --- a/Campus-iOS/LoginComponent/ViewModel/TokenPermissionsViewModel.swift +++ b/Campus-iOS/LoginComponent/ViewModel/TokenPermissionsViewModel.swift @@ -55,53 +55,44 @@ class TokenPermissionsViewModel: ObservableObject { self.states[.grades] = .failed(error: error) } case .calendar: - let calenderVM = CalendarViewModel(model: self.model) - - calenderVM.fetch { result in - if case let .success(data) = result { - print("Success") - self.states[.calendar] = .success(data: data) - } else { - print("No success") - self.states[.calendar] = .failed(error: CampusOnlineAPI.Error.noPermission) - } + do { + self.states[.calendar] = .success( + data: try await CalendarService().fetch(token: token, forcedRefresh: true)) + } catch { + self.states[.calendar] = .failed(error: error) } - case .lectures: do { self.states[.lectures] = .success( data: try await LecturesService().fetch(token: token, forcedRefresh: true)) } catch { self.states[.lectures] = .failed(error: error) - print(error) } case .tuitionFees: - - let profileVM = ProfileViewModel(model: self.model) - profileVM.fetch() - profileVM.checkTuitionFunc() { result in - print(result) - if case let .success(data) = result { - print("Success") - self.states[.tuitionFees] = .success(data: data) - } else { - print("no success") - self.states[.tuitionFees] = .failed(error: CampusOnlineAPI.Error.noPermission) + do { + guard let tuitionFees: Tuition = try await ProfileService().fetch(token: token, forcedRefresh: true) else { + self.states[.identification] = .failed(error: TUMOnlineAPIError(message: "Tuition couldn't be loaded.")) + break } + + self.states[.tuitionFees] = .success( + data: tuitionFees) + } catch { + self.states[.identification] = .failed(error: error) } case .identification: - let profileVM = ProfileViewModel(model: self.model) - profileVM.fetch() { result in - print(result) - if case let .success(data) = result { - print("Success") - self.states[.identification] = .success(data: data) - } else { - print("no success") - self.states[.identification] = .failed(error: CampusOnlineAPI.Error.noPermission) + do { + guard let profile: Profile = try await ProfileService().fetch(token: token, forcedRefresh: true) else { + self.states[.identification] = .failed(error: TUMOnlineAPIError(message: "Tuition couldn't be loaded.")) + break } + + self.states[.identification] = .success( + data: profile) + } catch { + self.states[.identification] = .failed(error: error) } } } diff --git a/Campus-iOS/LoginComponent/Views/LoginView.swift b/Campus-iOS/LoginComponent/Views/LoginView.swift index f171a86d..a8d87698 100644 --- a/Campus-iOS/LoginComponent/Views/LoginView.swift +++ b/Campus-iOS/LoginComponent/Views/LoginView.swift @@ -106,20 +106,22 @@ struct LoginView: View { if showLoginButton { Button { if logInState != .loggedIn { - self.viewModel.loginWithContinue() { result in - switch result { - case .success: - withAnimation() { - buttonBackgroundColor = .blue - logInState = .loggedIn - } - print("Log in Successfull") - case .failure(_): - withAnimation() { - buttonBackgroundColor = .red - logInState = .logInError + Task { + await self.viewModel.loginWithContinue { result in + switch result { + case .success: + withAnimation() { + buttonBackgroundColor = .blue + logInState = .loggedIn + } + print("Log in Successfull") + case .failure(_): + withAnimation() { + buttonBackgroundColor = .red + logInState = .logInError + } + print("Loggin Error") } - print("Loggin Error") } } } @@ -175,7 +177,7 @@ struct LoginView: View { Button(action: { self.viewModel.loginWithContinueWithoutTumID() - self.viewModel.model?.isLoginSheetPresented = false + self.viewModel.model.isLoginSheetPresented = false }) { Text("Continue without TUM ID").lineLimit(1).font(.caption) .frame(alignment: .center) diff --git a/Campus-iOS/LoginComponent/Views/TokenConfirmationView.swift b/Campus-iOS/LoginComponent/Views/TokenConfirmationView.swift index 23aa8da3..1a9b5518 100644 --- a/Campus-iOS/LoginComponent/Views/TokenConfirmationView.swift +++ b/Campus-iOS/LoginComponent/Views/TokenConfirmationView.swift @@ -131,19 +131,21 @@ struct TokenConfirmationView: View { if !tokenPermissionButton { Button(action: { - self.viewModel.checkAuthorization() { result in - switch result { - case .success: - withAnimation { - tokenState = .active - buttonBackgroundColor = .green - showTokenHelp = false - } - case .failure(_): - withAnimation { - tokenState = .inactive - buttonBackgroundColor = .red - showTokenHelp = true + Task { + await self.viewModel.checkAuthorization() { result in + switch result { + case .success: + withAnimation { + tokenState = .active + buttonBackgroundColor = .green + showTokenHelp = false + } + case .failure(_): + withAnimation { + tokenState = .inactive + buttonBackgroundColor = .red + showTokenHelp = true + } } } } @@ -176,17 +178,15 @@ struct TokenConfirmationView: View { } } case .active: - if let model = self.viewModel.model { - NavigationLink(destination: TokenPermissionsView(viewModel: TokenPermissionsViewModel(model: model)).navigationTitle("Check Permissions"), isActive: $isActive) { - Text("Next") - .lineLimit(1) - .font(.body) - .frame(width: 200, height: 48, alignment: .center) - .foregroundColor(.white) - .background(.green) - .cornerRadius(10) - .buttonStyle(.plain) - } + NavigationLink(destination: TokenPermissionsView(viewModel: TokenPermissionsViewModel(model: self.viewModel.model)).navigationTitle("Check Permissions"), isActive: $isActive) { + Text("Next") + .lineLimit(1) + .font(.body) + .frame(width: 200, height: 48, alignment: .center) + .foregroundColor(.white) + .background(.green) + .cornerRadius(10) + .buttonStyle(.plain) } } } @@ -255,11 +255,7 @@ struct TokenConfirmationView: View { private func switchSteps() async { // Delay of 5 seconds (1 second = 1_000_000_000 nanoseconds) - guard let model = self.viewModel.model else { - return - } - - while (!model.isUserAuthenticated) { + while (!self.viewModel.model.isUserAuthenticated) { switch currentStep { case 1: try? await Task.sleep(nanoseconds: 5_160_000_000) diff --git a/Campus-iOS/LoginComponent/Views/TokenPermissionsView.swift b/Campus-iOS/LoginComponent/Views/TokenPermissionsView.swift index 7fc1f9d7..8f20cbee 100644 --- a/Campus-iOS/LoginComponent/Views/TokenPermissionsView.swift +++ b/Campus-iOS/LoginComponent/Views/TokenPermissionsView.swift @@ -49,7 +49,7 @@ struct TokenPermissionsView: View { .padding() VStack { - HStack (){ + HStack { Button { self.showTUMOnline = true self.doneButton = false @@ -179,7 +179,7 @@ struct TokenPermissionsView: View { case .failed(let error): switch error { - case CampusOnlineAPI.Error.noPermission: + case TUMOnlineAPIError.noPermission: Image(systemName: "x.circle.fill").foregroundColor(.red) case NetworkingError.deviceIsOffline: Image(systemName: "wifi.slash").foregroundColor(.red) diff --git a/Campus-iOS/MapComponent/Service/CafeteriasService.swift b/Campus-iOS/MapComponent/Service/CafeteriasService.swift index 2b9efed3..fcb35451 100644 --- a/Campus-iOS/MapComponent/Service/CafeteriasService.swift +++ b/Campus-iOS/MapComponent/Service/CafeteriasService.swift @@ -6,13 +6,54 @@ // import Foundation +import Alamofire -protocol CafeteriasServiceProtocol { - func fetch(forcedRefresh: Bool) async throws -> [Cafeteria] -} - -struct CafeteriasService: CafeteriasServiceProtocol { +struct CafeteriasService: ServiceProtocol { + typealias T = Cafeteria + func fetch(forcedRefresh: Bool) async throws -> [Cafeteria] { - return try await EatAPI.fetchCafeterias(forcedRefresh: forcedRefresh) + let endpoint = EatAPI.canteens + + var response: [Cafeteria] = try await MainAPI.makeRequest(endpoint: endpoint) + + for i in response.indices { + if let queueStatusApi = response[i].queueStatusApi { + response[i].queue = try await fetch(eatAPI: endpoint, queueStatusApi: queueStatusApi, forcedRefresh: forcedRefresh) + } + } + + return response + } + + func fetch(eatAPI: EatAPI, queueStatusApi: String, forcedRefresh: Bool) async throws -> Queue { + if !forcedRefresh, let data = MainAPI.cache.value(forKey: queueStatusApi), let typedData = data as? Queue { + return typedData + } else { + var data: Data + do { + data = try await AF.request(queueStatusApi).serializingData().value + } catch { + print(error) + throw NetworkingError.deviceIsOffline + } + + if let error = try? eatAPI.decode(EatAPI.error, from: data) { + print(error) + throw error + } + + do { + // Decode data from the respective endpoint. + let decodedData = try eatAPI.decode(Queue.self, from: data) + // Write value to cache + MainAPI.cache.setValue(decodedData, forKey: queueStatusApi, cost: data.count) + + return decodedData + + } catch { + print(error) + throw EatAPI.error.init(message: error.localizedDescription) + } + } } } diff --git a/Campus-iOS/MapComponent/Service/DishService.swift b/Campus-iOS/MapComponent/Service/DishService.swift new file mode 100644 index 00000000..00f9bbc9 --- /dev/null +++ b/Campus-iOS/MapComponent/Service/DishService.swift @@ -0,0 +1,26 @@ +// +// DishService.swift +// Campus-iOS +// +// Created by David Lin on 23.01.23. +// + +import Foundation + +struct DishService { + func fetch(forcedRefresh: Bool) async -> [String: DishLabel]? { + do { + let response: [DishLabel] = try await MainAPI.makeRequest(endpoint: EatAPI.labels, forcedRefresh: forcedRefresh) + var labels = [String: DishLabel]() + for dishLabel in response { + labels[dishLabel.name] = dishLabel + } + + return labels + } catch { + print(error) + return nil + // No error is thrown, since the labels, can still be displayed, but just as text, instead of an emoji. + } + } +} diff --git a/Campus-iOS/MapComponent/Service/MealPlanService.swift b/Campus-iOS/MapComponent/Service/MealPlanService.swift new file mode 100644 index 00000000..0d99ace0 --- /dev/null +++ b/Campus-iOS/MapComponent/Service/MealPlanService.swift @@ -0,0 +1,75 @@ +// +// MealPlanService.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import Foundation + +struct MealPlanService { + func fetch(cafeteria: Cafeteria, forcedRefresh: Bool) async throws -> [Menu] { + let thisWeekAPI = EatAPI.menu(location: cafeteria.id, year: Date().year, week: Date().weekOfYear) + + let thisWeekMealPlanResponse = try await fetch(menu: thisWeekAPI, forcedRefresh: forcedRefresh) + + let thisWeekMenu: [Menu] = getMenuPerDay(mealPlan: thisWeekMealPlanResponse) + + guard let nextWeek = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: Date()) else { + return thisWeekMenu + } + let nextWeekAPI = EatAPI.menu(location: cafeteria.id, year: nextWeek.year, week: nextWeek.weekOfYear) + + var menus = thisWeekMenu + + do { + let nextWeekMealPlanResponse = try await fetch(menu: nextWeekAPI, forcedRefresh: forcedRefresh) + + let nextWeekMenu = getMenuPerDay(mealPlan: nextWeekMealPlanResponse) + + menus = menus + nextWeekMenu.filter{ menu in !thisWeekMenu.contains(where: {$0.date == menu.date}) } // don't re-add already existent days + } catch { + //Throw no error, since sometimes the next weeks menu isn't ready yet, thus a 404 error is thrown, but at the end of the week the next week's menu is ready. + print(error) + } + + return menus + } + + func fetch(menu: EatAPI, forcedRefresh: Bool) async throws -> MealPlan { + let response: MealPlan = try await MainAPI.makeRequest(endpoint: menu, forcedRefresh: forcedRefresh) + + return response + } + + + + func getMenuPerDay(mealPlan: MealPlan) -> [Menu] { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + var menus = [Menu]() + menus = mealPlan.days + .filter { !$0.dishes.isEmpty && ($0.date.isToday || $0.date.isLaterThanOrEqual(to: Date())) } + .sorted { $0.date < $1.date } + .map { + let categories = categories(from: $0.dishes) + return Menu(date: $0.date, categories: categories) + } + + return menus.sorted { $0.date < $1.date } + } + + func categories(from dishes: [Dish]) -> [MenuCategory] { + return dishes + .sorted { $0.dishType < $1.dishType } + .reduce(into: [:]) { (acc: inout [String: [Dish]], dish: Dish) -> () in + let type = dish.dishType.isEmpty ? "Sonstige" : dish.dishType + if acc[type] != nil { + acc[type]?.append(dish) + } + acc[type] = [dish] + } + .map { MenuCategory(name: $0.key, dishes: $0.value) } + } +} diff --git a/Campus-iOS/MapComponent/Service/MensaEnumService.swift b/Campus-iOS/MapComponent/Service/MensaEnumService.swift deleted file mode 100644 index 26fe5ab3..00000000 --- a/Campus-iOS/MapComponent/Service/MensaEnumService.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// MensaEnumService.swift -// Campus-iOS -// -// Created by August Wittgenstein on 14.01.22. -// - -import Foundation -import Alamofire - -final class MensaEnumService { - static let shared = MensaEnumService() - private var labels: [String : DishLabel]? - - public func getLabels() -> [String : DishLabel] { - if self.labels == nil { - self.labels = [:] - AF.request(EatAPI.labels).responseDecodable(of: [DishLabel].self) { (response) in - let labels = response.value ?? [] - for label in labels{ - self.labels?[label.name] = label - } - } - } - - return self.labels! - } -} diff --git a/Campus-iOS/MapComponent/Service/StudyRoomsService.swift b/Campus-iOS/MapComponent/Service/StudyRoomsService.swift index 18f9a5b5..48115893 100644 --- a/Campus-iOS/MapComponent/Service/StudyRoomsService.swift +++ b/Campus-iOS/MapComponent/Service/StudyRoomsService.swift @@ -7,12 +7,16 @@ import Foundation -protocol StudyRoomsServiceProtocol { - func fetch(forcedRefresh: Bool) async throws -> StudyRoomApiRespose -} - -struct StudyRoomsService: StudyRoomsServiceProtocol { +struct StudyRoomsService { func fetch(forcedRefresh: Bool) async throws -> StudyRoomApiRespose { - return try await TUMDevAppAPI.fetchStudyRooms(forcedRefresh: forcedRefresh) + let response: StudyRoomApiRespose = try await MainAPI.makeRequest(endpoint: TUMDevAppAPI.rooms, forcedRefresh: forcedRefresh) + + return response + } + + func fetchMap(room: String, forcedRefresh: Bool) async throws -> [RoomImageMapping] { + let response: [RoomImageMapping] = try await MainAPI.makeRequest(endpoint: TUMCabeAPI.roomMaps(room: room)) + + return response } } diff --git a/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoom.swift b/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoom.swift index fb701e98..f4cf5533 100644 --- a/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoom.swift +++ b/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoom.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI -struct StudyRoom: Entity { +struct StudyRoom: Decodable { var buildingCode: String? var buildingName: String? var buildingNumber: Int64 diff --git a/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomApiResponse.swift b/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomApiResponse.swift index 93e21d93..2377c0e0 100644 --- a/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomApiResponse.swift +++ b/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomApiResponse.swift @@ -7,7 +7,7 @@ import Foundation -struct StudyRoomApiRespose: Entity, Equatable { +struct StudyRoomApiRespose: Decodable, Equatable { static func == (lhs: StudyRoomApiRespose, rhs: StudyRoomApiRespose) -> Bool { lhs.groups?.map({$0.id}) == rhs.groups?.map({$0.id}) && lhs.rooms?.map({$0.id}) == rhs.rooms?.map({$0.id}) diff --git a/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomAttribute.swift b/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomAttribute.swift index 147c675d..24f8d1e7 100644 --- a/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomAttribute.swift +++ b/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomAttribute.swift @@ -7,7 +7,7 @@ import Foundation -struct StudyRoomAttribute: Entity { +struct StudyRoomAttribute: Decodable { var detail: String? var name: String? diff --git a/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomGroup.swift b/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomGroup.swift index e399c433..2f58ba3e 100644 --- a/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomGroup.swift +++ b/Campus-iOS/MapComponent/Types/StudyRooms/StudyRoomGroup.swift @@ -8,7 +8,7 @@ import Foundation import MapKit -struct StudyRoomGroup: Entity, Equatable { +struct StudyRoomGroup: Decodable, Equatable { var detail: String? var id: Int64 var name: String? diff --git a/Campus-iOS/MapComponent/View/Cafeterias/CafeteriaWidgetView.swift b/Campus-iOS/MapComponent/View/Cafeterias/CafeteriaWidgetView.swift index 539c00f0..eed863f5 100644 --- a/Campus-iOS/MapComponent/View/Cafeterias/CafeteriaWidgetView.swift +++ b/Campus-iOS/MapComponent/View/Cafeterias/CafeteriaWidgetView.swift @@ -33,13 +33,12 @@ struct CafeteriaWidgetView: View { WidgetLoadingView(text: "Searching nearby cafeteria") default: if let cafeteria = viewModel.cafeteria, - let title = cafeteria.title, - let coordinate = cafeteria.coordinate { + let title = cafeteria.title { CafeteriaWidgetContent( size: size, cafeteria: title, - dishes: viewModel.menuViewModel?.getDishes() ?? [], - coordinate: coordinate + dishes: viewModel.menu?.getDishes() ?? [], + coordinate: cafeteria.coordinate ) } else { TextWidgetView(text: "There was an error getting the menu from the nearest cafeteria.") @@ -62,13 +61,13 @@ struct CafeteriaWidgetView: View { } .sheet(isPresented: $showDetails) { VStack { - if let cafeteria = viewModel.cafeteria, let mealVm = viewModel.mealPlanViewModel { + if let cafeteria = viewModel.cafeteria { CafeteriaView( vm: MapViewModel(cafeteriaService: CafeteriasService(), studyRoomsService: StudyRoomsService()), selectedCanteen: .constant(cafeteria), canDismiss: false ) - MealPlanView(viewModel: mealVm) + MealPlanScreen(cafeteria: cafeteria) } else { ProgressView() } @@ -164,13 +163,16 @@ struct CompactMenuView: View { struct CompactDishView: View { - var dish: Dish + @StateObject var vm: DishViewModel + init(dish: Dish) { + self._vm = StateObject(wrappedValue: DishViewModel(dish: dish)) + } var body: some View { VStack(alignment: .leading) { - Text(dish.name) + Text(vm.dish.name) .lineLimit(1) - Text(DishView.formatPrice(dish: dish, pricingGroup: "students")) + Text(vm.formatPrice(dish: vm.dish, pricingGroup: "students")) .font(.caption) .bold() } diff --git a/Campus-iOS/MapComponent/View/Cafeterias/DishView.swift b/Campus-iOS/MapComponent/View/Cafeterias/DishView.swift new file mode 100644 index 00000000..5196cb75 --- /dev/null +++ b/Campus-iOS/MapComponent/View/Cafeterias/DishView.swift @@ -0,0 +1,60 @@ +// +// DishView.swift +// Campus-iOS +// +// Created by David Lin on 23.01.23. +// + +import SwiftUI + +struct DishView: View { + @StateObject var vm: DishViewModel + @State private var isExpanded = false + + init(dish: Dish) { + self._vm = StateObject(wrappedValue: DishViewModel(dish: dish)) + } + + var body: some View { + Group { + switch vm.state { + case .success(data: let generalLabels): + DisclosureGroup(isExpanded: $isExpanded) { + HStack{ + VStack{ + ForEach(vm.dish.labels, id: \.self) { label in + Text(vm.labelToAbbreviation(generalLabel: generalLabels, label: label)) + } + } + .padding(.trailing, 10.0) + VStack(alignment: .leading){ + ForEach(vm.dish.labels, id: \.self) { label in + Text(vm.labelToDescription(generalLabel: generalLabels, label: label)) + } + } + } + } label: { + VStack(alignment: .leading, spacing: 10){ + Spacer().frame(height: 0) + Text(vm.dish.name).bold() + HStack{ + Spacer() + Text(vm.formatPrice(dish: vm.dish, pricingGroup: "students")) + .lineLimit(1) + .font(.system(size: 15)) + } + Spacer().frame(height: 0) + } + } + .buttonStyle(PlainButtonStyle()).disabled(true) + case .loading, .na: + LoadingView(text: "Fetching Dish Labels") + case .failed(error: let error): + FailedView(errorDescription: error.localizedDescription, retryClosure: vm.getDishLabels + ) + } + }.task { + await vm.getDishLabels() + } + } +} diff --git a/Campus-iOS/MapComponent/View/Cafeterias/MealPlanScreen.swift b/Campus-iOS/MapComponent/View/Cafeterias/MealPlanScreen.swift new file mode 100644 index 00000000..028333cd --- /dev/null +++ b/Campus-iOS/MapComponent/View/Cafeterias/MealPlanScreen.swift @@ -0,0 +1,42 @@ +// +// MealPlanScreen.swift +// Campus-iOS +// +// Created by David Lin on 10.02.23. +// + +import SwiftUI + +struct MealPlanScreen: View { + @StateObject var vm: MealPlanViewModel + + init(cafeteria: Cafeteria) { + self._vm = StateObject(wrappedValue: MealPlanViewModel(cafeteria: cafeteria)) + } + + var body: some View { + Group { + switch vm.state { + case .success(let menus): + if let firstMenu = menus.first { + VStack { + MealPlanView(menus: menus, cafeteria: vm.cafeteria, selectedMenu: firstMenu) .refreshable { + await vm.getMenus() + } + } + } + case .loading, .na: + LoadingView(text: "Fetching Menus") + case .failed(_): + VStack { + Spacer() + // Since some cafeterias do not update their menus this is how we handle error here. There could be a better differentiation. + Text("No Menu available") + Spacer() + } + } + }.task { + await vm.getMenus() + } + } +} diff --git a/Campus-iOS/MapComponent/View/Cafeterias/MealPlanView.swift b/Campus-iOS/MapComponent/View/Cafeterias/MealPlanView.swift index 3d64070c..b7b3a6a9 100644 --- a/Campus-iOS/MapComponent/View/Cafeterias/MealPlanView.swift +++ b/Campus-iOS/MapComponent/View/Cafeterias/MealPlanView.swift @@ -10,27 +10,29 @@ import Alamofire struct MealPlanView: View { @Environment(\.colorScheme) var colorScheme - @ObservedObject var viewModel: MealPlanViewModel + let menus: [Menu] + let cafeteria: Cafeteria + @State var selectedMenu: Menu var body: some View { VStack { HStack{ - if viewModel.menus.count > 0 { + if self.menus.count > 0 { VStack{ HStack{ - ForEach(viewModel.menus.prefix(7), id: \.id){ menu in + ForEach(self.menus.prefix(7), id: \.id){ menu in Button(action: { - viewModel.selectedMenu = menu + self.selectedMenu = menu }){ VStack{ Circle() - .fill(menu === viewModel.selectedMenu ? + .fill(menu === self.selectedMenu ? (Calendar.current.isDateInToday(menu.date) ? Color("tumBlue") : Color(UIColor.label)) : Color.clear) .aspectRatio(contentMode: .fit) .overlay( Text(getFormattedDate(date: menu.date, format: "d")) .fontWeight(.semibold).fixedSize() - .foregroundColor(menu === viewModel.selectedMenu ? Color(UIColor.systemBackground) : (Calendar.current.isDateInToday(menu.date) ? Color("tumBlue") : Color(UIColor.label))) + .foregroundColor(menu === self.selectedMenu ? Color(UIColor.systemBackground) : (Calendar.current.isDateInToday(menu.date) ? Color("tumBlue") : Color(UIColor.label))) ) .frame(maxWidth: UIScreen.main.bounds.width, minHeight: 35, maxHeight: 35) @@ -42,18 +44,22 @@ struct MealPlanView: View { } .padding(.horizontal, 5.0) - if let menu = viewModel.selectedMenu { - MenuView(viewModel: menu) + MenuView(menu: selectedMenu) + + /* + if let menu = selectedMenu { + } else { Spacer().frame(height: 20) Text("No Menu available today").foregroundColor(colorScheme == .dark ? .init(UIColor.lightGray) : .init(UIColor.darkGray)) } + */ } } else { Text("No Menus available") } } - .navigationTitle(viewModel.title) + .navigationTitle(cafeteria.title ?? "No Cafeteria Title") Spacer(minLength: 0.0) } } @@ -65,11 +71,11 @@ struct MealPlanView: View { } } -struct MealPlanView_Previews: PreviewProvider { - static var previews: some View { - MealPlanView(viewModel: MealPlanViewModel(cafeteria: Cafeteria(location: Location(latitude: 0.0, longitude: 0.0, address: ""), - name: "", - id: "", - queueStatusApi: ""))) - } -} +//struct MealPlanView_Previews: PreviewProvider { +// static var previews: some View { +// MealPlanView(viewModel: MealPlanViewModel(cafeteria: Cafeteria(location: Location(latitude: 0.0, longitude: 0.0, address: ""), +// name: "", +// id: "", +// queueStatusApi: ""))) +// } +//} diff --git a/Campus-iOS/MapComponent/View/Cafeterias/MenuView.swift b/Campus-iOS/MapComponent/View/Cafeterias/MenuView.swift index c4fc6944..14de8d69 100644 --- a/Campus-iOS/MapComponent/View/Cafeterias/MenuView.swift +++ b/Campus-iOS/MapComponent/View/Cafeterias/MenuView.swift @@ -8,11 +8,11 @@ import SwiftUI struct MenuView: View { - @ObservedObject var viewModel: MenuViewModel + let menu: Menu var body: some View { List { - ForEach($viewModel.categories.sorted { $0.wrappedValue.name < $1.wrappedValue.name }) { $category in + ForEach(menu.categories.sorted { $0.name < $1.name }) { category in Section(category.name) { ForEach(category.dishes, id: \.self) { dish in DishView(dish: dish) @@ -23,106 +23,8 @@ struct MenuView: View { } } -struct DishView: View { - @State var dish: Dish - @State private var isExpanded = false - - var body: some View { - DisclosureGroup(isExpanded: $isExpanded) { - HStack{ - VStack{ - ForEach(dish.labels, id: \.self){label in - Text(DishView.labelToAbbreviation(label: label)) - } - } - .padding(.trailing, 10.0) - VStack(alignment: .leading){ - ForEach(dish.labels, id: \.self){label in - Text(DishView.labelToDescription(label: label)) - } - } - } - } label: { - VStack(alignment: .leading, spacing: 10){ - Spacer().frame(height: 0) - Text(dish.name).bold() - HStack{ - Spacer() - Text(DishView.formatPrice(dish: dish, pricingGroup: "students")) - .lineLimit(1) - .font(.system(size: 15)) - } - Spacer().frame(height: 0) - } - } - .buttonStyle(PlainButtonStyle()).disabled(true) - } - - static func formatPrice(dish: Dish, pricingGroup: String) -> String { - let priceFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.currencySymbol = "€" - formatter.numberStyle = .currency - return formatter - }() - - let price: Price - - var basePriceString: String? - var unitPriceString: String? - - switch pricingGroup { - case "staff": - price = dish.prices["staff"]! - case "guests": - price = dish.prices["guests"]! - default: - price = dish.prices["students"]! - } - - if let basePrice = price.basePrice, basePrice != 0 { - basePriceString = priceFormatter.string(for: basePrice) - } - - if let unitPrice = price.unitPrice, let unit = price.unit, unitPrice != 0 { - unitPriceString = priceFormatter.string(for: unitPrice)?.appending(" / " + unit) - } - - let divider: String = !(basePriceString?.isEmpty ?? true) && !(unitPriceString?.isEmpty ?? true) ? " + " : "" - - let finalPrice: String = (basePriceString ?? "") + divider + (unitPriceString ?? "") - - return finalPrice - } - - static func labelToAbbreviation(label: String) -> String { - let labelLookup = MensaEnumService.shared.getLabels() - - if let labelObject = labelLookup[label] { - return labelObject.abbreviation - } - return label - } - - static func labelToDescription(label: String) -> String { - let labelLookup = MensaEnumService.shared.getLabels() - - if let labelObject = labelLookup[label], let text = labelObject.text["DE"] { - return text - } - return label - } - - static func labelsToString(labels: [String]) -> String { - let shortenedLabels = labels.map{label -> String in - return labelToAbbreviation(label: label) - } - return shortenedLabels.joined(separator:", ") - } -} - -struct MenuView_Previews: PreviewProvider { - static var previews: some View { - MenuView(viewModel: MenuViewModel(date: Date(), categories: [])) - } -} +//struct MenuView_Previews: PreviewProvider { +// static var previews: some View { +// MenuView(viewModel: MenuViewModel(date: Date(), categories: [])) +// } +//} diff --git a/Campus-iOS/MapComponent/View/MapView.swift b/Campus-iOS/MapComponent/View/MapView.swift deleted file mode 100644 index b36b3ee1..00000000 --- a/Campus-iOS/MapComponent/View/MapView.swift +++ /dev/null @@ -1,28 +0,0 @@ -//// -//// MapView.swift -//// Campus-iOS -//// -//// Created by August Wittgenstein on 16.12.21. -//// -// -//import SwiftUI -//import MapKit -// -//struct MapView: View { -// @StateObject var vm: MapViewModel -// -// var body: some View { -// ZStack { -//// MapContentView(vm: self.vm) -// //PanelView(vm: self.vm) -// PanelContentView(vm: self.vm) -// } -// -// } -//} -// -//struct MapView_Previews: PreviewProvider { -// static var previews: some View { -// MapView(vm: MapViewModel(cafeteriaService: CafeteriasService(), studyRoomsService: StudyRoomsService())) -// } -//} diff --git a/Campus-iOS/MapComponent/View/PanelContentListView.swift b/Campus-iOS/MapComponent/View/PanelContentListView.swift index 8041ea26..655e856d 100644 --- a/Campus-iOS/MapComponent/View/PanelContentListView.swift +++ b/Campus-iOS/MapComponent/View/PanelContentListView.swift @@ -126,7 +126,11 @@ struct PanelContentListView: View { Button("Cancel", role: .cancel) {} } message: { detail in if case let .failed(error) = detail { - Text(error.localizedDescription) + if let apiError = error as? TUMDevAppAPIError { + Text(apiError.errorDescription ?? "TUMDevAppAPI Error") + } else { + Text(error.localizedDescription) + } } } } diff --git a/Campus-iOS/MapComponent/View/PanelContentView.swift b/Campus-iOS/MapComponent/View/PanelContentView.swift index a6394838..b2f05fc5 100644 --- a/Campus-iOS/MapComponent/View/PanelContentView.swift +++ b/Campus-iOS/MapComponent/View/PanelContentView.swift @@ -12,7 +12,7 @@ struct PanelContentView: View { @StateObject var vm: MapViewModel @State private var searchString = "" - @State private var mealPlanViewModel: MealPlanViewModel? +// @State private var mealPlanViewModel: MealPlanViewModel? @State private var sortedGroups: [StudyRoomGroup] = [] @State private var cafeteriasData = AppUsageData() @State private var studyRoomsData = AppUsageData() @@ -37,13 +37,12 @@ struct PanelContentView: View { .frame(width: 40, height: CGFloat(5.0)) .foregroundColor(Color.primary.opacity(0.2)) - if vm.selectedCafeteria != nil { + if let cafeteria = vm.selectedCafeteria { CafeteriaView(vm: vm, selectedCanteen: $vm.selectedCafeteria, panelHeight: $panelHeight) - if let viewModel = mealPlanViewModel { - MealPlanView(viewModel: viewModel) - } + + MealPlanScreen(cafeteria: cafeteria) } else if vm.selectedStudyGroup != nil { StudyRoomGroupView( @@ -140,11 +139,11 @@ struct PanelContentView: View { } } } - .onChange(of: vm.selectedCafeteria) { optionalCafeteria in - if let cafeteria = optionalCafeteria { - mealPlanViewModel = MealPlanViewModel(cafeteria: cafeteria) - } - } +// .onChange(of: vm.selectedCafeteria) { optionalCafeteria in +// if let cafeteria = optionalCafeteria { +// mealPlanViewModel = MealPlanViewModel(cafeteria: cafeteria) +// } +// } .task(id: vm.panelPos) { if panelHeight != vm.panelPos.rawValue { withAnimation(.interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0)) { diff --git a/Campus-iOS/MapComponent/View/StudyRooms/MapImagesHorizontalScrollingView.swift b/Campus-iOS/MapComponent/View/StudyRooms/MapImagesHorizontalScrollingView.swift index 3bcbf681..a388ab28 100644 --- a/Campus-iOS/MapComponent/View/StudyRooms/MapImagesHorizontalScrollingView.swift +++ b/Campus-iOS/MapComponent/View/StudyRooms/MapImagesHorizontalScrollingView.swift @@ -9,15 +9,16 @@ import SwiftUI struct MapImagesHorizontalScrollingView: View { - @ObservedObject var viewModel: StudyRoomViewModel + let room: StudyRoom + let roomImageMapping: [RoomImageMapping] var body: some View { - if viewModel.roomImageMapping.count > 0 { + if self.roomImageMapping.count > 0 { ScrollView(.horizontal, showsIndicators: true) { HStack(spacing: 10) { - ForEach(viewModel.roomImageMapping, id: \.id) { map in + ForEach(self.roomImageMapping, id: \.id) { map in GeometryReader { geometry in - if let link = viewModel.getImageURL(imageMappingId: map.id) { + if let link = getImageURL(for: self.room, imageMappingId: map.id) { AsyncImage(url: link) { image in switch image { case .empty: @@ -59,10 +60,18 @@ struct MapImagesHorizontalScrollingView: View { } } } -} - -struct MapImagesHorizontalScrollingView_Previews: PreviewProvider { - static var previews: some View { - MapImagesHorizontalScrollingView(viewModel: StudyRoomViewModel(studyRoom: StudyRoom())) + + func getImageURL(for room: StudyRoom, imageMappingId: Int) -> URL? { + if let raumNr = room.raum_nr_architekt { + return try? TUMCabeAPI.mapImage(room: raumNr, id: imageMappingId).asURLRequest().urlRequest?.url + } else { + return nil + } } } + +//struct MapImagesHorizontalScrollingView_Previews: PreviewProvider { +// static var previews: some View { +// MapImagesHorizontalScrollingView(viewModel: StudyRoomViewModel(studyRoom: StudyRoom())) +// } +//} diff --git a/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomDetailsScreen.swift b/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomDetailsScreen.swift new file mode 100644 index 00000000..aaa980ee --- /dev/null +++ b/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomDetailsScreen.swift @@ -0,0 +1,85 @@ +// +// StudyRoomDetailsScreen.swift +// Campus-iOS +// +// Created by David Lin on 22.01.23. +// + +import SwiftUI + +struct StudyRoomDetailsScreen: View { + @StateObject var vm = StudyRoomViewModel() + let room: StudyRoom + + var body: some View { + Group { + switch vm.state { + case .success(let roomImageMapping): + VStack { + StudyRoomDetailsView(studyRoom: self.room, roomImageMapping: roomImageMapping) .refreshable { + await vm.getRoomImageMapping(for: room, forcedRefresh: true) + } + } + case .loading, .na: + VStack { + Spacer() + LoadingView(text: "Fetching RoomImages") + Spacer() + }.task { + await vm.getRoomImageMapping(for: room) + } + case .failed(let error): + VStack { + Text("Error: \(error.localizedDescription)") + Button(action: { + Task { + await self.vm.getRoomImageMapping(for: room, forcedRefresh: true) + } + }) { + Text("Try Again".uppercased()) + .lineLimit(1).font(.body) + .frame(width: 200, height: 48, alignment: .center) + } + .font(.title) + .foregroundColor(.white) + .background(Color(.tumBlue)) + .cornerRadius(10) + .padding() + } + } + }.alert( + "Error while fetching Room Images", + isPresented: $vm.hasError, + presenting: vm.state) { detail in + Button("Retry") { + Task { + await vm.getRoomImageMapping(for: self.room, forcedRefresh: true) + } + } + + Button("Cancel", role: .cancel) { } + } message: { detail in + if case let .failed(error) = detail { + if let apiError = error as? TUMCabeAPIError { + Text(apiError.errorDescription ?? "TUMCabeAPI Error") + } else { + Text(error.localizedDescription) + } + } + } + } + + func printCell(key: String, value: String?) -> some View { + if let val = value { + return AnyView(HStack { + Text(key) + .foregroundColor(Color(UIColor.darkGray)) + Spacer() + Text(val).foregroundColor(.gray) + }) + } + + return AnyView(EmptyView()) + } +} + diff --git a/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomDetailsView.swift b/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomDetailsView.swift index 49695412..a62c5eec 100644 --- a/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomDetailsView.swift +++ b/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomDetailsView.swift @@ -8,13 +8,14 @@ import SwiftUI struct StudyRoomDetailsView: View { - - @ObservedObject var viewModel: StudyRoomViewModel - @State var showPopup = false - init(studyRoom room: StudyRoom) { - self.viewModel = StudyRoomViewModel(studyRoom: room) + let room: StudyRoom + let roomImageMapping: [RoomImageMapping] + + init(studyRoom room: StudyRoom, roomImageMapping: [RoomImageMapping]) { + self.room = room + self.roomImageMapping = roomImageMapping } func printCell(key: String, value: String?) -> some View { @@ -33,24 +34,24 @@ struct StudyRoomDetailsView: View { var body: some View { VStack(alignment: .leading) { Spacer() - if viewModel.roomImageMapping.count > 0 { + if self.roomImageMapping.count > 0 { HStack { Image(systemName: "map.fill").foregroundColor(.blue) Text("Available Maps") .fontWeight(.bold) .font(.headline) } - MapImagesHorizontalScrollingView(viewModel: viewModel) + MapImagesHorizontalScrollingView(room: self.room, roomImageMapping: self.roomImageMapping) Spacer(minLength: 10) } - printCell(key: "Building:", value: viewModel.room.buildingName) - printCell(key: "Building Number:", value: String(viewModel.room.buildingNumber)) - printCell(key: "Building Code:", value: viewModel.room.buildingCode) - if let id = viewModel.room.raum_nr_architekt { + printCell(key: "Building:", value: self.room.buildingName) + printCell(key: "Building Number:", value: String(self.room.buildingNumber)) + printCell(key: "Building Code:", value: self.room.buildingCode) + if let id = self.room.raum_nr_architekt { printCell(key: "ID:", value: id) } - if let attributes = viewModel.room.attributes, attributes.count > 0 { + if let attributes = self.room.attributes, attributes.count > 0 { Text("Attributes:") .foregroundColor(Color(UIColor.darkGray)) ForEach(attributes, id: \.name) { attribute in @@ -67,8 +68,68 @@ struct StudyRoomDetailsView: View { } } -struct StudyRoomDetailsView_Previews: PreviewProvider { - static var previews: some View { - StudyRoomDetailsView(studyRoom: StudyRoom()) - } -} +//struct StudyRoomDetailsView: View { +// +// @ObservedObject var viewModel: StudyRoomViewModel +// +// @State var showPopup = false +// +// init(studyRoom room: StudyRoom) { +// self.viewModel = StudyRoomViewModel(studyRoom: room) +// } +// +// func printCell(key: String, value: String?) -> some View { +// if let val = value { +// return AnyView(HStack { +// Text(key) +// .foregroundColor(Color(UIColor.darkGray)) +// Spacer() +// Text(val).foregroundColor(.gray) +// }) +// } +// +// return AnyView(EmptyView()) +// } +// +// var body: some View { +// VStack(alignment: .leading) { +// Spacer() +// if viewModel.roomImageMapping.count > 0 { +// HStack { +// Image(systemName: "map.fill").foregroundColor(.blue) +// Text("Available Maps") +// .fontWeight(.bold) +// .font(.headline) +// } +//// MapImagesHorizontalScrollingView(viewModel: viewModel) +// Spacer(minLength: 10) +// } +// +// printCell(key: "Building:", value: viewModel.room.buildingName) +// printCell(key: "Building Number:", value: String(viewModel.room.buildingNumber)) +// printCell(key: "Building Code:", value: viewModel.room.buildingCode) +// if let id = viewModel.room.raum_nr_architekt { +// printCell(key: "ID:", value: id) +// } +// if let attributes = viewModel.room.attributes, attributes.count > 0 { +// Text("Attributes:") +// .foregroundColor(Color(UIColor.darkGray)) +// ForEach(attributes, id: \.name) { attribute in +// HStack { +// Spacer() +// Text("\(attribute.name ?? "") \(attribute.detail ?? "")") +// .foregroundColor(.gray) +// Spacer() +// } +// } +// } +// Spacer() +// }.padding([.trailing], 15) +// } +//} +// +//struct StudyRoomDetailsView_Previews: PreviewProvider { +// static var previews: some View { +// StudyRoomDetailsView(studyRoom: StudyRoom()) +// } +//} diff --git a/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomGroupView.swift b/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomGroupView.swift index 9b179512..64e56c99 100644 --- a/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomGroupView.swift +++ b/Campus-iOS/MapComponent/View/StudyRooms/StudyRoomGroupView.swift @@ -115,7 +115,7 @@ struct StudyRoomGroupView: View { List { ForEach(self.sortedRooms, id: \.id) { room in DisclosureGroup(content: { - StudyRoomDetailsView(studyRoom: room) + StudyRoomDetailsScreen(room: room) }, label: { AnyView( HStack { diff --git a/Campus-iOS/MapComponent/ViewModel/CafeteriaWidgetViewModel.swift b/Campus-iOS/MapComponent/ViewModel/CafeteriaWidgetViewModel.swift index 8e9bf1cf..40f01300 100644 --- a/Campus-iOS/MapComponent/ViewModel/CafeteriaWidgetViewModel.swift +++ b/Campus-iOS/MapComponent/ViewModel/CafeteriaWidgetViewModel.swift @@ -13,16 +13,14 @@ import Alamofire class CafeteriaWidgetViewModel: ObservableObject { @Published var cafeteria: Cafeteria? - @Published var menuViewModel: MenuViewModel? - @Published var mealPlanViewModel: MealPlanViewModel? + @Published var menu: Menu? @Published var status: CafeteriaWidgetStatus - private let cafeteriaService: CafeteriasServiceProtocol - private let sessionManager = Session.defaultSession + private let cafeteriaService: CafeteriasService private let locationManager = CLLocationManager() - init(cafeteriaService: CafeteriasServiceProtocol) { + init(cafeteriaService: CafeteriasService) { self.status = .loading self.cafeteriaService = cafeteriaService @@ -54,24 +52,10 @@ class CafeteriaWidgetViewModel: ObservableObject { // Get today's menu plan of the closest cafeteria, if it exists. - let endpoint = EatAPI.menu(location: cafeteria.id, year: Date().year, week: Date().weekOfYear) - sessionManager.request(endpoint).responseDecodable(of: MealPlan.self, decoder: MealPlanViewModel.decoder()) { [self] response in - - guard let mealPlan = response.value else { - self.status = .noMenu - return - } - - guard let todaysPlan = mealPlan.days.first(where: { $0.date.isToday }) else { - self.status = .noMenu - return - } - - let categories = MealPlanViewModel.categories(from: todaysPlan.dishes) - self.menuViewModel = MenuViewModel(date: todaysPlan.date, categories: categories) - self.mealPlanViewModel = MealPlanViewModel(cafeteria: cafeteria) - self.status = .success - } + let menus = try await MealPlanService().fetch(cafeteria: cafeteria, forcedRefresh: false) + self.menu = menus.first + self.status = .success + } catch { self.status = .error } diff --git a/Campus-iOS/MapComponent/ViewModel/DishViewModel.swift b/Campus-iOS/MapComponent/ViewModel/DishViewModel.swift new file mode 100644 index 00000000..a4cc6691 --- /dev/null +++ b/Campus-iOS/MapComponent/ViewModel/DishViewModel.swift @@ -0,0 +1,93 @@ +// +// DishViewModel.swift +// Campus-iOS +// +// Created by David Lin on 23.01.23. +// + +import Foundation + +@MainActor +class DishViewModel: ObservableObject { + @Published var state: APIState<[String: DishLabel]> = .na + + let service = DishService() + let dish: Dish + + init(dish: Dish) { + self.dish = dish + } + + func getDishLabels(forcedRefresh: Bool = false) async { + if !forcedRefresh { + self.state = .loading + } + + let dishLabels = await service.fetch(forcedRefresh: forcedRefresh) + if let generalLabels = dishLabels { + self.state = .success( + data: generalLabels + ) + } else { + self.state = .success(data: [:]) + } + } + + func formatPrice(dish: Dish, pricingGroup: String) -> String { + let priceFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.currencySymbol = "€" + formatter.numberStyle = .currency + return formatter + }() + + let price: Price + + var basePriceString: String? + var unitPriceString: String? + + switch pricingGroup { + case "staff": + price = dish.prices["staff"]! + case "guests": + price = dish.prices["guests"]! + default: + price = dish.prices["students"]! + } + + if let basePrice = price.basePrice, basePrice != 0 { + basePriceString = priceFormatter.string(for: basePrice) + } + + if let unitPrice = price.unitPrice, let unit = price.unit, unitPrice != 0 { + unitPriceString = priceFormatter.string(for: unitPrice)?.appending(" / " + unit) + } + + let divider: String = !(basePriceString?.isEmpty ?? true) && !(unitPriceString?.isEmpty ?? true) ? " + " : "" + + let finalPrice: String = (basePriceString ?? "") + divider + (unitPriceString ?? "") + + return finalPrice + } + + func labelToAbbreviation(generalLabel: [String:DishLabel]?, label: String) -> String { + if let labelObject = generalLabel?[label] { + return labelObject.abbreviation + } + return label + } + + func labelToDescription(generalLabel: [String:DishLabel]?, label: String) -> String { + if let labelObject = generalLabel?[label], let text = labelObject.text["DE"] { + return text + } + return label + } + + func labelsToString(generalLabel: [String:DishLabel]?, labels: [String]) -> String { + let shortenedLabels = labels.map{label -> String in + return labelToAbbreviation(generalLabel: generalLabel, label: label) + } + return shortenedLabels.joined(separator:", ") + } +} diff --git a/Campus-iOS/MapComponent/ViewModel/MapViewModel.swift b/Campus-iOS/MapComponent/ViewModel/MapViewModel.swift index f6b7ff59..f2f4e3c1 100644 --- a/Campus-iOS/MapComponent/ViewModel/MapViewModel.swift +++ b/Campus-iOS/MapComponent/ViewModel/MapViewModel.swift @@ -38,8 +38,8 @@ class MapViewModel: MapViewModelProtocol { private let mock: Bool - private let cafeteriaService: CafeteriasServiceProtocol - private let studyRoomsService: StudyRoomsServiceProtocol + private let cafeteriaService: CafeteriasService + private let studyRoomsService: StudyRoomsService var cafeterias: [Cafeteria] { get { @@ -73,7 +73,7 @@ class MapViewModel: MapViewModelProtocol { } } - init(cafeteriaService: CafeteriasServiceProtocol, studyRoomsService: StudyRoomsServiceProtocol, mock: Bool = false) { + init(cafeteriaService: CafeteriasService, studyRoomsService: StudyRoomsService, mock: Bool = false) { self.cafeteriaService = cafeteriaService self.studyRoomsService = studyRoomsService self.mock = mock diff --git a/Campus-iOS/MapComponent/ViewModel/MealPlanViewModel.swift b/Campus-iOS/MapComponent/ViewModel/MealPlanViewModel.swift index f3f16e54..da81414e 100644 --- a/Campus-iOS/MapComponent/ViewModel/MealPlanViewModel.swift +++ b/Campus-iOS/MapComponent/ViewModel/MealPlanViewModel.swift @@ -9,81 +9,34 @@ import Foundation import SwiftUI import Alamofire -final class MealPlanViewModel: ObservableObject { - private let cafeteria: Cafeteria - private let endpoint = EatAPI.canteens - private let sessionManager = Session.defaultSession +@MainActor +class MealPlanViewModel: ObservableObject { + @Published var state: APIState<[Menu]> = .na + @Published var hasError: Bool = false - @Published private(set) var title: String - @Published private(set) var menus: [MenuViewModel] = [] - @Published var selectedMenu: MenuViewModel? + let service = MealPlanService() + let cafeteria: Cafeteria init(cafeteria: Cafeteria) { self.cafeteria = cafeteria - self.title = cafeteria.name - - fetch() } - func fetch() { - let thisWeekEndpoint = EatAPI.menu(location: cafeteria.id, year: Date().year, week: Date().weekOfYear) - - sessionManager.request(thisWeekEndpoint).responseDecodable(of: MealPlan.self, decoder: MealPlanViewModel.decoder()) { [self] response in - guard let mealPlans = response.value else { return } - addMealPlans(mealPlans: mealPlans) - if self.menus.count > 0 { - selectedMenu = self.menus[0] - } + func getMenus(forcedRefresh: Bool = false) async { + if !forcedRefresh { + self.state = .loading } - - guard let nextWeek = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: Date()) else { return } - - let nextWeekEndpoint = EatAPI.menu(location: cafeteria.id, year: nextWeek.year, week: nextWeek.weekOfYear) - - sessionManager.request(nextWeekEndpoint).responseDecodable(of: MealPlan.self, decoder: MealPlanViewModel.decoder()) { [self] response in - guard let mealPlans = response.value else { return } - addMealPlans(mealPlans: mealPlans) + self.hasError = false + + do { + let data = try await service.fetch(cafeteria: self.cafeteria, forcedRefresh: forcedRefresh) + print(data) + self.state = .success( + data: data + ) + } catch { + self.state = .failed(error: error) + self.hasError = true } - - // initiate loading of labels here, to prevent showing of placeholders - _ = MensaEnumService.shared.getLabels() - } - - func addMealPlans(mealPlans: MealPlan){ - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - - self.menus.append(contentsOf: mealPlans.days - .filter { !$0.dishes.isEmpty && ($0.date.isToday || $0.date.isLaterThanOrEqual(to: Date())) } - .sorted { $0.date < $1.date } - .map { - let categories = MealPlanViewModel.categories(from: $0.dishes) - return MenuViewModel(date: $0.date, categories: categories) } - .filter{ menu in !self.menus.contains(where: {$0.date == menu.date}) } // don't re-add already existent days - ) - - self.menus = self.menus.sorted { $0.date < $1.date } } - static func categories(from dishes: [Dish]) -> [MenuCategory] { - return dishes - .sorted { $0.dishType < $1.dishType } - .reduce(into: [:]) { (acc: inout [String: [Dish]], dish: Dish) -> () in - let type = dish.dishType.isEmpty ? "Sonstige" : dish.dishType - if acc[type] != nil { - acc[type]?.append(dish) - } - acc[type] = [dish] - } - .map { MenuCategory(name: $0.key, dishes: $0.value) } - } - - static func decoder() -> JSONDecoder { - let decoder = JSONDecoder() - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - decoder.dateDecodingStrategy = .formatted(formatter) - - return decoder - } } diff --git a/Campus-iOS/MapComponent/ViewModel/MenuViewModel.swift b/Campus-iOS/MapComponent/ViewModel/MenuViewModel.swift index e5780c9f..b6b6b4fa 100644 --- a/Campus-iOS/MapComponent/ViewModel/MenuViewModel.swift +++ b/Campus-iOS/MapComponent/ViewModel/MenuViewModel.swift @@ -8,11 +8,10 @@ import Foundation import SwiftUI - -final class MenuViewModel: ObservableObject, Identifiable { - let id = UUID() +final class Menu: Identifiable, Decodable { + var id = UUID() let date: Date - @Published var categories: [MenuCategory] + var categories: [MenuCategory] init(date: Date, categories: [MenuCategory]) { self.date = date @@ -31,8 +30,8 @@ final class MenuViewModel: ObservableObject, Identifiable { } } -struct MenuCategory: Identifiable { - let id = UUID() +struct MenuCategory: Identifiable, Decodable { + var id = UUID() let name: String let dishes: [Dish] diff --git a/Campus-iOS/MapComponent/ViewModel/StudyRoomVIewModel.swift b/Campus-iOS/MapComponent/ViewModel/StudyRoomVIewModel.swift deleted file mode 100644 index 16708130..00000000 --- a/Campus-iOS/MapComponent/ViewModel/StudyRoomVIewModel.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// StudyRoomVIewModel.swift -// Campus-iOS -// -// Created by Milen Vitanov on 02.06.22. -// - -import Foundation -import Alamofire - -final class StudyRoomViewModel: ObservableObject { - @Published var roomImageMapping = [RoomImageMapping]() - @Published var room: StudyRoom - - private let endpoint: TUMCabeAPI - private let sessionManager = Session.defaultSession - - init(studyRoom room: StudyRoom) { - self.room = room - self.endpoint = TUMCabeAPI.roomMaps(room: String(room.raum_nr_architekt ?? "")) - fetchImageMapping() - } - - func fetchImageMapping() { - sessionManager.request(endpoint).responseDecodable(of: [RoomImageMapping].self, decoder: JSONDecoder()) { [self] response in - guard let mapping = response.value else { - return - } - self.roomImageMapping = mapping - } - } - - func getImageURL(imageMappingId: Int) -> URL? { - return TUMCabeAPI.mapImage(room: String(self.room.raum_nr_architekt ?? ""), id: imageMappingId).urlRequest?.url - } -} diff --git a/Campus-iOS/MapComponent/ViewModel/StudyRoomViewModel.swift b/Campus-iOS/MapComponent/ViewModel/StudyRoomViewModel.swift new file mode 100644 index 00000000..545426d9 --- /dev/null +++ b/Campus-iOS/MapComponent/ViewModel/StudyRoomViewModel.swift @@ -0,0 +1,72 @@ +// +// StudyRoomVIewModel.swift +// Campus-iOS +// +// Created by Milen Vitanov on 02.06.22. +// + +import Foundation +import Alamofire + +@MainActor +class StudyRoomViewModel: ObservableObject { + @Published var state: APIState<[RoomImageMapping]> = .na + @Published var hasError: Bool = false + + let service: StudyRoomsService = StudyRoomsService() + + func getRoomImageMapping(for room: StudyRoom, forcedRefresh: Bool = false) async { + guard let raumNr = room.raum_nr_architekt else { + return + } + + if !forcedRefresh { + self.state = .loading + } + self.hasError = false + + do { + self.state = .success( + data: try await service.fetchMap(room: raumNr, forcedRefresh: forcedRefresh) + ) + } catch { + self.state = .failed(error: error) + self.hasError = true + } + } + + func getImageURL(for room: StudyRoom, imageMappingId: Int) -> URL? { + if let raumNr = room.raum_nr_architekt { + return try? TUMCabeAPI.mapImage(room: raumNr, id: imageMappingId).asURLRequest().urlRequest?.url + } else { + return nil + } + } +} + +//final class StudyRoomViewModel: ObservableObject { +// @Published var roomImageMapping = [RoomImageMapping]() +// @Published var room: StudyRoom +// +// private let endpoint: TUMCabeAPI +// private let sessionManager = Session.defaultSession +// +// init(studyRoom room: StudyRoom) { +// self.room = room +// self.endpoint = TUMCabeAPI.roomMaps(room: String(room.raum_nr_architekt ?? "")) +// fetchImageMapping() +// } +// +// func fetchImageMapping() { +// sessionManager.request(endpoint).responseDecodable(of: [RoomImageMapping].self, decoder: JSONDecoder()) { [self] response in +// guard let mapping = response.value else { +// return +// } +// self.roomImageMapping = mapping +// } +// } +// +// func getImageURL(imageMappingId: Int) -> URL? { +// return TUMCabeAPI.mapImage(room: String(self.room.raum_nr_architekt ?? ""), id: imageMappingId).urlRequest?.url +// } +//} diff --git a/Campus-iOS/MapComponent/ViewModel/StudyRoomWidgetViewModel.swift b/Campus-iOS/MapComponent/ViewModel/StudyRoomWidgetViewModel.swift index eda0aad4..60b3c90a 100644 --- a/Campus-iOS/MapComponent/ViewModel/StudyRoomWidgetViewModel.swift +++ b/Campus-iOS/MapComponent/ViewModel/StudyRoomWidgetViewModel.swift @@ -16,12 +16,11 @@ class StudyRoomWidgetViewModel: ObservableObject { @Published var rooms: [StudyRoom]? @Published var status: StudyRoomWidgetStatus - private let studyRoomService: StudyRoomsServiceProtocol - private let sessionManager = Session.defaultSession + private let studyRoomService: StudyRoomsService private let locationManager = CLLocationManager() - init(studyRoomService: StudyRoomsServiceProtocol) { + init(studyRoomService: StudyRoomsService) { self.status = .loading self.studyRoomService = studyRoomService diff --git a/Campus-iOS/Model/Model.swift b/Campus-iOS/Model/Model.swift index dd295b97..9d578de8 100644 --- a/Campus-iOS/Model/Model.swift +++ b/Campus-iOS/Model/Model.swift @@ -12,6 +12,7 @@ import SwiftUI import FirebaseAnalytics #endif +@MainActor public class Model: ObservableObject { @Published var showProfile = false { @@ -34,53 +35,28 @@ public class Model: ObservableObject { } } - @Published var loginController: AuthenticationHandler + @Published var loginController = AuthenticationHandler() @Published var isUserAuthenticated = false - @Published var profile: ProfileViewModel = ProfileViewModel() +// @Published var profile: ProfileViewModel = ProfileViewModel() var anyCancellables: [AnyCancellable] = [] - init() { - loginController = AuthenticationHandler() - - if loginController.credentials == Credentials.noTumID { - isUserAuthenticated = false - } else { - loginController.confirmToken() { [weak self] result in - switch result { - case .success: - #if !targetEnvironment(macCatalyst) - Analytics.logEvent("token_confirmed", parameters: nil) - #endif - self?.isLoginSheetPresented = false - self?.isUserAuthenticated = true - self?.loadProfile() - case .failure(_): - self?.isUserAuthenticated = false - if let model = self { - if !model.showProfile { - model.isLoginSheetPresented = true - } - } else { - self?.isLoginSheetPresented = true - } - } - } + var token: String? { + switch self.loginController.credentials { + case .none, .noTumID: + return nil + case .tumID(_, let token): + return token + case .tumIDAndKey(_, let token, _): + return token } } func logout() { - loginController.logout() - self.isLoginSheetPresented = self.showProfile ? false : true - self.isUserAuthenticated = false - self.unloadProfile() - } - - func unloadProfile() { - self.profile = ProfileViewModel() - } - - func loadProfile() { - self.profile = ProfileViewModel(model: self) + DispatchQueue.main.async { + self.loginController.logout() + self.isLoginSheetPresented = true + self.isUserAuthenticated = false + } } } diff --git a/Campus-iOS/MoviesComponent/Screen/MoviesScreen.swift b/Campus-iOS/MoviesComponent/Screen/MoviesScreen.swift new file mode 100644 index 00000000..22c8cd37 --- /dev/null +++ b/Campus-iOS/MoviesComponent/Screen/MoviesScreen.swift @@ -0,0 +1,8 @@ +// +// MovieScreen.swift +// Campus-iOS +// +// Created by David Lin on 22.01.23. +// + +import Foundation diff --git a/Campus-iOS/MoviesComponent/Service/MovieService.swift b/Campus-iOS/MoviesComponent/Service/MovieService.swift new file mode 100644 index 00000000..55eddf59 --- /dev/null +++ b/Campus-iOS/MoviesComponent/Service/MovieService.swift @@ -0,0 +1,17 @@ +// +// MovieService.swift +// Campus-iOS +// +// Created by David Lin on 22.01.23. +// + +import Foundation + +struct MoviesService: ServiceProtocol { + func fetch(forcedRefresh: Bool = false) async throws -> [Movie] { + + let response: [Movie] = try await MainAPI.makeRequest(endpoint: TUMCabeAPI.movie, forcedRefresh: forcedRefresh) + + return response + } +} diff --git a/Campus-iOS/MoviesComponent/ViewModel/Movie.swift b/Campus-iOS/MoviesComponent/ViewModel/Movie.swift index ba96c073..f6c5c0cd 100644 --- a/Campus-iOS/MoviesComponent/ViewModel/Movie.swift +++ b/Campus-iOS/MoviesComponent/ViewModel/Movie.swift @@ -7,7 +7,7 @@ import Foundation -struct Movie: Entity { +struct Movie: Decodable { var id: Int64 var actors: String? diff --git a/Campus-iOS/MoviesComponent/ViewModel/MoviesViewModel.swift b/Campus-iOS/MoviesComponent/ViewModel/MoviesViewModel.swift index 3aab5b49..f153f7bb 100644 --- a/Campus-iOS/MoviesComponent/ViewModel/MoviesViewModel.swift +++ b/Campus-iOS/MoviesComponent/ViewModel/MoviesViewModel.swift @@ -9,45 +9,90 @@ import Foundation import Alamofire import FirebaseCrashlytics +@MainActor class MoviesViewModel: ObservableObject { + @Published var state: APIState<[Movie]> = .na + @Published var hasError: Bool = false - @Published var movies = [Movie]() + let service: MoviesService = MoviesService() - typealias ImporterType = Importer - private let sessionManager: Session = Session.defaultSession - - init() { - // TODO: Get from cache, if not found, then fetch - fetch() + func getMovies(forcedRefresh: Bool = false) async { + if !forcedRefresh { + self.state = .loading + } + self.hasError = false + + do { + let movies = try await service.fetch(forcedRefresh: forcedRefresh) + + self.state = .success( + data: filterAndSort(for: movies) + ) + } catch { + self.state = .failed(error: error) + self.hasError = true + } } - func fetch() { - let endpoint: URLRequestConvertible = TUMCabeAPI.movie - let dateDecodingStrategy: JSONDecoder.DateDecodingStrategy? = .formatted(.yyyyMMddhhmmss) - let importer = ImporterType(endpoint: endpoint, dateDecodingStrategy: dateDecodingStrategy) + func filterAndSort(for movies: [Movie]) -> [Movie] { + let relevantMovies = movies.filter ({ + if let date = $0.date { + return Date.now <= date + } else { + // If no date available keep movie just in case + return true; + } + }) - importer.performFetch(handler: { result in - switch result { - case .success(let incoming): - // Remove all movies from list that are older than today - let relevantMovies = incoming.filter ({ - if let date = $0.date { - return Date.now <= date - } else { - // If no date available keep movie just in case - return true; - } - }) - - self.movies = relevantMovies.sorted(by: { - guard let dateOne = $0.date, let dateTwo = $1.date else { - return false - } - return dateOne < dateTwo - }) - case .failure(let error): - print(error) + return relevantMovies.sorted(by: { + guard let dateOne = $0.date, let dateTwo = $1.date else { + return false } + return dateOne < dateTwo }) + } } + +//class MoviesViewModel: ObservableObject { +// +// @Published var movies = [Movie]() +// +// typealias ImporterType = Importer +// private let sessionManager: Session = Session.defaultSession +// +// init() { +// // TODO: Get from cache, if not found, then fetch +// fetch() +// } +// +// func fetch() { +// let endpoint: URLRequestConvertible = TUMCabeAPI.movie +// let dateDecodingStrategy: JSONDecoder.DateDecodingStrategy? = .formatted(.yyyyMMddhhmmss) +// let importer = ImporterType(endpoint: endpoint, dateDecodingStrategy: dateDecodingStrategy) +// +// importer.performFetch(handler: { result in +// switch result { +// case .success(let incoming): +// // Remove all movies from list that are older than today +// let relevantMovies = incoming.filter ({ +// if let date = $0.date { +// return Date.now <= date +// } else { +// // If no date available keep movie just in case +// return true; +// } +// }) +// +// self.movies = relevantMovies.sorted(by: { +// guard let dateOne = $0.date, let dateTwo = $1.date else { +// return false +// } +// return dateOne < dateTwo +// }) +// case .failure(let error): +// print(error) +// } +// }) +// } +//} diff --git a/Campus-iOS/MoviesComponent/Views/MoviesView.swift b/Campus-iOS/MoviesComponent/Views/MoviesView.swift index 0e20d911..b9fc19f8 100644 --- a/Campus-iOS/MoviesComponent/Views/MoviesView.swift +++ b/Campus-iOS/MoviesComponent/Views/MoviesView.swift @@ -7,9 +7,54 @@ import SwiftUI -struct MoviesView: View { +struct MoviesScreen: View { + @StateObject var vm = MoviesViewModel() - @ObservedObject var viewModel = MoviesViewModel() + var body: some View { + Group { + switch vm.state { + case .success(let movies): + VStack { + MoviesView(movies: movies) + .refreshable { + await vm.getMovies(forcedRefresh: true) + } + } + case .loading, .na: + LoadingView(text: "Fetching News") + case .failed(let error): + FailedView( + errorDescription: error.localizedDescription, + retryClosure: vm.getMovies + ) + } + }.task { + await vm.getMovies() + }.alert( + "Error while fetching News", + isPresented: $vm.hasError, + presenting: vm.state) { detail in + Button("Retry") { + Task { + await vm.getMovies(forcedRefresh: true) + } + } + + Button("Cancel", role: .cancel) { } + } message: { detail in + if case let .failed(error) = detail { + if let apiError = error as? TUMCabeAPIError { + Text(apiError.errorDescription ?? "TUMCabeAPI Error") + } else { + Text(error.localizedDescription) + } + } + } + } +} + +struct MoviesView: View { + let movies: [Movie] @State private var selectedMovie: Movie? = nil var items: [GridItem] { @@ -22,7 +67,7 @@ struct MoviesView: View { .foregroundColor(Color(UIColor.lightGray)) ScrollView(.vertical) { LazyVGrid(columns: items, spacing: 10) { - ForEach(self.viewModel.movies, id: \.id ) { movie in + ForEach(self.movies, id: \.id ) { movie in MovieCard(movie: movie).padding(7) .onTapGesture { selectedMovie = movie @@ -39,8 +84,8 @@ struct MoviesView: View { } } -struct MoviesView_Previews: PreviewProvider { - static var previews: some View { - MoviesView() - } -} +//struct MoviesView_Previews: PreviewProvider { +// static var previews: some View { +// MoviesView() +// } +//} diff --git a/Campus-iOS/NewsComponent/Service/News.swift b/Campus-iOS/NewsComponent/Model/News.swift similarity index 78% rename from Campus-iOS/NewsComponent/Service/News.swift rename to Campus-iOS/NewsComponent/Model/News.swift index eb43915f..80e4b1bc 100644 --- a/Campus-iOS/NewsComponent/Service/News.swift +++ b/Campus-iOS/NewsComponent/Model/News.swift @@ -7,7 +7,7 @@ import Foundation -struct News: Entity { +struct News: Decodable { var id: String? var sourceID: Int64 var date: Date? @@ -44,18 +44,28 @@ struct News: Entity { guard let sourceID = Int64(sourceString) else { throw DecodingError.typeMismatch(Int64.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Value for source could not be converted to Int64")) } - let date = try container.decode(Date.self, forKey: .date) let created = try container.decode(Date.self, forKey: .created) let title = try container.decode(String.self, forKey: .title) - let link = try container.decode(URL.self, forKey: .link) let imageURLString = try container.decode(String.self, forKey: .imageURL) + do { + self.date = try container.decode(Date.self, forKey: .date) + } catch { + self.date = Date.distantPast + print("News decoding error for property date: \(error)") + } + + do { + self.link = try container.decode(URL.self, forKey: .link) + } catch { + self.link = nil + print("News decoding error for property link: \(error)") + } + self.id = id self.sourceID = sourceID - self.date = date self.created = created self.title = title - self.link = link self.imageURL = imageURLString.replacingOccurrences(of: " ", with: "%20") } } diff --git a/Campus-iOS/NewsComponent/Service/NewsSource.swift b/Campus-iOS/NewsComponent/Model/NewsSource.swift similarity index 52% rename from Campus-iOS/NewsComponent/Service/NewsSource.swift rename to Campus-iOS/NewsComponent/Model/NewsSource.swift index a1bc137d..9a719214 100644 --- a/Campus-iOS/NewsComponent/Service/NewsSource.swift +++ b/Campus-iOS/NewsComponent/Model/NewsSource.swift @@ -9,14 +9,12 @@ import Alamofire import Combine import FirebaseCrashlytics -class NewsSource: Entity, ObservableObject { - - typealias ImporterType = Importer +struct NewsSource: Decodable, Identifiable { public var id: Int64? public var title: String? public var icon: URL? - @Published var news: [News] + public var news: [News] enum CodingKeys: String, CodingKey { case id = "source" @@ -31,7 +29,7 @@ class NewsSource: Entity, ObservableObject { self.news = news } - required init(from decoder: Decoder) throws { + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let idString = try container.decode(String.self, forKey: .id) @@ -46,31 +44,5 @@ class NewsSource: Entity, ObservableObject { self.title = title self.icon = icon self.news = [] - fetchNews() - } - - func fetchNews() { - guard let id = self.id else { - print("NewsSource contain no id") - return - } - - let endpoint: URLRequestConvertible = TUMCabeAPI.news(source: id.description) - let dateDecodingStrategy: JSONDecoder.DateDecodingStrategy? = .formatted(.yyyyMMddhhmmss) - let importer = ImporterType(endpoint: endpoint, dateDecodingStrategy: dateDecodingStrategy) - - importer.performFetch(handler: { result in - switch result { - case .success(let storage): - self.news = storage.filter( { - guard let title = $0.title, let link = $0.link else { - return false - } - return !title.isEmpty && !link.description.isEmpty - } ) - case .failure(let error): - print(error) - } - }) } } diff --git a/Campus-iOS/NewsComponent/NewsScreen/NewsScreen.swift b/Campus-iOS/NewsComponent/NewsScreen/NewsScreen.swift new file mode 100644 index 00000000..0b588370 --- /dev/null +++ b/Campus-iOS/NewsComponent/NewsScreen/NewsScreen.swift @@ -0,0 +1,53 @@ +// +// NewsScreen.swift +// Campus-iOS +// +// Created by David Lin on 22.01.23. +// + +import SwiftUI + +struct NewsScreen: View { + @StateObject var vm = NewsViewModel() + + var body: some View { + Group { + switch vm.state { + case .success(let newsSources): + VStack { + NewsView(latestFiveNews: vm.latestFiveNews, newsSources: newsSources) .refreshable { + await vm.getNewsSources(forcedRefresh: true) + } + } + case .loading, .na: + LoadingView(text: "Fetching News") + case .failed(let error): + FailedView( + errorDescription: error.localizedDescription, + retryClosure: vm.getNewsSources + ) + } + }.task { + await vm.getNewsSources() + }.alert( + "Error while fetching News", + isPresented: $vm.hasError, + presenting: vm.state) { detail in + Button("Retry") { + Task { + await vm.getNewsSources(forcedRefresh: true) + } + } + + Button("Cancel", role: .cancel) { } + } message: { detail in + if case let .failed(error) = detail { + if let apiError = error as? TUMCabeAPIError { + Text(apiError.errorDescription ?? "TUMCabeAPI Error") + } else { + Text(error.localizedDescription) + } + } + } + } +} diff --git a/Campus-iOS/NewsComponent/Service/NewsService.swift b/Campus-iOS/NewsComponent/Service/NewsService.swift new file mode 100644 index 00000000..f334543c --- /dev/null +++ b/Campus-iOS/NewsComponent/Service/NewsService.swift @@ -0,0 +1,27 @@ +// +// NewsService.swift +// Campus-iOS +// +// Created by David Lin on 22.01.23. +// + +import Foundation + +struct NewsService: ServiceProtocol { + func fetch(forcedRefresh: Bool = false) async throws -> [NewsSource] { + + var newsSourceResponse: [NewsSource] = try await MainAPI.makeRequest(endpoint: TUMCabeAPI.newsSources, forcedRefresh: forcedRefresh) + + for i in newsSourceResponse.indices { + guard let idDescription = newsSourceResponse[i].id?.description else { + break + } + + let news: [News] = try await MainAPI.makeRequest(endpoint: TUMCabeAPI.news(source: String(idDescription))) + + newsSourceResponse[i].news = news + } + + return newsSourceResponse + } +} diff --git a/Campus-iOS/NewsComponent/ViewModel/NewsViewModel.swift b/Campus-iOS/NewsComponent/ViewModel/NewsViewModel.swift index b17c338b..bcfc7707 100644 --- a/Campus-iOS/NewsComponent/ViewModel/NewsViewModel.swift +++ b/Campus-iOS/NewsComponent/ViewModel/NewsViewModel.swift @@ -10,24 +10,33 @@ import FirebaseCrashlytics @MainActor class NewsViewModel: ObservableObject { - - @Published var newsSources = [NewsSource]() - @Published var news = [News]() - @Published var sourcesAndNews = [(Int64?, [News])]() - //@Published var news = [News]() + @Published var state: APIState<[NewsSource]> = .na + @Published var hasError: Bool = false - private let sessionManager: Session = Session.defaultSession + let service: NewsService = NewsService() - init() { - // TODO: Get from cache, if not found, then fetch - - fetch() -// fetchNews(sourceId: 1) + func getNewsSources(forcedRefresh: Bool = false) async { + if !forcedRefresh { + self.state = .loading + } + self.hasError = false + + do { + self.state = .success( + data: try await service.fetch(forcedRefresh: forcedRefresh) + ) + } catch { + self.state = .failed(error: error) + self.hasError = true + } } var latestFiveNews: [(String?, News?)] { - print(">> latestFiveNews loaded") - let latestNews = Array(self.newsSources + guard case .success(let newsSources) = self.state else { + return [] + } + + let latestNews = Array(newsSources .map({$0.news}) .reduce([], +) .filter({$0.created != nil && $0.sourceID != 2}) @@ -44,71 +53,110 @@ class NewsViewModel: ObservableObject { return latestFiveNews } - - func fetch() { - typealias ImporterType = Importer - - let endpoint: URLRequestConvertible = TUMCabeAPI.newsSources - let dateDecodingStrategy: JSONDecoder.DateDecodingStrategy? = .formatted(.yyyyMMddhhmmss) - let importer = ImporterType(endpoint: endpoint, dateDecodingStrategy: dateDecodingStrategy) - - importer.performFetch(handler: { result in - switch result { - case .success(let incoming): - incoming.forEach { newsSource in - self.fetchNews(sourceId: newsSource.id) - } - - self.newsSources = incoming - case .failure(let error): - print(error) - } - }) - } - - func fetchNews(sourceId: Int64?) { - typealias ImporterTypeNews = Importer - - guard let id = sourceId else { - print("NewsSource contains no id") - return - } - - let endpointNews: URLRequestConvertible = TUMCabeAPI.news(source: id.description) - let dateDecodingStrategyNews: JSONDecoder.DateDecodingStrategy? = .formatted(.yyyyMMddhhmmss) - let importerNews = ImporterTypeNews(endpoint: endpointNews, dateDecodingStrategy: dateDecodingStrategyNews) - - importerNews.performFetch(handler: { result in - switch result { - case .success(let storage): - let news = storage.filter( { - guard let title = $0.title, let link = $0.link else { - return false - } - return !title.isEmpty && !link.description.isEmpty - } ) - self.sourcesAndNews.append((sourceId, news)) - case .failure(let error): - print(error) - } - }) - } - - } -class MockNewsViewModel: NewsViewModel { - - static let mockNewsA = News(id: "1", sourceId: 1, date: Date(), created: Date(), title: "Dummy Title", link: URL(string: "https://github.com/orgs/TUM-Dev"), imageURL: "https://app.tum.de/File/news/newspread/dab04abdf3954d3e1bf56cef44d68662.jpg") - static let mockNewsB = News(id: "3", sourceId: 3, date: Date(), created: Date(), title: "Dummy Title", link: URL(string: "https://github.com/orgs/TUM-Dev"), imageURL: "https://app.tum.de/File/news/newspread/dab04abdf3954d3e1bf56cef44d68662.jpg") - static let newsSourceA = NewsSource(id: 1, title: "TUM News A", icon: nil, news: [mockNewsA, mockNewsA, mockNewsA]) - static let newsSourceB = NewsSource(id: 3, title: "TUM News B", icon: nil, news: [mockNewsB, mockNewsB, mockNewsB]) - - let mockNewsSources = [newsSourceA, newsSourceB] - - - override func fetch() { - self.newsSources = mockNewsSources - } -} +//@MainActor +//class NewsViewModel: ObservableObject { +// +// @Published var newsSources = [NewsSource]() +// @Published var news = [News]() +// @Published var sourcesAndNews = [(Int64?, [News])]() +// //@Published var news = [News]() +// +// private let sessionManager: Session = Session.defaultSession +// +// init() { +// // TODO: Get from cache, if not found, then fetch +// +// fetch() +//// fetchNews(sourceId: 1) +// } +// +// var latestFiveNews: [(String?, News?)] { +// print(">> latestFiveNews loaded") +// let latestNews = Array(self.newsSources +// .map({$0.news}) +// .reduce([], +) +// .filter({$0.created != nil && $0.sourceID != 2}) +// .sorted(by: { +// guard let date1 = $0.created, let date2 = $1.created else { +// return false +// } +// return date1.compare(date2) == .orderedDescending +// }).prefix(5)) +// +// let latestFiveNews = latestNews.map { news in +// (newsSources.first(where: {$0.id == news.sourceID})?.title, news) +// } +// +// return latestFiveNews +// } +// +// func fetch() { +// typealias ImporterType = Importer +// +// let endpoint: URLRequestConvertible = TUMCabeAPI.newsSources +// let dateDecodingStrategy: JSONDecoder.DateDecodingStrategy? = .formatted(.yyyyMMddhhmmss) +// let importer = ImporterType(endpoint: endpoint, dateDecodingStrategy: dateDecodingStrategy) +// +// importer.performFetch(handler: { result in +// switch result { +// case .success(let incoming): +// incoming.forEach { newsSource in +// self.fetchNews(sourceId: newsSource.id) +// } +// +// self.newsSources = incoming +// case .failure(let error): +// print(error) +// } +// }) +// } +// +// func fetchNews(sourceId: Int64?) { +// typealias ImporterTypeNews = Importer +// +// guard let id = sourceId else { +// print("NewsSource contains no id") +// return +// } +// +// let endpointNews: URLRequestConvertible = TUMCabeAPI.news(source: id.description) +// let dateDecodingStrategyNews: JSONDecoder.DateDecodingStrategy? = .formatted(.yyyyMMddhhmmss) +// let importerNews = ImporterTypeNews(endpoint: endpointNews, dateDecodingStrategy: dateDecodingStrategyNews) +// +// importerNews.performFetch(handler: { result in +// switch result { +// case .success(let storage): +// let news = storage.filter( { +// guard let title = $0.title, let link = $0.link else { +// return false +// } +// return !title.isEmpty && !link.description.isEmpty +// } ) +// self.sourcesAndNews.append((sourceId, news)) +// case .failure(let error): +// print(error) +// } +// }) +// } +// +// +//} + +//class MockNewsViewModel: NewsViewModel { +// +// static let mockNewsA = News(id: "1", sourceId: 1, date: Date(), created: Date(), title: "Dummy Title", link: URL(string: "https://github.com/orgs/TUM-Dev"), imageURL: "https://app.tum.de/File/news/newspread/dab04abdf3954d3e1bf56cef44d68662.jpg") +// static let mockNewsB = News(id: "3", sourceId: 3, date: Date(), created: Date(), title: "Dummy Title", link: URL(string: "https://github.com/orgs/TUM-Dev"), imageURL: "https://app.tum.de/File/news/newspread/dab04abdf3954d3e1bf56cef44d68662.jpg") +// +// static let newsSourceA = NewsSource(id: 1, title: "TUM News A", icon: nil, news: [mockNewsA, mockNewsA, mockNewsA]) +// static let newsSourceB = NewsSource(id: 3, title: "TUM News B", icon: nil, news: [mockNewsB, mockNewsB, mockNewsB]) +// +// let mockNewsSources = [newsSourceA, newsSourceB] +// +// +// override func fetch() { +// self.newsSources = mockNewsSources +// } +//} diff --git a/Campus-iOS/NewsComponent/Views/NewsView.swift b/Campus-iOS/NewsComponent/Views/NewsView.swift index 67c573ef..ce4c6ef0 100644 --- a/Campus-iOS/NewsComponent/Views/NewsView.swift +++ b/Campus-iOS/NewsComponent/Views/NewsView.swift @@ -9,18 +9,20 @@ import SwiftUI struct NewsView: View { - @StateObject var viewModel: NewsViewModel @AppStorage("useBuildInWebView") var useBuildInWebView: Bool = true @Environment(\.scenePhase) var scenePhase @State var isWebViewShowed = false @State var selectedLink: URL? = nil + let latestFiveNews: [(String?, News?)] + let newsSources: [NewsSource] + var body: some View { ScrollView(.vertical) { VStack(alignment: .center) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 30) { - ForEach(viewModel.latestFiveNews, id: \.1?.id) { oneLatestNews in + ForEach(latestFiveNews, id: \.1?.id) { oneLatestNews in GeometryReader { geometry in if let url = oneLatestNews.1?.link { if self.useBuildInWebView { @@ -43,16 +45,15 @@ struct NewsView: View { // adjust height Spacer(minLength: 1) }.sheet(item: $selectedLink) { selectedLink in - if let link = selectedLink { - SFSafariViewWrapper(url: link) - } + SFSafariViewWrapper(url: selectedLink) } Spacer() }.padding() } Spacer() - ForEach(viewModel.newsSources.filter({!$0.news.isEmpty && $0.id != 2}), id: \.id) { source in + + ForEach(newsSources.filter({!$0.news.isEmpty && $0.id != 2}), id: \.id) { source in Collapsible(title: { AnyView(HStack(alignment: .center) { Image(systemName: "list.bullet").foregroundColor(.blue) @@ -67,17 +68,4 @@ struct NewsView: View { } } } - - init(viewModel: NewsViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) - viewModel.fetch() - } -} - - -struct NewsView_Previews: PreviewProvider { - static var previews: some View { - let vm = MockNewsViewModel() - NewsView(viewModel: vm) - } } diff --git a/Campus-iOS/PersonDetailedComponent/Entity/Organization.swift b/Campus-iOS/PersonDetailedComponent/Entity/Organization.swift index e464cb0c..5812e1ca 100644 --- a/Campus-iOS/PersonDetailedComponent/Entity/Organization.swift +++ b/Campus-iOS/PersonDetailedComponent/Entity/Organization.swift @@ -7,7 +7,7 @@ import Foundation -struct Organisation: Decodable { +struct Organisation: Decodable, Identifiable { let name: String let id: String let number: String diff --git a/Campus-iOS/PersonDetailedComponent/Entity/PhoneExtension.swift b/Campus-iOS/PersonDetailedComponent/Entity/PhoneExtension.swift index 1c8709a0..7e75cf75 100644 --- a/Campus-iOS/PersonDetailedComponent/Entity/PhoneExtension.swift +++ b/Campus-iOS/PersonDetailedComponent/Entity/PhoneExtension.swift @@ -7,7 +7,8 @@ import Foundation -struct PhoneExtension: Decodable { +struct PhoneExtension: Decodable, Identifiable { + let id = UUID() let phoneNumber: String let countryCode: String let areaCode: String diff --git a/Campus-iOS/PersonDetailedComponent/Entity/Room.swift b/Campus-iOS/PersonDetailedComponent/Entity/Room.swift index eec1fcf4..7d92181c 100644 --- a/Campus-iOS/PersonDetailedComponent/Entity/Room.swift +++ b/Campus-iOS/PersonDetailedComponent/Entity/Room.swift @@ -7,7 +7,7 @@ import Foundation -struct Room: Decodable { +struct Room: Decodable, Identifiable { let number: String let buildingName: String let buildingNumber: String diff --git a/Campus-iOS/PersonDetailedComponent/Screen/PersonDetailedScreen.swift b/Campus-iOS/PersonDetailedComponent/Screen/PersonDetailedScreen.swift new file mode 100644 index 00000000..2a988a8c --- /dev/null +++ b/Campus-iOS/PersonDetailedComponent/Screen/PersonDetailedScreen.swift @@ -0,0 +1,69 @@ +// +// PersonDetailedScreen.swift +// Campus-iOS +// +// Created by David Lin on 21.01.23. +// + +import SwiftUI + +struct PersonDetailedScreen: View { + @StateObject var vm: PersonDetailedViewModel + + init(model: Model, person: Person) { + self._vm = StateObject(wrappedValue: PersonDetailedViewModel(model: model, service: PersonDetailedService(), type: .Person(person))) + } + + init(model: Model, profile: Profile) { + self._vm = StateObject(wrappedValue: PersonDetailedViewModel(model: model, service: PersonDetailedService(), type: .Profile(profile))) + } + + var body: some View { + Group { + switch vm.state { + case .success(let personDetails): + VStack { + PersonDetailedView(personDetails: personDetails) + .background(Color(.systemGroupedBackground)) + } + case .loading, .na: + LoadingView(text: "Fetching Person Details") + case .failed(let error): + FailedView( + errorDescription: error.localizedDescription, + retryClosure: {_ in await vm.getDetails(forcedRefresh: true)} + ) + } + } + .task { + await vm.getDetails(forcedRefresh: true) + } + .alert("Error while fetching Person Details", isPresented: $vm.hasError, presenting: vm.state) { detail in + Button("Retry") { + Task { + await vm.getDetails(forcedRefresh: true) + } + } + + Button("Cancel", role: .cancel) { } + } message: { detail in + if case let .failed(error) = detail { + if let apiError = error as? TUMOnlineAPIError { + Text(apiError.errorDescription ?? "TUMOnlineAPI Error") + } else { + Text(error.localizedDescription) + } + } + } + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + NavigationLink( + destination: AddToContactsView(contact: self.vm.cnContact) + .navigationBarTitleDisplayMode(.inline) + ) { + Label("", systemImage: "person.crop.circle.badge.plus") + } + } + } + } +} diff --git a/Campus-iOS/PersonDetailedComponent/Service/PersonDetailedService.swift b/Campus-iOS/PersonDetailedComponent/Service/PersonDetailedService.swift new file mode 100644 index 00000000..c1dd0076 --- /dev/null +++ b/Campus-iOS/PersonDetailedComponent/Service/PersonDetailedService.swift @@ -0,0 +1,16 @@ +// +// PersonDetailedService.swift +// Campus-iOS +// +// Created by David Lin on 21.01.23. +// + +import Foundation + +struct PersonDetailedService { + func fetch(for id: String, token: String, forcedRefresh: Bool) async throws -> PersonDetails { + let response : PersonDetails = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.personDetails(identNumber: id), token: token, forcedRefresh: forcedRefresh) + + return response + } +} diff --git a/Campus-iOS/PersonDetailedComponent/View/PersonDetailedCellView.swift b/Campus-iOS/PersonDetailedComponent/View/PersonDetailedCellView.swift deleted file mode 100644 index b2c2ab5b..00000000 --- a/Campus-iOS/PersonDetailedComponent/View/PersonDetailedCellView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// SwiftUIView.swift -// Campus-iOS -// -// Created by Milen Vitanov on 09.02.22. -// - -import SwiftUI - -struct PersonDetailedCellView: View { - - @State var cell: PersonDetailsCell - - var body: some View { - VStack(alignment: .leading) { - Text(cell.key) - .foregroundColor(Color(.label)) - Text(cell.value) - .foregroundColor(.blue) - } - } -} - -struct PersonDetailedCellView_Previews: PreviewProvider { - static var previews: some View { - PersonDetailedCellView(cell: PersonDetailsCell(key: "E-Mail", value: "test@example.com", actionType: PersonDetailsCell.ActionType.mail)) - } -} diff --git a/Campus-iOS/PersonDetailedComponent/View/PersonDetailedView.swift b/Campus-iOS/PersonDetailedComponent/View/PersonDetailedView.swift index 96167105..e1fef093 100644 --- a/Campus-iOS/PersonDetailedComponent/View/PersonDetailedView.swift +++ b/Campus-iOS/PersonDetailedComponent/View/PersonDetailedView.swift @@ -10,115 +10,144 @@ import ContactsUI struct PersonDetailedView: View { let imageSize: CGFloat = 125.0 - - @ObservedObject var viewModel: PersonDetailedViewModel - - init(withPerson person: Person) { - self.viewModel = PersonDetailedViewModel(withPerson: person) - } - - init(withProfile profile: Profile) { - self.viewModel = PersonDetailedViewModel(withProfile: profile) - } + let personDetails: PersonDetails var body: some View { VStack { - Spacer() - if let header = viewModel.sections?.first(where: { $0.name == "Header" })?.cells.first, let cell = header as? PersonDetailsHeader { - if let image = cell.image { - Image(uiImage: image) - .resizable() - .clipShape(Circle()) - .aspectRatio(contentMode: .fill) - .frame(width: imageSize, height: imageSize) - } else { - Image(systemName: "person.crop.circle.fill") - .resizable() - .foregroundColor(Color(.secondaryLabel)) - .frame(width: imageSize, height: imageSize) - } - Spacer().frame(height: 10) - Text("\(cell.name)").font(.system(size: 18)) + if let image = personDetails.image { + Image(uiImage: image) + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fill) + .frame(width: imageSize, height: imageSize) } else { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .accentColor)) - .padding(2) + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundColor(Color(.secondaryLabel)) + .frame(width: imageSize, height: imageSize) } - if self.viewModel.sections?.count ?? 0 > 1 { - form - } else { - List { - HStack { - Spacer() - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .accentColor)) - .padding(2) - Spacer() + Spacer().frame(height: 10) + Text("\(personDetails.firstName) \(personDetails.name)").font(.system(size: 18)) + List { + if !personDetails.email.isEmpty || !(personDetails.officeHours?.isEmpty ?? false) { + Section(header: Text("General")) { + if !personDetails.email.isEmpty, let mailURL = URL(string: "mailto:\(personDetails.email)") { + VStack(alignment: .leading) { + Text("E-Mail") + Link(personDetails.email, destination: mailURL) + } + } + if let officeHours = personDetails.officeHours, !officeHours.isEmpty { + VStack(alignment: .leading) { + Text("Office Hours") + Text(officeHours) + } + } } } - } - } - .background(Color(.systemGroupedBackground)) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - NavigationLink( - destination: AddToContactsView(contact: self.viewModel.cnContact) - .navigationBarTitleDisplayMode(.inline) - ) { - Label("", systemImage: "person.crop.circle.badge.plus") - }.disabled(self.viewModel.sections?.count ?? 0 < 2) - } - } - .onAppear { - self.viewModel.fetch() - } - } - - var form: some View { - Form { - ForEach(self.viewModel.sections?.filter({ $0.name != "Header" }) ?? []) { section in - Section(section.name) { - ForEach(section.cells as? [PersonDetailsCell] ?? []) { singleCell in - Button(action: { - Self.cellActionBasedOnType(cell: singleCell) - }, label: { PersonDetailedCellView(cell: singleCell) }) + if !personDetails.officialContact.isEmpty { + Section(header: Text("Offical Contact")) { + ForEach(personDetails.officialContact) { contactInfo in + VStack(alignment: .leading) { + switch contactInfo { + case .phone(let phone): + let number = phone.replacingOccurrences(of: " ", with: "") + if let phoneURL = URL(string: "tel:\(number)") { + Text("Phone") + Link("\(phone)", destination: phoneURL) + } + case .mobilePhone(let mobilePhone): + let number = mobilePhone.replacingOccurrences(of: " ", with: "") + if let mobilePhoneURL = URL(string: "tel:\(number)") { + Text("Mobile") + Link("\(mobilePhone)", destination: mobilePhoneURL) + } + case .fax(let fax): + Text("Fax") + Text("\(fax)") + case .additionalInfo(let additionalInfo): + Text("Additional Info") + Text("\(additionalInfo)") + case .homepage(let homepage): + if let homepageURL = URL(string: homepage) { + Text("Hoomepage") + Link("\(homepage)", destination: homepageURL) + } + } + } + } + } + } + if !personDetails.privateContact.isEmpty { + Section(header: Text("Offical Contact")) { + ForEach(personDetails.privateContact) { contactInfo in + VStack(alignment: .leading) { + switch contactInfo { + case .phone(let phone): + let number = phone.replacingOccurrences(of: " ", with: "") + if let phoneURL = URL(string: "tel:\(number)") { + Text("Phone") + Link("\(phone)", destination: phoneURL) + } + case .mobilePhone(let mobilePhone): + let number = mobilePhone.replacingOccurrences(of: " ", with: "") + if let mobilePhoneURL = URL(string: "tel:\(number)") { + Text("Mobile") + Link("\(mobilePhone)", destination: mobilePhoneURL) + } + case .fax(let fax): + Text("Fax") + Text("\(fax)") + case .additionalInfo(let additionalInfo): + Text("Additional Info") + Text("\(additionalInfo)") + case .homepage(let homepage): + if let homepageURL = URL(string: homepage) { + Text("Hoomepage") + Link("\(homepage)", destination: homepageURL) + } + } + } + } + } + } + if !personDetails.phoneExtensions.isEmpty { + Section(header: Text("Phone Extensions")) { + ForEach(personDetails.phoneExtensions) { phoneExtension in + let number = phoneExtension.phoneNumber.replacingOccurrences(of: " ", with: "") + if let phoneNumberURL = URL(string: "tel:\(number)") { + VStack(alignment: .leading) { + Text("Office") + Link("\(phoneExtension.phoneNumber)", destination: phoneNumberURL) + } + } + } + } + } + + if !personDetails.organisations.isEmpty { + Section(header: Text("Organisations")) { + ForEach(personDetails.organisations) { organisation in + VStack(alignment: .leading) { + Text("Organisation") + Text("\(organisation.name)") + } + } + } + } + + if !personDetails.rooms.isEmpty { + Section(header: Text("Rooms")) { + ForEach(personDetails.rooms) { room in + VStack(alignment: .leading) { + Text("Room") + Text("\(room.shortLocationDescription)") + } + } } } } } - .edgesIgnoringSafeArea(.all) } - static func cellActionBasedOnType(cell: PersonDetailsCell) { - switch cell.actionType { - case .none, .showRoom: - break - case .call: - let number = cell.value.replacingOccurrences(of: " ", with: "") - if let url = URL(string: "tel://\(number)") { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - case .mail: - if let url = URL(string: "mailto:\(cell.value)") { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - case .openURL: - if let url = URL(string: cell.value) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - } - } -} - -struct PersonDetailedView_Previews: PreviewProvider { - static var previews: some View { - Group { - PersonDetailedView(withPerson: Person(firstName: "Milen", lastName: "Vitanov", title: nil, nr: "12654465", obfuscatedId: "445555dd4", gender: Gender.male)) - .preferredColorScheme(.light) - .previewInterfaceOrientation(.portrait) - PersonDetailedView(withPerson: Person(firstName: "Milen", lastName: "Vitanov", title: nil, nr: "12654465", obfuscatedId: "445555dd4", gender: Gender.male)) - .preferredColorScheme(.dark) - .previewInterfaceOrientation(.portrait) - } - } } diff --git a/Campus-iOS/PersonDetailedComponent/ViewModel/PersonDetailedViewModel.swift b/Campus-iOS/PersonDetailedComponent/ViewModel/PersonDetailedViewModel.swift index 60a19249..da28fe42 100644 --- a/Campus-iOS/PersonDetailedComponent/ViewModel/PersonDetailedViewModel.swift +++ b/Campus-iOS/PersonDetailedComponent/ViewModel/PersonDetailedViewModel.swift @@ -11,197 +11,107 @@ import XMLCoder import SwiftUI import Contacts -struct PersonDetailsHeader: Identifiable, Hashable { - let id = UUID() - let image: UIImage? - let imageURL: URL? - let name: String - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(name) - } -} - -struct PersonDetailsCell: Identifiable, Hashable { - enum ActionType { - case call - case mail - case openURL - case showRoom - } - - let id = UUID() - let key: String - let value: String - let actionType: ActionType? -} - -struct PersonDetailsSection: Identifiable, Hashable { - let id = UUID() - let name: String - let cells: [AnyHashable] +enum DetailsType { + case Person(Person) + case Profile(Profile) } +@MainActor class PersonDetailedViewModel: ObservableObject { - var person: PersonDetails? - var endpoint: TUMOnlineAPI - - @Published var sections: [PersonDetailsSection]? - - private let sessionManager = Session.defaultSession - - init(withId id: String) { - self.endpoint = TUMOnlineAPI.personDetails(identNumber: id) - } - - init(withPerson person: Person) { - let header: PersonDetailsHeader - if let personGroup = person.personGroup, let id = person.id { - header = PersonDetailsHeader(image: nil, imageURL: TUMOnlineAPI.profileImage(personGroup: personGroup, id: id).urlRequest?.url, name: "\(person.title?.appending(" ") ?? "")\(person.firstName) \(person.name)") - } else { - header = PersonDetailsHeader(image: nil, imageURL: nil, name: "\(person.title?.appending(" ") ?? "")\(person.firstName) \(person.name)") - } - - self.sections = [PersonDetailsSection(name: "Header", cells: [header])] - self.endpoint = TUMOnlineAPI.personDetails(identNumber: person.obfuscatedID) - } + @Published var state: APIState = .na + @Published var hasError: Bool = false + let model: Model + let service: PersonDetailedService + let type: DetailsType - init(withProfile profile: Profile) { - var sections: [PersonDetailsSection] = [] - - let header: PersonDetailsHeader - if let personGroup = profile.personGroup, let id = profile.id { - header = PersonDetailsHeader(image: nil, imageURL: TUMOnlineAPI.profileImage(personGroup: personGroup, id: id).urlRequest?.url, name: "\(profile.firstname ?? "") \(profile.surname ?? "")") - } else { - header = PersonDetailsHeader(image: nil, imageURL: nil, name: "\(profile.firstname ?? "") \(profile.surname ?? "")") - } - sections.append(PersonDetailsSection(name: "Header", cells: [header])) - - if let tumID = profile.tumID { - sections.append(PersonDetailsSection(name: "General", cells: [PersonDetailsCell(key: "TUM ID".localized, value: tumID, actionType: .none)])) - } - - self.sections = sections - self.endpoint = TUMOnlineAPI.personDetails(identNumber: profile.obfuscatedID ?? "") + init(model: Model, service: PersonDetailedService, type: DetailsType) { + self.model = model + self.service = service + self.type = type } - func fetch() { - self.sessionManager.request(self.endpoint).responseDecodable(of: PersonDetails.self, decoder: XMLDecoder()) { [weak self] response in - guard let value = response.value else { return } - self?.person = value - self?.fillFromProfileDetails() - } - } - - func fillFromProfileDetails() { - let header: PersonDetailsHeader - if let personGroup = self.person?.personGroup, let id = self.person?.id { - header = PersonDetailsHeader(image: self.person?.image, imageURL: TUMOnlineAPI.profileImage(personGroup: personGroup, id: id).urlRequest?.url, name: "\(self.person?.title?.appending(" ") ?? "")\(self.person?.firstName.appending(" ") ?? "")\(self.person?.name ?? "")") - } else { - header = PersonDetailsHeader(image: self.person?.image, imageURL: nil, name: "\(self.person?.title?.appending(" ") ?? "")\(self.person?.firstName.appending(" ") ?? "")\(self.person?.name ?? "")") - } - - var general: [PersonDetailsCell] = [] - if let email = self.person?.email, !email.isEmpty { - general.append(PersonDetailsCell(key: "E-Mail".localized, value: email, actionType: .mail)) + func getDetails(forcedRefresh: Bool) async { + if !forcedRefresh { + self.state = .loading } - if let officeHours = self.person?.officeHours, !officeHours.isEmpty { - general.append(PersonDetailsCell(key: "Office Hours".localized, value: officeHours, actionType: .none)) + self.hasError = false + + guard let token = self.model.token else { + self.state = .failed(error: NetworkingError.unauthorized) + self.hasError = true + return } - - var officialContact: [PersonDetailsCell] = [] - if let contactInfo = self.person?.officialContact, contactInfo.count > 0 { - officialContact = contactInfo.map { info in - switch info { - case let .phone(number): return PersonDetailsCell(key: "Phone".localized, value: number, actionType: .call) - case let .mobilePhone(number): return PersonDetailsCell(key: "Mobile".localized, value: number, actionType: .call) - case let .fax(number): return PersonDetailsCell(key: "Fax".localized, value: number, actionType: .none) - case let .additionalInfo(additionalInfo): return PersonDetailsCell(key: "Additional Info".localized, value: additionalInfo, actionType: .none) - case let .homepage(urlString): return PersonDetailsCell(key: "Homepage".localized, value: urlString, actionType: .openURL) - } + + do { + + if case .Person(let person) = type { + self.state = .success( + data: try await service.fetch(for: person.obfuscatedID, token: token, forcedRefresh: forcedRefresh) + ) } - } - var privateContact: [PersonDetailsCell] = [] - if let privateContactInfo = self.person?.privateContact, privateContactInfo.count > 0 { - privateContact = privateContactInfo.map { info in - switch info { - case let .phone(number): return PersonDetailsCell(key: "Phone".localized, value: number, actionType: .call) - case let .mobilePhone(number): return PersonDetailsCell(key: "Mobile".localized, value: number, actionType: .call) - case let .fax(number): return PersonDetailsCell(key: "Fax".localized, value: number, actionType: .none) - case let .additionalInfo(additionalInfo): return PersonDetailsCell(key: "Additional Info".localized, value: additionalInfo, actionType: .none) - case let .homepage(urlString): return PersonDetailsCell(key: "Homepage".localized, value: urlString, actionType: .openURL) - } + if case .Profile(let profile) = type, let obfuscatedID = profile.obfuscatedID { + self.state = .success( + data: try await service.fetch(for: obfuscatedID, token: token, forcedRefresh: forcedRefresh) + ) } + } catch { + self.state = .failed(error: error) + self.hasError = true } - - let phoneExtensions = self.person?.phoneExtensions.map { PersonDetailsCell(key: "Office".localized, value: $0.phoneNumber, actionType: .call) } ?? [] - - let organisations = self.person?.organisations.map { PersonDetailsCell(key: "Organisation".localized, value: $0.name, actionType: .none) } ?? [] - - let rooms = self.person?.rooms.map { PersonDetailsCell(key: "Room".localized, value: $0.shortLocationDescription, actionType: .showRoom) } ?? [] - - self.sections = [ - PersonDetailsSection(name: "Header", cells: [header]), - PersonDetailsSection(name: "General", cells: general), - PersonDetailsSection(name: "Official Contact", cells: officialContact), - PersonDetailsSection(name: "Private Contact", cells: privateContact), - PersonDetailsSection(name: "Phone Extensions", cells: phoneExtensions), - PersonDetailsSection(name: "Organisations", cells: organisations), - PersonDetailsSection(name: "Rooms", cells: rooms) - ].filter { !$0.cells.isEmpty } } var cnContact: CNMutableContact { - guard let person = self.person else { return CNMutableContact() } + guard case .success(let personDetails) = state else { + return CNMutableContact() + } let contact = CNMutableContact() contact.contactType = .person - if let title = person.title { + if let title = personDetails.title { contact.namePrefix = title } - contact.givenName = person.firstName - contact.familyName = person.name + contact.givenName = personDetails.firstName + contact.familyName = personDetails.name - contact.emailAddresses = [CNLabeledValue(label: CNLabelWork, value: person.email as NSString)] - if let organisation = person.organisations.first { + contact.emailAddresses = [CNLabeledValue(label: CNLabelWork, value: personDetails.email as NSString)] + if let organisation = personDetails.organisations.first { contact.departmentName = organisation.name } - var phoneNumbers: [CNLabeledValue] = person.privateContact.compactMap { info in + var phoneNumbers: [CNLabeledValue] = personDetails.privateContact.compactMap { info in switch info { case .phone(let number), .mobilePhone(let number) : return CNLabeledValue(label: CNLabelWork, value: CNPhoneNumber(stringValue: number)) default: return nil } } - phoneNumbers.append(contentsOf: person.officialContact.compactMap { info in + phoneNumbers.append(contentsOf: personDetails.officialContact.compactMap { info in switch info { case .phone(let number), .mobilePhone(let number) : return CNLabeledValue(label: CNLabelWork, value: CNPhoneNumber(stringValue: number)) default: return nil } }) - phoneNumbers.append(contentsOf: person.phoneExtensions.map { phoneExtension in + phoneNumbers.append(contentsOf: personDetails.phoneExtensions.map { phoneExtension in return CNLabeledValue(label: CNLabelWork, value: CNPhoneNumber(stringValue: phoneExtension.phoneNumber)) }) contact.phoneNumbers = phoneNumbers - if let imageData = person.image?.jpegData(compressionQuality: 1) { + if let imageData = personDetails.image?.jpegData(compressionQuality: 1) { contact.imageData = imageData } - var urls: [CNLabeledValue] = person.privateContact.compactMap{ info in + var urls: [CNLabeledValue] = personDetails.privateContact.compactMap{ info in switch info { case .homepage(let urlString): return CNLabeledValue(label: CNLabelWork, value: urlString as NSString) default: return nil } } - urls.append(contentsOf: person.officialContact.compactMap { info in + urls.append(contentsOf: personDetails.officialContact.compactMap { info in switch info { case .homepage(let urlString): return CNLabeledValue(label: CNLabelWork, value: urlString as NSString) default: return nil @@ -211,7 +121,7 @@ class PersonDetailedViewModel: ObservableObject { contact.urlAddresses = urls contact.organizationName = "TUM" - if let room = person.rooms.first { + if let room = personDetails.rooms.first { contact.note = room.locationDescription } diff --git a/Campus-iOS/PersonSearchComponent/Screen/PersonSearchScreen.swift b/Campus-iOS/PersonSearchComponent/Screen/PersonSearchScreen.swift new file mode 100644 index 00000000..c717f989 --- /dev/null +++ b/Campus-iOS/PersonSearchComponent/Screen/PersonSearchScreen.swift @@ -0,0 +1,74 @@ +// +// PersonSearchView.swift +// Campus-iOS +// +// Created by Milen Vitanov on 06.02.22. +// + +import SwiftUI + +struct PersonSearchScreen: View { + @StateObject var vm: PersonSearchViewModel + @State var searchText = "" + + init(model: Model) { + self._vm = StateObject(wrappedValue: PersonSearchViewModel(model: model, service: PersonSearchService())) + } + + init(model: Model, findPerson: String) { + self._vm = StateObject(wrappedValue: PersonSearchViewModel(model: model, service: PersonSearchService())) + self._searchText = State(wrappedValue: findPerson) + } + + var body: some View { + Group { + switch vm.state { + case .success(let persons): + VStack { + PersonSearchView(model: vm.model, persons: persons) + .background(Color(.systemGroupedBackground)) + } + case .loading: + LoadingView(text: "Fetching Persons") + case .failed(let error): + FailedView( + errorDescription: error.localizedDescription, + retryClosure: {_ in await vm.getPersons(for: self.searchText, forcedRefresh: true)} + ) + case .na: + EmptyView() + } + } + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) + .onChange(of: self.searchText) { query in + Task { + await vm.getPersons(for: query, forcedRefresh: true) + } + } + .task { + if !searchText.isEmpty { + await vm.getPersons(for: searchText, forcedRefresh: true) + } + } + .alert( + "Error while fetching Persons", + isPresented: $vm.hasError, + presenting: vm.state) { detail in + Button("Retry") { + Task { + await vm.getPersons(for: self.searchText, forcedRefresh: true) + } + } + + Button("Cancel", role: .cancel) { } + } message: { detail in + if case let .failed(error) = detail { + if let apiError = error as? TUMOnlineAPIError { + Text(apiError.errorDescription ?? "TUMOnlineAPI Error") + } else { + Text(error.localizedDescription) + } + } + } + } +} diff --git a/Campus-iOS/PersonSearchComponent/Service/PersonSearchService.swift b/Campus-iOS/PersonSearchComponent/Service/PersonSearchService.swift new file mode 100644 index 00000000..a8432771 --- /dev/null +++ b/Campus-iOS/PersonSearchComponent/Service/PersonSearchService.swift @@ -0,0 +1,16 @@ +// +// PersonSearchService.swift +// Campus-iOS +// +// Created by David Lin on 21.01.23. +// + +import Foundation + +struct PersonSearchService { + func fetch(for query: String, token: String, forcedRefresh: Bool) async throws -> [Person] { + let response : TUMOnlineAPI.Response = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.personSearch(search: query), token: token, forcedRefresh: forcedRefresh) + + return response.row + } +} diff --git a/Campus-iOS/PersonSearchComponent/View/PersonSearchListView.swift b/Campus-iOS/PersonSearchComponent/View/PersonSearchListView.swift deleted file mode 100644 index 8956bf62..00000000 --- a/Campus-iOS/PersonSearchComponent/View/PersonSearchListView.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// PersonSearchListView.swift -// Campus-iOS -// -// Created by Milen Vitanov on 13.02.22. -// - -import SwiftUI - -struct PersonSearchListView: View { - - @Environment(\.isSearching) private var isSearching - @ObservedObject var viewModel: PersonSearchViewModel - - var body: some View { - List { - ForEach(self.viewModel.result, id: \.nr) { person in - NavigationLink( - destination: PersonDetailedView(withPerson: person) - .navigationBarTitleDisplayMode(.inline) - ) { - Text(person.fullName) - } - } - if viewModel.errorMessage != "" { - VStack { - Spacer() - Text(self.viewModel.errorMessage).foregroundColor(.gray) - Spacer() - } - } - } - .onChange(of: isSearching) { newValue in - if !newValue { - self.viewModel.result = [] - } - } - } -} - -struct PersonSearchListView_Previews: PreviewProvider { - static var previews: some View { - PersonSearchListView(viewModel: PersonSearchViewModel()) - } -} diff --git a/Campus-iOS/PersonSearchComponent/View/PersonSearchView.swift b/Campus-iOS/PersonSearchComponent/View/PersonSearchView.swift index 89109bae..83565638 100644 --- a/Campus-iOS/PersonSearchComponent/View/PersonSearchView.swift +++ b/Campus-iOS/PersonSearchComponent/View/PersonSearchView.swift @@ -1,34 +1,33 @@ // -// PersonSearchView.swift +// PersonSearchListView.swift // Campus-iOS // -// Created by Milen Vitanov on 06.02.22. +// Created by Milen Vitanov on 13.02.22. // import SwiftUI struct PersonSearchView: View { - - @Environment(\.isSearching) private var isSearching - - @ObservedObject var viewModel = PersonSearchViewModel() - @State var searchText = "" + let model: Model + let persons: [Person] var body: some View { - PersonSearchListView(viewModel: self.viewModel) - .background(Color(.systemGroupedBackground)) - .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) - .onChange(of: self.searchText) { searchValue in - if searchValue.count > 3 { - self.viewModel.fetch(searchString: searchValue) + List { + ForEach(self.persons, id: \.nr) { person in + NavigationLink( + destination: + PersonDetailedScreen(model: model, person: person) + .navigationBarTitleDisplayMode(.inline) + ) { + Text(person.fullName) } } - .animation(.default, value: self.viewModel.result) + } } } struct PersonSearchView_Previews: PreviewProvider { static var previews: some View { - PersonSearchView() + PersonSearchView(model: Model(), persons: []) } } diff --git a/Campus-iOS/PersonSearchComponent/ViewModel/PersonSearchViewModel.swift b/Campus-iOS/PersonSearchComponent/ViewModel/PersonSearchViewModel.swift index df2750c2..0fcc9d7e 100644 --- a/Campus-iOS/PersonSearchComponent/ViewModel/PersonSearchViewModel.swift +++ b/Campus-iOS/PersonSearchComponent/ViewModel/PersonSearchViewModel.swift @@ -9,30 +9,39 @@ import Foundation import Alamofire import XMLCoder +@MainActor class PersonSearchViewModel: ObservableObject { - @Published var result: [Person] = [] - @Published var errorMessage: String = "" + @Published var state: APIState<[Person]> = .na + @Published var hasError: Bool = false - private let sessionManager = Session.defaultSession + let model: Model + let service: PersonSearchService - func fetch(searchString: String) { - // activate only when more than 3 characters - - let endpoint = TUMOnlineAPI.personSearch(search: searchString) - sessionManager.cancelAllRequests() - let request = sessionManager.request(endpoint) - request.responseDecodable(of: TUMOnlineAPIResponse.self, decoder: XMLDecoder()) { [weak self] response in - guard !request.isCancelled else { - // cancelAllRequests doesn't seem to cancel all requests, so better check for this explicitly - return - } - self?.result = response.value?.rows ?? [] + init(model: Model, service: PersonSearchService) { + self.model = model + self.service = service + } + + func getPersons(for query: String, forcedRefresh: Bool) async { + if !forcedRefresh { + self.state = .loading + } + self.hasError = false - if let result = self?.result, result.isEmpty { - self?.errorMessage = NSString(format: "Unable to find person".localized as NSString, searchString) as String - } else { - self?.errorMessage = "" - } + guard let token = self.model.token else { + self.state = .failed(error: NetworkingError.unauthorized) + self.hasError = true + return + } + + do { + self.state = .success( + data: try await service.fetch(for: query, token: token, forcedRefresh: forcedRefresh) + ) + + } catch { + self.state = .failed(error: error) + self.hasError = true } } } diff --git a/Campus-iOS/ProfileComponent/Entity/Profile.swift b/Campus-iOS/ProfileComponent/Entity/Profile.swift index afed5a54..fb2ef368 100644 --- a/Campus-iOS/ProfileComponent/Entity/Profile.swift +++ b/Campus-iOS/ProfileComponent/Entity/Profile.swift @@ -6,8 +6,9 @@ // import Foundation +import SwiftUI -struct Profile: Decodable, Entity { +struct Profile: Decodable { let firstname: String? let obfuscatedID: String? let obfuscatedIDEmployee: String? @@ -31,6 +32,8 @@ struct Profile: Decodable, Entity { "\(self.firstname?.appending(" ") ?? "")\(self.surname?.appending(" ") ?? "")" } + var image: Image? + /* ga94zuh @@ -65,7 +68,7 @@ struct Profile: Decodable, Entity { } } - init(firstname: String?, surname: String?, tumId: String?, obfuscatedID: String?, obfuscatedIDEmployee: String?, obfuscatedIDExtern: String?, obfuscatedIDStudent: String?) { + init(firstname: String?, surname: String?, tumId: String?, obfuscatedID: String?, obfuscatedIDEmployee: String?, obfuscatedIDExtern: String?, obfuscatedIDStudent: String?, image: Image?) { self.firstname = firstname self.surname = surname self.obfuscatedID = obfuscatedID @@ -73,6 +76,7 @@ struct Profile: Decodable, Entity { self.obfuscatedIDExtern = obfuscatedIDExtern self.obfuscatedIDStudent = obfuscatedIDStudent self.tumID = tumId + self.image = image } init(from decoder: Decoder) throws { @@ -93,5 +97,6 @@ struct Profile: Decodable, Entity { self.obfuscatedIDExtern = obfuscatedIDExtern self.obfuscatedIDStudent = obfuscatedIDStudent self.firstname = firstname + self.image = nil } } diff --git a/Campus-iOS/ProfileComponent/Entity/Tuition.swift b/Campus-iOS/ProfileComponent/Entity/Tuition.swift index 58dc2bbc..34d1993d 100644 --- a/Campus-iOS/ProfileComponent/Entity/Tuition.swift +++ b/Campus-iOS/ProfileComponent/Entity/Tuition.swift @@ -7,7 +7,7 @@ import Foundation -struct Tuition: Entity { +struct Tuition: Decodable { var amount: NSDecimalNumber? var deadline: Date? diff --git a/Campus-iOS/ProfileComponent/Service/ProfileService.swift b/Campus-iOS/ProfileComponent/Service/ProfileService.swift new file mode 100644 index 00000000..576ef35a --- /dev/null +++ b/Campus-iOS/ProfileComponent/Service/ProfileService.swift @@ -0,0 +1,42 @@ +// +// ProfileService.swift +// Campus-iOS +// +// Created by David Lin on 22.01.23. +// + +import Foundation +import SwiftUI +import UIKit + +struct ProfileService { + func fetch(token: String, forcedRefresh: Bool) async throws -> Profile? { + let response : TUMOnlineAPI.Response = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.identify, token: token, forcedRefresh: forcedRefresh) + + return response.row.first + } + + func fetch(token: String, forcedRefresh: Bool) async throws -> Tuition? { + let response : TUMOnlineAPI.Response = try await MainAPI.makeRequest(endpoint: TUMOnlineAPI.tuitionStatus, token: token, forcedRefresh: forcedRefresh) + + return response.row.first + } + + struct superImage: Decodable { + let value: Image? + + enum CodingKeys: String, CodingKey { + case imageData = "" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let imageString = try container.decodeIfPresent(String.self, forKey: .imageData), let imageData = Data(base64Encoded: imageString, options: [.ignoreUnknownCharacters]), let uiImage = UIImage(data: imageData) { + self.value = Image(uiImage: uiImage) + } else { + self.value = nil + } + } + } +} diff --git a/Campus-iOS/ProfileComponent/View/ProfileMyTumSection.swift b/Campus-iOS/ProfileComponent/View/ProfileMyTumSection.swift deleted file mode 100644 index df25bfd9..00000000 --- a/Campus-iOS/ProfileComponent/View/ProfileMyTumSection.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// ProfileTuitionNavigationLink.swift -// Campus-iOS -// -// Created by Milen Vitanov on 13.02.22. -// - -import SwiftUI - -struct ProfileMyTumSection: View { - - @EnvironmentObject private var model: Model - - var formattedAmount: String { - guard let amount = self.model.profile.tuition?.amount else { - return "n/a" - } - return OpenTuitionAmountView.currencyFormatter.string(from: amount) ?? "n/a" - } - - var body: some View { - Section("MY TUM") { - NavigationLink(destination: TuitionView(viewModel: self.model.profile).navigationBarTitle(Text("Tuition fees"))) { - Label { - HStack { - Text("Tuition fees") - if let isOpenAmount = self.model.profile.tuition?.isOpenAmount, isOpenAmount != true { - Spacer() - Text("✅") - } else { - Spacer() - Text(self.formattedAmount).foregroundColor(.red) - } - } - } icon: { - Image(systemName: "eurosign.circle") - } - } - .disabled(!self.model.isUserAuthenticated) - - NavigationLink(destination: PersonSearchView().navigationBarTitle(Text("Person Search")).navigationBarTitleDisplayMode(.large)) { - Label("Person Search", systemImage: "magnifyingglass") - } - .disabled(!self.model.isUserAuthenticated) - - NavigationLink(destination: LectureSearchView(model: model).navigationBarTitle(Text("Lecture Search")).navigationBarTitleDisplayMode(.large)) { - Label("Lecture Search", systemImage: "brain.head.profile") - } - .disabled(!self.model.isUserAuthenticated) - } - } -} - -struct ProfileMyTumSection_Previews: PreviewProvider { - static var previews: some View { - ProfileMyTumSection() - } -} diff --git a/Campus-iOS/ProfileComponent/View/ProfileToolbar.swift b/Campus-iOS/ProfileComponent/View/ProfileToolbar.swift index 36beaffb..b4be5f3d 100644 --- a/Campus-iOS/ProfileComponent/View/ProfileToolbar.swift +++ b/Campus-iOS/ProfileComponent/View/ProfileToolbar.swift @@ -8,14 +8,15 @@ import SwiftUI struct ProfileToolbar: View { - @ObservedObject var model: Model + @StateObject var model: Model + @State var showProfile = false var body: some View { - Button(action: {model.showProfile.toggle()}) { + Button(action: {self.showProfile.toggle()}) { Image(systemName: "person.crop.circle") } - .sheet(isPresented: $model.showProfile) { + .sheet(isPresented: $showProfile) { ProfileView(model: model) } diff --git a/Campus-iOS/ProfileComponent/View/ProfileView.swift b/Campus-iOS/ProfileComponent/View/ProfileView.swift index dbdc579e..db8157ad 100644 --- a/Campus-iOS/ProfileComponent/View/ProfileView.swift +++ b/Campus-iOS/ProfileComponent/View/ProfileView.swift @@ -8,82 +8,83 @@ import SwiftUI struct ProfileView: View { + @StateObject var vm: ProfileViewModel + @State var showActionSheet = false - @ObservedObject var model: Model @AppStorage("useBuildInWebView") var useBuildInWebView: Bool = true @AppStorage("calendarWeekDays") var calendarWeekDays: Int = 7 @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss @State var isWebViewShowed = false - @State var selectedLink: URL? = nil - + @State private var showSheet: Bool = false + @State private var url: URL? + + init(model: Model) { + self._vm = StateObject(wrappedValue: ProfileViewModel(model: model, service: ProfileService())) + self.url = .init(string: "https://google.com") + } + var body: some View { - NavigationView { - List { - NavigationLink(destination: PersonDetailedView(withProfile: self.model.profile.profile ?? ProfileViewModel.defaultProfile)) { - HStack(spacing: 24) { - self.model.profile.profileImage - .resizable() - .clipShape(Circle()) - .aspectRatio(contentMode: .fill) - .frame(width: 75, height: 75) - .foregroundColor(Color(.secondaryLabel)) + if case .success(let profile) = vm.profileState { + NavigationLink(destination: PersonDetailedScreen(model: self.vm.model, profile: profile)) { - VStack(alignment: .leading) { - if self.model.isUserAuthenticated { - Text(self.model.profile.profile?.fullName ?? "TUM Student") - .font(.title2) - } else { - Text("Not logged in") - .font(.title2) - } - - Text(self.model.profile.profile?.tumID ?? "TUM ID") - .font(.subheadline) - .foregroundColor(.gray) - } - } - .padding(.vertical, 6) - }.disabled(!self.model.isUserAuthenticated) + ProfileCell(model: self.vm.model, profile: profile) + }.disabled(!self.vm.model.isUserAuthenticated) + } else { + ProfileCell(model: self.vm.model, profile: ProfileViewModel.defaultProfile) + } - ProfileMyTumSection() + Section("MY TUM") { + TuitionScreen(vm: self.vm) + + NavigationLink(destination: PersonSearchScreen(model: self.vm.model).navigationBarTitle(Text("Person Search")).navigationBarTitleDisplayMode(.large)) { + Label("Person Search", systemImage: "magnifyingglass") + } + .disabled(!self.vm.model.isUserAuthenticated) + + NavigationLink(destination: LectureSearchScreen(model: vm.model).navigationBarTitle(Text("Lecture Search")).navigationBarTitleDisplayMode(.large)) { + Label("Lecture Search", systemImage: "brain.head.profile") + } + .disabled(!self.vm.model.isUserAuthenticated) + } Section("GENERAL") { - NavigationLink(destination: TUMSexyView().navigationBarTitle(Text("Useful Links"))) { + NavigationLink(destination: TUMSexyScreen().navigationBarTitle(Text("Useful Links"))) { Label("TUM.sexy", systemImage: "heart") } NavigationLink( - destination: RoomFinderView(model: self.model) + destination: NavigaTumView(model: self.vm.model) .navigationTitle(Text("Roomfinder")) .navigationBarTitleDisplayMode(.large) ) { Label("Roomfinder", systemImage: "rectangle.portrait.arrowtriangle.2.inward") } - NavigationLink(destination: NewsView(viewModel: NewsViewModel()) - .navigationBarTitle(Text("News")) - .navigationBarTitleDisplayMode(.large) + NavigationLink(destination: NewsScreen() + .navigationBarTitle(Text("News")) + .navigationBarTitleDisplayMode(.large) ) { Label("News", systemImage: "newspaper") } - NavigationLink(destination: MoviesView() - .navigationBarTitle(Text("Movies")) - .navigationBarTitleDisplayMode(.large) + NavigationLink(destination: MoviesScreen() + .navigationBarTitle(Text("Movies")) + .navigationBarTitleDisplayMode(.large) ) { Label("Movies", systemImage: "film") } - NavigationLink(destination: TokenPermissionsView(viewModel: TokenPermissionsViewModel(model: self.model), dismissWhenDone: true).navigationBarTitle("Check Permissions")) { - if self.model.isUserAuthenticated { + NavigationLink(destination: TokenPermissionsView(viewModel: TokenPermissionsViewModel(model: self.vm.model), dismissWhenDone: true).navigationBarTitle("Check Permissions")) { + if self.vm.model.isUserAuthenticated { Label("Token Permissions", systemImage: "key") } else { Label("Token Permissions (You are logged out)", systemImage: "key") } - }.disabled(!self.model.isUserAuthenticated) + }.disabled(!self.vm.model.isUserAuthenticated) } Section() { @@ -114,15 +115,18 @@ struct ProfileView: View { Section("GET IN CONTACT") { if self.useBuildInWebView { Button("Join Beta") { - self.selectedLink = URL(string: "https://testflight.apple.com/join/4Ddi6f2f") + self.url = URL(string: "https://testflight.apple.com/join/4Ddi6f2f")! + showSheet = true } Button("TUM Dev on Github") { - self.selectedLink = URL(string: "https://github.com/TUM-Dev") + self.url = URL(string: "https://github.com/TUM-Dev")! + showSheet = true } Button("TUM Dev Website") { - self.selectedLink = URL(string: "https://tum.app") + self.url = URL(string: "https://tum.app")! + showSheet = true } } else { Link(LocalizedStringKey("Join Beta"), destination: URL(string: "https://testflight.apple.com/join/4Ddi6f2f")!) @@ -136,7 +140,7 @@ struct ProfileView: View { let mailToString = "mailto:app@tum.de?subject=[IOS]&body=Hello I have an issue...".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) let mailToUrl = URL(string: mailToString!)! if UIApplication.shared.canOpenURL(mailToUrl) { - UIApplication.shared.open(mailToUrl, options: [:]) + UIApplication.shared.open(mailToUrl, options: [:]) } } } @@ -144,15 +148,15 @@ struct ProfileView: View { Section() { HStack(alignment: .bottom) { Spacer() - if model.isUserAuthenticated { + if vm.model.isUserAuthenticated { Button(action: { - model.logout() + vm.model.logout() }) { Text("Sign Out").foregroundColor(.red) } } else { Button(action: { - model.isLoginSheetPresented = true + vm.model.isLoginSheetPresented = true }) { Text("Sign In").foregroundColor(.green) } @@ -190,30 +194,67 @@ struct ProfileView: View { } .listRowBackground(Color.clear) } - .sheet(isPresented: $model.isLoginSheetPresented) { + .sheet(isPresented: $vm.model.isLoginSheetPresented) { NavigationView { - LoginView(model: model) + LoginView(model: vm.model) } } .navigationTitle("Profile") .navigationBarTitleDisplayMode(.inline) .toolbar { - Button(action: {model.showProfile.toggle()}) { + Button { + dismiss() + } label: { Text("Done").bold() } + } - .sheet(item: $selectedLink) { selectedLink in - if let link = selectedLink { - SFSafariViewWrapper(url: link) - } + .sheet(isPresented: $showSheet) { + if showSheet { SFSafariViewWrapper(url: url!) } } + }.task { + await vm.getProfile(forcedRefresh: false) } } } -struct ProfileView_Previews: PreviewProvider { +struct ProfileCell: View { + @StateObject var model: Model + let profile: Profile - static var previews: some View { - ProfileView(model: MockModel()).environmentObject(MockModel()) + var body: some View { + HStack(spacing: 24) { + if let image = profile.image { + image + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fill) + .frame(width: 75, height: 75) + .foregroundColor(Color(.secondaryLabel)) + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fill) + .frame(width: 75, height: 75) + .foregroundColor(Color(.secondaryLabel)) + } + + VStack(alignment: .leading) { + if self.model.isUserAuthenticated { + Text(profile.fullName) + .font(.title2) + Text(profile.tumID ?? "TUM ID") + .font(.subheadline) + .foregroundColor(.gray) + } else { + Text("Not logged in") + .font(.title2) + } + + + } + } + .padding(.vertical, 6) } } diff --git a/Campus-iOS/ProfileComponent/View/TuitionScreen.swift b/Campus-iOS/ProfileComponent/View/TuitionScreen.swift new file mode 100644 index 00000000..31570d47 --- /dev/null +++ b/Campus-iOS/ProfileComponent/View/TuitionScreen.swift @@ -0,0 +1,97 @@ +// +// ProfileTuitionNavigationLink.swift +// Campus-iOS +// +// Created by Milen Vitanov on 13.02.22. +// + +import SwiftUI + +struct TuitionScreen: View { + + @StateObject var vm: ProfileViewModel + + var body: some View { + Group { + switch vm.tuitionState { + case .success(let tuition): + NavigationLink(destination: TuitionView(tuition: tuition).navigationBarTitle(Text("Tuition fees"))) { + Label { + HStack { + Text("Tuition fees") + if !tuition.isOpenAmount { + Spacer() + Text("✅") + } else { + if let amount = tuition.amount, let formattedAmount = OpenTuitionAmountView.currencyFormatter.string(from: amount) { + Spacer() + Text(formattedAmount).foregroundColor(.red) + } else { + Text("Open amount couldn't be fetched.") + } + } + } + } icon: { + Image(systemName: "eurosign.circle") + } + } + case .loading, .na: + Text("Loading") + case .failed(error: let error): + Text(error.localizedDescription) + } + }.task { + await vm.getTuition(forcedRefresh: true) + } + } +} + +//struct ProfileMyTumSection: View { +// +// @EnvironmentObject private var model: Model +// +// var formattedAmount: String { +// guard let amount = self.model.profile.tuition?.amount else { +// return "n/a" +// } +// return OpenTuitionAmountView.currencyFormatter.string(from: amount) ?? "n/a" +// } +// +// var body: some View { +// Section("MY TUM") { +//// NavigationLink(destination: TuitionView(viewModel: self.model.profile).navigationBarTitle(Text("Tuition fees"))) { +//// Label { +//// HStack { +//// Text("Tuition fees") +//// if let isOpenAmount = self.model.profile.tuition?.isOpenAmount, isOpenAmount != true { +//// Spacer() +//// Text("✅") +//// } else { +//// Spacer() +//// Text(self.formattedAmount).foregroundColor(.red) +//// } +//// } +//// } icon: { +//// Image(systemName: "eurosign.circle") +//// } +//// } +//// .disabled(!self.model.isUserAuthenticated) +// +// NavigationLink(destination: PersonSearchScreen(model: self.model).navigationBarTitle(Text("Person Search")).navigationBarTitleDisplayMode(.large)) { +// Label("Person Search", systemImage: "magnifyingglass") +// } +// .disabled(!self.model.isUserAuthenticated) +// +// NavigationLink(destination: LectureSearchScreen(model: model).navigationBarTitle(Text("Lecture Search")).navigationBarTitleDisplayMode(.large)) { +// Label("Lecture Search", systemImage: "brain.head.profile") +// } +// .disabled(!self.model.isUserAuthenticated) +// } +// } +//} +// +//struct ProfileMyTumSection_Previews: PreviewProvider { +// static var previews: some View { +// ProfileMyTumSection() +// } +//} diff --git a/Campus-iOS/ProfileComponent/ViewModel/ProfileViewModel.swift b/Campus-iOS/ProfileComponent/ViewModel/ProfileViewModel.swift index dfa4b497..161e5d69 100644 --- a/Campus-iOS/ProfileComponent/ViewModel/ProfileViewModel.swift +++ b/Campus-iOS/ProfileComponent/ViewModel/ProfileViewModel.swift @@ -10,16 +10,15 @@ import Alamofire import XMLCoder import SwiftUI +@MainActor class ProfileViewModel: ObservableObject { + @Published var profileState: APIState = .na + @Published var profileHasError: Bool = false + @Published var tuitionState: APIState = .na + @Published var tuitionHasError: Bool = false - @Published var profile: Profile? - @Published var tuition: Tuition? - @Published var profileImage = Image(systemName: "person.crop.circle.fill") - - private let sessionManager = Session.defaultSession - - var profileState : ProfileState = .na - var tuitionState : TuitionState = .na + var model: Model + let service: ProfileService static let defaultProfile = Profile( firstname: nil, @@ -28,100 +27,235 @@ class ProfileViewModel: ObservableObject { obfuscatedID: nil, obfuscatedIDEmployee: nil, obfuscatedIDExtern: nil, - obfuscatedIDStudent: nil + obfuscatedIDStudent: nil, + image: nil ) - init() { - self.profile = Self.defaultProfile + init(model: Model, service: ProfileService) { + self.model = model + self.service = service } - init(model: Model) { - switch model.loginController.credentials { - case .none, .noTumID: - self.profile = Self.defaultProfile - case .tumID(_, _), .tumIDAndKey(_, _, _): - fetch() + func getProfile(forcedRefresh: Bool) async { + if !forcedRefresh { + self.profileState = .loading } - } - - func fetch(callback: @escaping (Result) -> Void = {_ in }) { - let importer = Importer, XMLDecoder>(endpoint: TUMOnlineAPI.identify) - importer.performFetch( handler: { result in - DispatchQueue.main.async { - switch result { - case .success(let storage): - self.profile = storage.rows?.first - if let personGroup = self.profile?.personGroup, let personId = self.profile?.id, let obfuscatedID = self.profile?.obfuscatedID { - self.downloadProfileImage(personGroup: personGroup, personId: personId, obfuscatedID: obfuscatedID) - } - - self.checkTuitionFunc() - - if let _ = self.profile { - callback(.success(true)) - } else { - callback(.failure(CampusOnlineAPI.Error.noPermission)) - } - case .failure(let error): - callback(.failure(error)) - print(error) - } + self.profileHasError = false + + guard let token = self.model.token else { + self.profileState = .failed(error: NetworkingError.unauthorized) + self.profileHasError = true + return + } + + do { + guard var profile: Profile = try await service.fetch(token: token, forcedRefresh: forcedRefresh) else { + self.profileState = .failed(error: TUMOnlineAPIError(message: "No profile found")) + return + } + + if let personGroup = profile.personGroup, let id = profile.id, let obfuscatedID = profile.obfuscatedID, let image = await downloadProfileImage(personGroup: personGroup, personId: id, obfuscatedID: obfuscatedID, forcedRefresh: forcedRefresh) { + profile.image = image } - }) + print(profile) + self.profileState = .success(data: profile) + } catch { + self.profileState = .failed(error: error) + self.profileHasError = true + } } - func downloadProfileImage(personGroup: String, personId: String, obfuscatedID: String) { - let imageRequest = TUMOnlineAPI.profileImage(personGroup: personGroup, id: personId) - self.sessionManager.request(imageRequest).responseData(completionHandler: { response in - if let imageData = response.value, let image = UIImage(data: imageData) { - self.profileImage = Image(uiImage: image) + func getTuition(forcedRefresh: Bool) async { + if !forcedRefresh { + self.tuitionState = .loading + } + self.tuitionHasError = false + + guard let token = self.model.token else { + self.tuitionState = .failed(error: NetworkingError.unauthorized) + self.tuitionHasError = true + return + } + + do { + guard let tuition: Tuition = try await service.fetch(token: token, forcedRefresh: forcedRefresh) else { + self.tuitionState = .failed(error: TUMOnlineAPIError(message: "No profile found")) return } - - self.sessionManager.request(TUMOnlineAPI.personDetails(identNumber: obfuscatedID)).responseDecodable(of: PersonDetails.self, decoder: XMLDecoder()) { response in - guard let image = response.value?.image else { return } - self.profileImage = Image(uiImage: image) - } - }) + + self.tuitionState = .success(data: tuition) + } catch { + self.tuitionState = .failed(error: error) + self.tuitionHasError = true + } } - func checkTuitionFunc(callback: @escaping (Result) -> Void = {_ in }) { + func downloadProfileImage(personGroup: String, personId: String, obfuscatedID: String, forcedRefresh: Bool = false) async -> Image? { + // Neu machen mit Alamofire (not async) + guard let token = self.model.token else { + return nil + } - let importerTuition = Importer, - XMLDecoder>(endpoint: TUMOnlineAPI.tuitionStatus, dateDecodingStrategy: .formatted(DateFormatter.yyyyMMdd)) + let endpoint = TUMOnlineAPI.profileImage(personGroup: personGroup, id: personId) - DispatchQueue.main.async { - importerTuition.performFetch(handler: { result in - switch result { - case .success(let storage): - self.tuition = storage.rows?.first - if let _ = self.tuition { - callback(.success(true)) - } else { - callback(.failure(CampusOnlineAPI.Error.noPermission)) - } - case .failure(let error): - callback(.failure(error)) - print(error) + if !forcedRefresh, let imageData = TUMOnlineAPI.imageCache.value(forKey: endpoint.basePathsParametersURL), let uiImage = UIImage(data: imageData) { + return Image(uiImage: uiImage) + } else { + var image: Image? + + TUMOnlineAPI.profileImage(personGroup: personGroup, id: personId).asRequest(token: token).responseData { response in + if let imageData = response.value, let uiImage = UIImage(data: imageData) { + TUMOnlineAPI.imageCache.setValue(imageData, forKey: endpoint.basePathsParametersURL, cost: imageData.count) + image = Image(uiImage: uiImage) + return + } + } + + if image != nil { + return image + } + + do { + let personDetails = try await PersonDetailedService().fetch(for: obfuscatedID, token: token, forcedRefresh: forcedRefresh) + guard let uiImage = personDetails.image else { + return nil } - }) + return Image(uiImage: uiImage) + } catch { + print(error) + return nil + } } - + + // let imageRequest = TUMOnlineAPI.profileImage(personGroup: personGroup, id: personId) + // + // self.sessionManager.request(imageRequest).responseData(completionHandler: { response in + // if let imageData = response.value, let image = UIImage(data: imageData) { + // return Image(uiImage: image) + // } + // + // self.sessionManager.request(TUMOnlineAPI.personDetails(identNumber: obfuscatedID)).responseDecodable(of: PersonDetails.self, decoder: XMLDecoder()) { response in + // guard let image = response.value?.image else { return } + // self.profileImage = Image(uiImage: image) + // } + // }) } } -extension ProfileViewModel { - enum ProfileState { - case na - case loading - case success(data: Profile?) - case failed(error: Error) - } - - enum TuitionState { - case na - case loading - case success(data: Tuition?) - case failed(error: Error) - } -} +//@MainActor +//class ProfileViewModel: ObservableObject { +// +// @Published var profile: Profile? +// @Published var tuition: Tuition? +// @Published var profileImage = Image(systemName: "person.crop.circle.fill") +// +// private let sessionManager = Session.defaultSession +// +// var profileState : ProfileState = .na +// var tuitionState : TuitionState = .na +// +// static let defaultProfile = Profile( +// firstname: nil, +// surname: "TUM Student".localized, +// tumId: "TUM ID", +// obfuscatedID: nil, +// obfuscatedIDEmployee: nil, +// obfuscatedIDExtern: nil, +// obfuscatedIDStudent: nil, +// image: nil +// ) +// +// init() { +// self.profile = Self.defaultProfile +// } +// +// init(model: Model) { +// switch model.loginController.credentials { +// case .none, .noTumID: +// self.profile = Self.defaultProfile +// case .tumID(_, _), .tumIDAndKey(_, _, _): +// fetch() +// } +// } +// +// func fetch(callback: @escaping (Result) -> Void = {_ in }) { +// let importer = Importer, XMLDecoder>(endpoint: TUMOnlineAPI.identify) +// importer.performFetch( handler: { result in +// DispatchQueue.main.async { +// switch result { +// case .success(let storage): +// self.profile = storage.rows?.first +// if let personGroup = self.profile?.personGroup, let personId = self.profile?.id, let obfuscatedID = self.profile?.obfuscatedID { +// self.downloadProfileImage(personGroup: personGroup, personId: personId, obfuscatedID: obfuscatedID) +// } +// +// self.checkTuitionFunc() +// +// if let _ = self.profile { +// callback(.success(true)) +// } else { +// callback(.failure(CampusOnlineAPI.Error.noPermission)) +// } +// case .failure(let error): +// callback(.failure(error)) +// print(error) +// } +// } +// }) +// } +// +// func downloadProfileImage(personGroup: String, personId: String, obfuscatedID: String) { +// let imageRequest = TUMOnlineAPI.profileImage(personGroup: personGroup, id: personId) +// self.sessionManager.request(imageRequest).responseData(completionHandler: { response in +// if let imageData = response.value, let image = UIImage(data: imageData) { +// self.profileImage = Image(uiImage: image) +// return +// } +// +// self.sessionManager.request(TUMOnlineAPI.personDetails(identNumber: obfuscatedID)).responseDecodable(of: PersonDetails.self, decoder: XMLDecoder()) { response in +// guard let image = response.value?.image else { return } +// self.profileImage = Image(uiImage: image) +// } +// }) +// } +// +// func checkTuitionFunc(callback: @escaping (Result) -> Void = {_ in }) { +// +// let importerTuition = Importer, +// XMLDecoder>(endpoint: TUMOnlineAPI.tuitionStatus, dateDecodingStrategy: .formatted(DateFormatter.yyyyMMdd)) +// +// DispatchQueue.main.async { +// importerTuition.performFetch(handler: { result in +// switch result { +// case .success(let storage): +// self.tuition = storage.rows?.first +// if let _ = self.tuition { +// callback(.success(true)) +// } else { +// callback(.failure(CampusOnlineAPI.Error.noPermission)) +// } +// case .failure(let error): +// callback(.failure(error)) +// print(error) +// } +// }) +// } +// +// } +//} + +//extension ProfileViewModel { +// enum ProfileState { +// case na +// case loading +// case success(data: Profile?) +// case failed(error: Error) +// } +// +// enum TuitionState { +// case na +// case loading +// case success(data: Tuition?) +// case failed(error: Error) +// } +//} diff --git a/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationAdditionalProperties.swift b/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationAdditionalProperties.swift new file mode 100644 index 00000000..932631ae --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationAdditionalProperties.swift @@ -0,0 +1,15 @@ +// +// NavigationAdditionalProperties.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumNavigationAdditionalProperties: Codable { + let properties: [NavigaTumNavigationProperty] + + enum CodingKeys: String, CodingKey { + case properties = "computed" + } +} diff --git a/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationCoordinates.swift b/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationCoordinates.swift new file mode 100644 index 00000000..37ba002e --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationCoordinates.swift @@ -0,0 +1,17 @@ +// +// NavigationCoordinates.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumNavigationCoordinates: Codable { + let latitude: Double + let longitude: Double + + enum CodingKeys: String, CodingKey { + case latitude = "lat" + case longitude = "lon" + } +} diff --git a/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationMaps.swift b/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationMaps.swift new file mode 100644 index 00000000..d1fc47bf --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/Details/NavigaTumNavigationMaps.swift @@ -0,0 +1,13 @@ +// +// NavigationMaps.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumNavigationMaps: Codable { + let `default`: String + let roomfinder: NavigaTumRoomFinderMaps? + let overlays: NavigaTumOverlaysMaps? +} diff --git a/Campus-iOS/RoomFinder/Model/Details/NavigaTumOverlaysMaps.swift b/Campus-iOS/RoomFinder/Model/Details/NavigaTumOverlaysMaps.swift new file mode 100644 index 00000000..aca65f26 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/Details/NavigaTumOverlaysMaps.swift @@ -0,0 +1,11 @@ +// +// NavigaTumOverlaysMaps.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 07.03.23. +// +import Foundation + +struct NavigaTumOverlaysMaps: Codable { + let available: [NavigaTumOverlayMap] +} diff --git a/Campus-iOS/RoomFinder/Model/Details/NavigaTumRoomFinderMaps.swift b/Campus-iOS/RoomFinder/Model/Details/NavigaTumRoomFinderMaps.swift new file mode 100644 index 00000000..eee574e2 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/Details/NavigaTumRoomFinderMaps.swift @@ -0,0 +1,17 @@ +// +// RoomFinderMaps.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumRoomFinderMaps: Codable { + let available: [NavigaTumRoomFinderMap] + let defaultMapId: String + + enum CodingKeys: String, CodingKey { + case available + case defaultMapId = "default" + } +} diff --git a/Campus-iOS/RoomFinder/Model/NavigaTumNavigationDetails.swift b/Campus-iOS/RoomFinder/Model/NavigaTumNavigationDetails.swift new file mode 100644 index 00000000..a90070f6 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/NavigaTumNavigationDetails.swift @@ -0,0 +1,26 @@ +// +// NavigationDetails.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumNavigationDetails: Codable { + let id: String + let name: String + let parentNames: [String] + let type: String + let typeCommonName: String + let additionalProperties: NavigaTumNavigationAdditionalProperties + let coordinates: NavigaTumNavigationCoordinates + let maps: NavigaTumNavigationMaps + + enum CodingKeys: String, CodingKey { + case id, name, type, maps + case typeCommonName = "type_common_name" + case additionalProperties = "props" + case coordinates = "coords" + case parentNames = "parent_names" + } +} diff --git a/Campus-iOS/RoomFinder/Model/NavigaTumNavigationEntity.swift b/Campus-iOS/RoomFinder/Model/NavigaTumNavigationEntity.swift new file mode 100644 index 00000000..a3d2c502 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/NavigaTumNavigationEntity.swift @@ -0,0 +1,46 @@ +// +// NavigationEntity.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumNavigationEntity: Codable, Identifiable, Equatable { + let id: String + let type: String + let name: String + let subtext: String + let parsedId: String? + + enum CodingKeys: String, CodingKey { + case id, type, name, subtext + case parsedId = "parsed_id" + } + + func getFormattedName() -> String { + guard let parsedId = parsedId else { + return removeHighlight(name) + } + + return removeHighlight(parsedId) + " ➤ " + removeHighlight(name) + } + + func getFormattedSubtext() -> String { + return removeHighlight(subtext) + } + + private func removeHighlight(_ field: String) -> String { + /*** + * Info from NavigaTum swagger: https://editor.swagger.io/?url=https://raw.githubusercontent.com/TUM-Dev/navigatum/main/openapi.yaml + * In future maybe there will be query parameter for this + * "Some fields support highlighting the query terms and it uses DC3 (\x19 or \u{0019}) + * and DC1 (\x17 or \u{0017}) to mark the beginning/end of a highlighted sequence" + */ + field + .replacingOccurrences(of: "\u{0019}", with: "") + .replacingOccurrences(of: "\u{0017}", with: "") + .replacingOccurrences(of: "\\x19", with: "") + .replacingOccurrences(of: "\\x17", with: "") + } +} diff --git a/Campus-iOS/RoomFinder/Model/NavigaTumNavigationProperty.swift b/Campus-iOS/RoomFinder/Model/NavigaTumNavigationProperty.swift new file mode 100644 index 00000000..d3ac3123 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/NavigaTumNavigationProperty.swift @@ -0,0 +1,12 @@ +// +// NavigationProperty.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumNavigationProperty: Codable { + let name: String + let text: String +} diff --git a/Campus-iOS/RoomFinder/Model/NavigaTumOverlayMap.swift b/Campus-iOS/RoomFinder/Model/NavigaTumOverlayMap.swift new file mode 100644 index 00000000..fff98ce7 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/NavigaTumOverlayMap.swift @@ -0,0 +1,19 @@ +// +// NavigaTumOverlayMap.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 07.03.23. +// +import Foundation + +struct NavigaTumOverlayMap: Codable, Identifiable { + let id: Int + let floor: String + let imageUrl: String + let name: String + + enum CodingKeys: String, CodingKey { + case id, floor, name + case imageUrl = "file" + } +} diff --git a/Campus-iOS/RoomFinder/Model/NavigaTumRoomFinderMap.swift b/Campus-iOS/RoomFinder/Model/NavigaTumRoomFinderMap.swift new file mode 100644 index 00000000..2bd82022 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/NavigaTumRoomFinderMap.swift @@ -0,0 +1,23 @@ +// +// RoomFinderMap.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumRoomFinderMap: Codable, Identifiable { + let id: String + let name: String + let imageUrl: String // let baseMapUrl = "https://nav.tum.sexy/cdn/maps/roomfinder/" + let height: Int + let width: Int + let x: Int + let y: Int + let scale: String + + enum CodingKeys: String, CodingKey { + case id, name, height, width, x, y, scale + case imageUrl = "file" + } +} diff --git a/Campus-iOS/RoomFinder/Model/Search/NavigaTumSearchResponse.swift b/Campus-iOS/RoomFinder/Model/Search/NavigaTumSearchResponse.swift new file mode 100644 index 00000000..7fe40682 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/Search/NavigaTumSearchResponse.swift @@ -0,0 +1,16 @@ +// +// NavigaTumSearchResponse.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumSearchResponse: Codable { + let id = UUID() + let sections: [NavigaTumSearchResponseSection] + + enum CodingKeys: CodingKey { + case sections + } +} diff --git a/Campus-iOS/RoomFinder/Model/Search/NavigaTumSearchResponseSection.swift b/Campus-iOS/RoomFinder/Model/Search/NavigaTumSearchResponseSection.swift new file mode 100644 index 00000000..7cfbc175 --- /dev/null +++ b/Campus-iOS/RoomFinder/Model/Search/NavigaTumSearchResponseSection.swift @@ -0,0 +1,17 @@ +// +// NavigaTumSearchResponseSection.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation + +struct NavigaTumSearchResponseSection: Codable { + let type: String + let entries: [NavigaTumNavigationEntity] + + enum CodingKeys: String, CodingKey { + case type = "facet" + case entries + } +} diff --git a/Campus-iOS/RoomFinder/Service/RoomFinderService.swift b/Campus-iOS/RoomFinder/Service/RoomFinderService.swift new file mode 100644 index 00000000..60dfbb32 --- /dev/null +++ b/Campus-iOS/RoomFinder/Service/RoomFinderService.swift @@ -0,0 +1,25 @@ +// +// RoomFinderService.swift +// Campus-iOS +// +// Created by Philipp Zagar on 01.01.23. +// +import Foundation +import Alamofire + +protocol RoomFinderServiceProtocol { + func search(query: String) async throws -> NavigaTumSearchResponse + func details(id: String) async throws -> NavigaTumNavigationDetails +} + +struct RoomFinderService: RoomFinderServiceProtocol { + func search(query: String) async throws -> NavigaTumSearchResponse { + return try await MainAPI.makeRequest(endpoint: NavigaTUMAPI.search(query: query)) + } + + func details(id: String) async throws -> NavigaTumNavigationDetails { + let language = (Locale.current.languageCode == "de") ? "de" : "en" + + return try await MainAPI.makeRequest(endpoint: NavigaTUMAPI.details(id: id, language: language)) + } +} diff --git a/Campus-iOS/RoomFinder/ViewModel/NavigaTumDetailsViewModel.swift b/Campus-iOS/RoomFinder/ViewModel/NavigaTumDetailsViewModel.swift new file mode 100644 index 00000000..54593e42 --- /dev/null +++ b/Campus-iOS/RoomFinder/ViewModel/NavigaTumDetailsViewModel.swift @@ -0,0 +1,33 @@ +// +// NavigaTumDetailsViewModel.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 07.01.23. +// +import Foundation + +import Foundation +import Alamofire +import XMLCoder + +class NavigaTumDetailsViewModel: ObservableObject { + @Published var details: NavigaTumNavigationDetails? + @Published var errorMessage = "" + let id: String + + init(id: String) { + self.id = id + } + + @MainActor func fetchDetails() async { + guard !id.isEmpty else { + self.errorMessage = "Couldn't fetch room details" + return + } + do { + self.details = try await RoomFinderService().details(id: id) + } catch { + self.errorMessage = "Room finder service failed" + } + } +} diff --git a/Campus-iOS/RoomFinder/ViewModel/NavigaTumViewModel.swift b/Campus-iOS/RoomFinder/ViewModel/NavigaTumViewModel.swift new file mode 100644 index 00000000..084dc2f6 --- /dev/null +++ b/Campus-iOS/RoomFinder/ViewModel/NavigaTumViewModel.swift @@ -0,0 +1,27 @@ +// +// NavigaTumViewModel.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 07.01.23. +// +import Foundation +import Alamofire +import XMLCoder + +class NavigaTumViewModel: ObservableObject { + @Published var searchResults: [NavigaTumNavigationEntity] = [] + @Published var errorMessage: String = "" + + @MainActor func fetch(searchString: String) async { + guard !searchString.isEmpty else { + self.errorMessage = "" + return + } + do { + let results = try await RoomFinderService().search(query: searchString) + self.searchResults = results.sections.flatMap(\.entries) + } catch { + self.errorMessage = "Room Service Failed" + } + } +} diff --git a/Campus-iOS/RoomFinder/ViewModel/RoomFinderViewModel.swift b/Campus-iOS/RoomFinder/ViewModel/RoomFinderViewModel.swift index 56a15a38..0fd51698 100644 --- a/Campus-iOS/RoomFinder/ViewModel/RoomFinderViewModel.swift +++ b/Campus-iOS/RoomFinder/ViewModel/RoomFinderViewModel.swift @@ -9,35 +9,41 @@ import Foundation import Alamofire import XMLCoder +@MainActor class RoomFinderViewModel: ObservableObject { @Published var result: [FoundRoom] = [] @Published var errorMessage: String = "" - private let sessionManager = Session.defaultSession - - func fetch(searchString: String) { + func fetch(searchString: String) async { guard !searchString.isEmpty else { - sessionManager.cancelAllRequests() self.errorMessage = "" return } - let endpoint = TUMCabeAPI.roomSearch(query: searchString) - sessionManager.cancelAllRequests() - let request = sessionManager.request(endpoint) - request.responseDecodable(of: [FoundRoom].self, decoder: JSONDecoder()) { [weak self] response in - guard !request.isCancelled else { - // cancelAllRequests doesn't seem to cancel all requests, so better check for this explicitly - return - } - - self?.result = response.value ?? [] - - if let result = self?.result, result.isEmpty { - self?.errorMessage = NSString(format: "Unable to find room".localized as NSString, searchString) as String - } else { - self?.errorMessage = "" - } + do { + self.result = try await MainAPI.makeRequest(endpoint: TUMCabeAPI.roomSearch(query: searchString)) + self.errorMessage = "" + } catch { + print(error) + self.errorMessage = NSString(format: "Unable to find room".localized as NSString, searchString) as String } + +// let endpoint = TUMCabeAPI.roomSearch(query: searchString) +// sessionManager.cancelAllRequests() +// let request = sessionManager.request(endpoint) +// request.responseDecodable(of: [FoundRoom].self, decoder: JSONDecoder()) { [weak self] response in +// guard !request.isCancelled else { +// // cancelAllRequests doesn't seem to cancel all requests, so better check for this explicitly +// return +// } +// +// self?.result = response.value ?? [] +// +// if let result = self?.result, result.isEmpty { +// self?.errorMessage = NSString(format: "Unable to find room".localized as NSString, searchString) as String +// } else { +// self?.errorMessage = "" +// } +// } } } diff --git a/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumDetailsBaseView.swift b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumDetailsBaseView.swift new file mode 100644 index 00000000..8bae73c5 --- /dev/null +++ b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumDetailsBaseView.swift @@ -0,0 +1,58 @@ +// +// NavigaTumDetailsBaseView.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 10.01.23. +// +import SwiftUI + +struct NavigaTumDetailsBaseView: View { + @State var chosenRoom: NavigaTumNavigationDetails + + var body: some View { + GroupBox( + label: GroupBoxLabelView( + iconName: "info.circle.fill", + text: "Room Details".localized + ) + .padding(.bottom, 10) + ) { + VStack(alignment: .leading, spacing: 8) { + ForEach(chosenRoom.additionalProperties.properties, id: \.name) { property in + LectureDetailsBasicInfoRowView(iconName: iconName(property.name), text: property.text) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame( + maxWidth: .infinity, + alignment: .topLeading + ) + } + + func iconName(_ name: String) -> String { + if name == "Roomcode" || name == "Raumkennung" { + return "qrcode.viewfinder" + } else if name == "Architect's name" || name == "Architekten-Name" || name == "Gebäudekennung" || name == "Buildingcode" { + return "building.columns" + } else if name == "Address" || name == "Adresse" { + return "location.fill" + } else if name == "Stockwerk" || name == "Floor" { + return "figure.stairs" + } else if name == "Sitzplätze" || name == "Seats" { + return "person.2.fill" + } else if name == "Anzahl Räume" || name == "Number of rooms"{ + return "door.right.hand.closed" + } else if name == "Anzahl Gebäude" || name == "Number of buildings" { + return "building.2" + } else { + return "questionmark.circle" + } + } +} + +struct NavigaTumDetailsBaseView_Previews: PreviewProvider { + static var previews: some View { + NavigaTumDetailsBaseView(chosenRoom: NavigaTumDetailsView_Previews.chosenRoom) + } +} diff --git a/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumDetailsView.swift b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumDetailsView.swift new file mode 100644 index 00000000..8dd2e0b2 --- /dev/null +++ b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumDetailsView.swift @@ -0,0 +1,66 @@ +// +// NavigaTumDetailsView.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 07.01.23. +// +import SwiftUI + + +struct NavigaTumDetailsView: View { + @StateObject var viewModel: NavigaTumDetailsViewModel + + var body: some View { + ScrollView { + Text(viewModel.errorMessage) + if let chosenRoom = viewModel.details { + VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 5) { + Text(chosenRoom.name) + .font(.title) + .multilineTextAlignment(.leading) + Text(chosenRoom.typeCommonName) + .font(.subheadline) + } + + Spacer().frame(height: 30) + + NavigaTumDetailsBaseView(chosenRoom: chosenRoom) + NavigaTumMapView(chosenRoom: chosenRoom) + // Unsure how to show images/overlays that provide useful information + // NavigaTumMapImagesView(chosenRoom: chosenRoom) + } + .frame( + maxWidth: .infinity, + alignment: .topLeading + ) + .padding(.horizontal) + } + } + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.fetchDetails() + try? await Task.sleep(nanoseconds: 2_000_000_000) + viewModel.details?.additionalProperties.properties.forEach { entry in + print(entry.name) + } + } + } +} + +struct NavigaTumDetailsView_Previews: PreviewProvider { + static let props = [NavigaTumNavigationProperty(name: "Roomcode", text: "5606.EG.036"), NavigaTumNavigationProperty(name: "Architect's name", text: "00.06.036"), NavigaTumNavigationProperty(name: "Address", text: "Boltzmannstr. 3, EG, 85748 Garching b. München")] + static let additionalProperties = NavigaTumNavigationAdditionalProperties(properties: props) + static let coords = NavigaTumNavigationCoordinates(latitude: 48.26217845031176, longitude: 11.668693278105701) + static let available = [NavigaTumRoomFinderMap(id: "rf95", name: "FMI Garching BT06 EG", imageUrl: "rf95.webp", height: 605, width: 318, x: 207, y: 217, scale: "500"), + NavigaTumRoomFinderMap(id: "rf142", name: "FMI Übersicht", imageUrl: "rf142.webp", height: 461, width: 639, x: 443, y: 242, scale: "2000"), + NavigaTumRoomFinderMap(id: "rf80", name: "Lageplan Campus Garching", imageUrl: "rf80.webp", height: 480, width: 676, x: 329, y: 344, scale: "10000"), + NavigaTumRoomFinderMap(id: "rf54", name: "München", imageUrl: "rf54.webp", height: 603, width: 640, x: 444, y: 36, scale: "200000"), + NavigaTumRoomFinderMap(id: "rf156", name: "München und Umgebung", imageUrl: "rf156.webp", height: 515, width: 420, x: 265, y: 167, scale: "400000")] + static let maps = NavigaTumNavigationMaps(default: "rf95", roomfinder: NavigaTumRoomFinderMaps(available: available , defaultMapId: "rf95"), overlays: nil) + static var chosenRoom = NavigaTumNavigationDetails(id: "5606.EG.036", name: "5606.EG.036 (MPI Fachschaftsbüro im MI)", parentNames: ["Standorte", "Garching Forschungszentrum","Fakultät Mathematik & Informatik (FMI oder MI)", "Finger 06 (BT06)"], type: "room", typeCommonName: "Office", additionalProperties: additionalProperties, coordinates: coords, maps: maps) + static var viewmodel = NavigaTumDetailsViewModel(id: "5606.EG.036") + static var previews: some View { + NavigaTumDetailsView(viewModel: viewmodel) + } +} diff --git a/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumListView.swift b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumListView.swift new file mode 100644 index 00000000..769905ea --- /dev/null +++ b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumListView.swift @@ -0,0 +1,47 @@ +// +// NavigaTumListView.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 06.01.23. +// +import SwiftUI + +struct NavigaTumListView: View { + @StateObject var model: Model + @Environment(\.isSearching) private var isSearching + @ObservedObject var viewModel: NavigaTumViewModel + + var body: some View { + List { + ForEach(viewModel.searchResults) { entry in + NavigationLink( + destination: NavigaTumDetailsView(viewModel: NavigaTumDetailsViewModel(id: entry.id)) + ) { + VStack(alignment: .leading) { + HStack { + Text(entry.name) + } + } + } + } + if viewModel.errorMessage != "" { + VStack { + Spacer() + Text(self.viewModel.errorMessage).foregroundColor(.gray) + Spacer() + } + } + } + .onChange(of: isSearching) { newValue in + if !newValue { + self.viewModel.searchResults = [] + } + } + } +} + +struct NavigaTumListView_Previews: PreviewProvider { + static var previews: some View { + NavigaTumListView(model: MockModel(), viewModel: NavigaTumViewModel()) + } +} diff --git a/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumMapImagesView.swift b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumMapImagesView.swift new file mode 100644 index 00000000..d4b9b662 --- /dev/null +++ b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumMapImagesView.swift @@ -0,0 +1,86 @@ +// +// NavigaTumMapImagesView.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 07.02.23. +// +import SwiftUI + +struct NavigaTumMapImagesView: View { + + @State var chosenRoom: NavigaTumNavigationDetails + + var body: some View { + if let roomfinder = chosenRoom.maps.roomfinder { + GroupBox( + label: GroupBoxLabelView( + iconName: "photo.fill.on.rectangle.fill", + text: "Room".localized + ) + ) { + if roomfinder.available.count > 0 { + ScrollView (.horizontal, showsIndicators: true) { + HStack (spacing: 20) { + ForEach(roomfinder.available) { map in + GeometryReader { _ in + actualImage(id: map.imageUrl, isRoomFinderImage: true) + } + .frame(width: 200, height: 200) + Spacer(minLength: 1) + } + if let overlays = chosenRoom.maps.overlays { + ForEach(overlays.available) { map in + GeometryReader { _ in + actualImage(id: map.imageUrl, isRoomFinderImage: false) + } + .frame(width: 400, height: 200) + Spacer(minLength: 1) + } + } + } + } + .padding([.top, .bottom], 15) + } + } + } + } + + func actualImage(id: String, isRoomFinderImage: Bool) -> some View { + let path: String + if isRoomFinderImage { + path = NavigaTUMAPI.images(id: id).basePathsParametersURL + } else { + path = NavigaTUMAPI.overlayImages(id: id).basePathsParametersURL + } + + return AsyncImage(url: URL(string: path)) { image in + switch image { + case .empty: + ProgressView() + case .success(let image): + NavigationLink(destination: ImageFullScreenView(image: image)) { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(minWidth: nil, idealWidth: nil, maxWidth: UIScreen.main.bounds.width, minHeight: nil, idealHeight: nil, maxHeight: UIScreen.main.bounds.height, alignment: .center) + .clipped() + .cornerRadius(10.0) + } + case .failure: + Image(systemName: "photo") + @unknown default: + // Since the AsyncImagePhase enum isn't frozen, + // we need to add this currently unused fallback + // to handle any new cases that might be added + // in the future: + EmptyView() + } + } + } +} + +struct NavigaTumMapImagesView_Previews: PreviewProvider { + static var previews: some View { + NavigaTumMapImagesView(chosenRoom: NavigaTumDetailsView_Previews.chosenRoom) + } +} diff --git a/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumMapView.swift b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumMapView.swift new file mode 100644 index 00000000..1b231d36 --- /dev/null +++ b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumMapView.swift @@ -0,0 +1,51 @@ +// +// NavigaTumMapView.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 10.01.23. +// +import SwiftUI +import MapKit + +struct NavigaTumMapView: View { + @State var chosenRoom: NavigaTumNavigationDetails + + var body: some View { + GroupBox( + label: HStack { + GroupBoxLabelView( + iconName: "map.fill", + text: "Building".localized + ) + .padding(.bottom, 10) + Spacer() +// Button("Open in Maps") { +// // TODO: Update Info.plist to allow maps? +// let url = URL(string: "maps://?saddr&daddr=\(chosenRoom.coordinates.latitude), \(chosenRoom.coordinates.longitude)") +// if UIApplication.shared.canOpenURL(url!) { +// UIApplication.shared.open(url!, options: [:], completionHandler: nil) +// } +// } +// .font(.footnote) + } + ) { + let coords = CLLocationCoordinate2D(latitude: chosenRoom.coordinates.latitude, longitude: chosenRoom.coordinates.longitude) + let mapRegion = MKCoordinateRegion(center: coords , span: MKCoordinateSpan(latitudeDelta: 0.003, longitudeDelta: 0.003)) + Map(coordinateRegion: .constant(mapRegion), showsUserLocation: true, annotationItems: [RoomFinderLocation(coordinate: coords)]) { location in + MapMarker(coordinate: location.coordinate) + } + .frame(height: 360) + .cornerRadius(10) + } + .frame( + maxWidth: .infinity, + alignment: .topLeading + ) + } +} + +struct NavigaTumMapView_Previews: PreviewProvider { + static var previews: some View { + NavigaTumMapView(chosenRoom: NavigaTumDetailsView_Previews.chosenRoom) + } +} diff --git a/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumView.swift b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumView.swift new file mode 100644 index 00000000..57a10a12 --- /dev/null +++ b/Campus-iOS/RoomFinder/ViewNavigaTum/NavigaTumView.swift @@ -0,0 +1,42 @@ +// +// NavigaTumView.swift +// Campus-iOS +// +// Created by Atharva Mathapati on 06.01.23. +// +import SwiftUI + +struct NavigaTumView: View { + @ObservedObject var model: Model + @StateObject var viewModel = NavigaTumViewModel() + @State var searchText = "" + + var body: some View { + NavigaTumListView(model: self.model, viewModel: self.viewModel) + .background(Color(.systemGroupedBackground)) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) + .onChange(of: self.searchText) { searchValue in + if searchValue.count > 3 { + Task { + await search(searchValue) + } + } + } + .task { + if !searchText.isEmpty { + await search(searchText) + } + } + .animation(.default, value: self.viewModel.searchResults) + } + + func search(_ searchValue: String) async { + await self.viewModel.fetch(searchString: searchValue) + } +} + +struct NavigaTumView_Previews: PreviewProvider { + static var previews: some View { + NavigaTumView(model: MockModel()) + } +} diff --git a/Campus-iOS/RoomFinder/Views/RoomFinderDetailsMapImagesView.swift b/Campus-iOS/RoomFinder/Views/RoomFinderDetailsMapImagesView.swift index 899cae2a..fe7fb82c 100644 --- a/Campus-iOS/RoomFinder/Views/RoomFinderDetailsMapImagesView.swift +++ b/Campus-iOS/RoomFinder/Views/RoomFinderDetailsMapImagesView.swift @@ -8,7 +8,7 @@ import SwiftUI struct RoomFinderDetailsMapImagesView: View { - + @StateObject var vm = StudyRoomViewModel() @State var room: FoundRoom var body: some View { @@ -21,13 +21,16 @@ struct RoomFinderDetailsMapImagesView: View { ) { Divider() - - MapImagesHorizontalScrollingView(viewModel: StudyRoomViewModel(studyRoom: StudyRoom(room: room))) + if case .success(let roomImageMapping) = vm.state { + MapImagesHorizontalScrollingView(room: StudyRoom(room: self.room), roomImageMapping: roomImageMapping) + } } .frame( maxWidth: .infinity, alignment: .topLeading - ) + ).task { + await vm.getRoomImageMapping(for: StudyRoom(room: self.room)) + } } } diff --git a/Campus-iOS/RoomFinder/Views/RoomFinderView.swift b/Campus-iOS/RoomFinder/Views/RoomFinderView.swift index 9e1b7239..e365c582 100644 --- a/Campus-iOS/RoomFinder/Views/RoomFinderView.swift +++ b/Campus-iOS/RoomFinder/Views/RoomFinderView.swift @@ -19,20 +19,22 @@ struct RoomFinderView: View { .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) .onChange(of: self.searchText) { searchValue in if searchValue.count > 3 { - search(searchValue) + Task { + await search(searchValue) + } } } - .onAppear { + .task { if !searchText.isEmpty { - search(searchText) + await search(searchText) } } .animation(.default, value: self.viewModel.result) } - func search(_ searchValue: String) { - self.viewModel.fetch(searchString: searchValue) + func search(_ searchValue: String) async { + await self.viewModel.fetch(searchString: searchValue) } } diff --git a/Campus-iOS/TUMSexyComponent/Model/TUMSexyLink.swift b/Campus-iOS/TUMSexyComponent/Model/TUMSexyLink.swift new file mode 100644 index 00000000..2a58b910 --- /dev/null +++ b/Campus-iOS/TUMSexyComponent/Model/TUMSexyLink.swift @@ -0,0 +1,21 @@ +// +// TUMSexyLink.swift +// Campus-iOS +// +// Created by David Lin on 23.01.23. +// + +import Foundation + +struct TUMSexyLink: Decodable, Identifiable { + var id = UUID() + var description: String? + var target: String? + var moodleID: String? + + enum CodingKeys: String, CodingKey { + case description = "description" + case target = "target" + case moodleID = "moodleID" + } +} diff --git a/Campus-iOS/TUMSexyComponent/Screen/TUMSexyScreen.swift b/Campus-iOS/TUMSexyComponent/Screen/TUMSexyScreen.swift new file mode 100644 index 00000000..00ef9090 --- /dev/null +++ b/Campus-iOS/TUMSexyComponent/Screen/TUMSexyScreen.swift @@ -0,0 +1,55 @@ +// +// TUMSexyScreen.swift +// Campus-iOS +// +// Created by David Lin on 23.01.23. +// + +import SwiftUI + +struct TUMSexyScreen: View { + @StateObject var vm = TUMSexyViewModel() + + var body: some View { + Group { + switch vm.state { + case .success(let links): + VStack { + TUMSexyView(links: links) + .refreshable { + await vm.getLinks(forcedRefresh: true) + } + } + case .loading, .na: + LoadingView(text: "Fetching Links") + case .failed(let error): + FailedView( + errorDescription: error.localizedDescription, + retryClosure: vm.getLinks + ) + } + }.task { + await vm.getLinks() + } + .alert( + "Error while fetching Links", + isPresented: $vm.hasError, + presenting: vm.state) { detail in + Button("Retry") { + Task { + await vm.getLinks(forcedRefresh: true) + } + } + + Button("Cancel", role: .cancel) { } + } message: { detail in + if case let .failed(error) = detail { + if let apiError = error as? TUMSexyAPIError { + Text(apiError.errorDescription ?? "TUMSexyAPI Error") + } else { + Text(error.localizedDescription) + } + } + } + } +} diff --git a/Campus-iOS/TUMSexyComponent/Service/TUMSexyService.swift b/Campus-iOS/TUMSexyComponent/Service/TUMSexyService.swift new file mode 100644 index 00000000..ff7cf94b --- /dev/null +++ b/Campus-iOS/TUMSexyComponent/Service/TUMSexyService.swift @@ -0,0 +1,25 @@ +// +// TUMSexyService.swift +// Campus-iOS +// +// Created by David Lin on 23.01.23. +// + +import Foundation + +struct TUMSexyService: ServiceProtocol { + typealias T = TUMSexyLink + + func fetch(forcedRefresh: Bool) async throws -> [TUMSexyLink] { + let response: [String : TUMSexyLink] = try await MainAPI.makeRequest(endpoint: TUMSexyAPI.standard, forcedRefresh: forcedRefresh) + + var links = [TUMSexyLink]() + response.values.forEach { + if $0.target != nil && $0.description != nil { + links.append($0) + } + } + + return links + } +} diff --git a/Campus-iOS/TUMSexyComponent/ViewModel/TUMSexyViewModel.swift b/Campus-iOS/TUMSexyComponent/ViewModel/TUMSexyViewModel.swift index fc46160c..f1abc82d 100644 --- a/Campus-iOS/TUMSexyComponent/ViewModel/TUMSexyViewModel.swift +++ b/Campus-iOS/TUMSexyComponent/ViewModel/TUMSexyViewModel.swift @@ -5,40 +5,58 @@ // Created by Milen Vitanov on 13.01.22. // -import UIKit - -struct TUMSexyLink: Entity { - var description: String? - var target: String? - var moodleID: String? -} +import Foundation +@MainActor class TUMSexyViewModel: ObservableObject { - @Published var links: [TUMSexyLink] = [] - - typealias ImporterType = Importer - private let importer = ImporterType(endpoint: TUMSexyAPI()) - + @Published var state: APIState<[TUMSexyLink]> = .na + @Published var hasError: Bool = false - init() { - // TODO: Get from cache, if not found, then fetch - fetch() - } + let service = TUMSexyService() - func fetch() { - importer.performFetch( handler: { result in - switch result { - case .success(let storage): - var filledLinks = [TUMSexyLink]() - storage.values.forEach() { - if $0.target != nil && $0.description != nil { - filledLinks.append($0) - } - } - self.links = filledLinks - case .failure(let error): - print(error) - } - }) + func getLinks(forcedRefresh: Bool = false) async { + if !forcedRefresh { + self.state = .loading + } + self.hasError = false + + do { + self.state = .success( + data: try await service.fetch(forcedRefresh: forcedRefresh) + ) + } catch { + self.state = .failed(error: error) + self.hasError = true + } } } + +//class TUMSexyViewModel: ObservableObject { +// @Published var links: [TUMSexyLink] = [] +// +// typealias ImporterType = Importer +// private let importer = ImporterType(endpoint: TUMSexyAPI()) +// +// +// init() { +// // TODO: Get from cache, if not found, then fetch +// fetch() +// } +// +// func fetch() { +// importer.performFetch( handler: { result in +// switch result { +// case .success(let storage): +// var filledLinks = [TUMSexyLink]() +// storage.values.forEach() { +// if $0.target != nil && $0.description != nil { +// filledLinks.append($0) +// } +// } +// self.links = filledLinks +// case .failure(let error): +// print(error) +// } +// }) +// } +//} diff --git a/Campus-iOS/TUMSexyComponent/Views/TUMSexyView.swift b/Campus-iOS/TUMSexyComponent/Views/TUMSexyView.swift index 495b1fe2..3899a3b7 100644 --- a/Campus-iOS/TUMSexyComponent/Views/TUMSexyView.swift +++ b/Campus-iOS/TUMSexyComponent/Views/TUMSexyView.swift @@ -9,41 +9,52 @@ import SwiftUI struct TUMSexyView: View { - @ObservedObject var viewModel = TUMSexyViewModel() + let links: [TUMSexyLink] + @AppStorage("useBuildInWebView") var useBuildInWebView: Bool = true @State var isWebViewShowed = false + @State var shownLink: TUMSexyLink? = nil @State private var searchText = "" var body: some View { - List(searchResults, id: \.target) { link in - if useBuildInWebView { - Text(link.description ?? "") - .foregroundColor(.blue) - .onTapGesture { - isWebViewShowed.toggle() + List { + ForEach(searchResults, id: \.id) { link in + Group { + if useBuildInWebView { + Button { + self.shownLink = link + isWebViewShowed.toggle() + } label: { + Text(link.description ?? "") + }.foregroundColor(.blue) + } else { + Link(link.description ?? "", destination: URL(string: link.target ?? "")!) } - .sheet(isPresented: $isWebViewShowed, content: { - SFSafariViewWrapper(url: URL(string: link.target ?? "")!) - }) - } else { - Link(link.description ?? "", destination: URL(string: link.target ?? "")!) + } + +// } } .searchable(text: $searchText) .navigationTitle("Useful Links") + .sheet(item: $shownLink) { link in + if let target = link.target, let url = URL(string: target) { + SFSafariViewWrapper(url: url) + } + } } var searchResults: [TUMSexyLink] { if searchText.isEmpty { - return viewModel.links + return links } else { - return viewModel.links.filter { $0.description!.localizedLowercase.contains(searchText.localizedLowercase) } + return links.filter { $0.description!.localizedLowercase.contains(searchText.localizedLowercase) } } } } -struct TUMSexyView_Previews: PreviewProvider { - static var previews: some View { - TUMSexyView() - } -} +//struct TUMSexyView_Previews: PreviewProvider { +// static var previews: some View { +// TUMSexyView() +// } +//} diff --git a/Campus-iOS/TuitionComponent/View/TuitionView.swift b/Campus-iOS/TuitionComponent/View/TuitionView.swift index ff0742c9..e96eaf47 100644 --- a/Campus-iOS/TuitionComponent/View/TuitionView.swift +++ b/Campus-iOS/TuitionComponent/View/TuitionView.swift @@ -9,20 +9,17 @@ import SwiftUI struct TuitionView: View { - @ObservedObject var viewModel: ProfileViewModel + let tuition: Tuition @State private var data = AppUsageData() var body: some View { List { VStack(alignment: .center) { Spacer(minLength: 0.10 * UIScreen.main.bounds.width) - TuitionCard(tuition: self.viewModel.tuition ?? Tuition.unknown) + TuitionCard(tuition: self.tuition) } .listRowBackground(Color(.systemGroupedBackground)) } - .refreshable { - self.viewModel.checkTuitionFunc() - } .task { data.visitView(view: .tuition) } @@ -32,11 +29,11 @@ struct TuitionView: View { } } -struct TuitionView_Previews: PreviewProvider { - - static var previews: some View { - TuitionView(viewModel: ProfileViewModel()) - TuitionView(viewModel: ProfileViewModel()) - .preferredColorScheme(.dark) - } -} +//struct TuitionView_Previews: PreviewProvider { +// +// static var previews: some View { +// TuitionView(viewModel: ProfileViewModel()) +// TuitionView(viewModel: ProfileViewModel()) +// .preferredColorScheme(.dark) +// } +//} diff --git a/Campus-iOS/TuitionComponent/View/TuitionWidgetView.swift b/Campus-iOS/TuitionComponent/View/TuitionWidgetView.swift index c975a436..8b5fcc40 100644 --- a/Campus-iOS/TuitionComponent/View/TuitionWidgetView.swift +++ b/Campus-iOS/TuitionComponent/View/TuitionWidgetView.swift @@ -14,45 +14,56 @@ struct TuitionWidgetView: View { @State private var scale: CGFloat = 1 @Binding var refresh: Bool - init(size: TuitionWidgetSize, refresh: Binding = .constant(false)) { + let model: Model + + init(model: Model ,size: TuitionWidgetSize, refresh: Binding = .constant(false)) { + self.model = model self._size = State(initialValue: size.value) self.initialSize = size.value self._refresh = refresh } var body: some View { - WidgetFrameView(size: size, content: TuitionWidgetContent(size: size, refresh: $refresh)) + WidgetFrameView(size: size, content: TuitionWidgetContent(viewModel: ProfileViewModel(model: model, service: ProfileService()), size: size, refresh: $refresh)) .expandable(size: $size, initialSize: initialSize, biggestSize: .rectangle, scale: $scale) } } struct TuitionWidgetContent: View { - @StateObject var viewModel = ProfileViewModel() + @StateObject var viewModel: ProfileViewModel let size: WidgetSize @Binding var refresh: Bool var body: some View { Group { - if let tuition = self.viewModel.tuition, - let amount = tuition.amount, - let deadline = tuition.deadline, - let semester = tuition.semesterID { - TuitionWidgetInfoView( - amount: amount, - openAmount: tuition.isOpenAmount, - deadline: deadline, - semester: semester, - big: size != .square - ) - } else { - WidgetLoadingView(text: "Loading tuition fee") + switch viewModel.tuitionState { + case .success(let tuition): + if let amount = tuition.amount, + let deadline = tuition.deadline, + let semester = tuition.semesterID { + TuitionWidgetInfoView( + amount: amount, + openAmount: tuition.isOpenAmount, + deadline: deadline, + semester: semester, + big: size != .square + ) + } + case .loading, .na: + WidgetLoadingView(text: "Loading tuition fee") + case .failed(error: let error): + WidgetLoadingView(text: "Error: \(error)") } } .onChange(of: refresh) { _ in - viewModel.fetch() + Task { + await viewModel.getTuition(forcedRefresh: true) + } } .task { - viewModel.fetch() + Task { + await viewModel.getTuition(forcedRefresh: false) + } } } } diff --git a/Campus-iOS/WidgetComponent/Recommender/Strategy/MLModelDataHandler.swift b/Campus-iOS/WidgetComponent/Recommender/Strategy/MLModelDataHandler.swift index 5f2fc0a6..4e95d739 100644 --- a/Campus-iOS/WidgetComponent/Recommender/Strategy/MLModelDataHandler.swift +++ b/Campus-iOS/WidgetComponent/Recommender/Strategy/MLModelDataHandler.swift @@ -160,7 +160,7 @@ class MLModelDataHandler { private func groupedTimes(from data: [AppUsageDataEntity]) -> [[Date.Time]] { let times = data.compactMap { $0.startTime?.time } - var result = times.groups(where: { Date.Time.minutesBetween($0, $1) <= timeNearbyThreshold }) + let result = times.groups(where: { Date.Time.minutesBetween($0, $1) <= timeNearbyThreshold }) // Remove duplicate groups. return Array(Set(result)) @@ -168,7 +168,7 @@ class MLModelDataHandler { private func groupedDates(from data: [AppUsageDataEntity]) -> [[Date]] { let dates = data.compactMap { $0.startTime } - var result = dates.groups(where: { Calendar.current.dateComponents([.day], from: $0, to: $1).day! <= dateNearbyThreshold }) + let result = dates.groups(where: { Calendar.current.dateComponents([.day], from: $0, to: $1).day! <= dateNearbyThreshold }) // Remove duplicate groups. return Array(Set(result)) diff --git a/Campus-iOS/WidgetComponent/Recommender/WidgetRecommender.swift b/Campus-iOS/WidgetComponent/Recommender/WidgetRecommender.swift index 221c2067..1d06c262 100644 --- a/Campus-iOS/WidgetComponent/Recommender/WidgetRecommender.swift +++ b/Campus-iOS/WidgetComponent/Recommender/WidgetRecommender.swift @@ -40,7 +40,7 @@ class WidgetRecommender: ObservableObject { case .calendar: CalendarWidgetView(model: model, size: size, refresh: refresh) case .tuition: - TuitionWidgetView(size: TuitionWidgetSize.from(widgetSize: size), refresh: refresh) + TuitionWidgetView(model: model, size: TuitionWidgetSize.from(widgetSize: size), refresh: refresh) case .grades: GradeWidgetView(model: model, size: size, refresh: refresh) } diff --git a/Campus-iOS/WidgetComponent/Screen/WidgetScreen.swift b/Campus-iOS/WidgetComponent/Screen/WidgetScreen.swift index 9ded3a0c..ae3a86f4 100644 --- a/Campus-iOS/WidgetComponent/Screen/WidgetScreen.swift +++ b/Campus-iOS/WidgetComponent/Screen/WidgetScreen.swift @@ -11,13 +11,16 @@ import MapKit struct WidgetScreen: View { @StateObject private var recommender: WidgetRecommender - @StateObject var model: Model = Model() + var model: Model + var profileViewModel: ProfileViewModel @State private var refresh = false - @State private var widgetTitle = String() + @State private var widgetTitle = "" private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() init(model: Model) { self._recommender = StateObject(wrappedValue: WidgetRecommender(strategy: SpatioTemporalStrategy(), model: model)) + self.model = model + self.profileViewModel = ProfileViewModel(model: model, service: ProfileService()) } var body: some View { @@ -29,7 +32,7 @@ struct WidgetScreen: View { case .success: ScrollView { self.generateContent( - views: recommender.recommendations.map { recommender.getWidget(for: $0.widget, size: $0.size(), refresh: $refresh) } + views: recommender.recommendations.map { recommender.getWidget(for: $0.widget, size: $0.size(), refresh: $refresh) }, widgetTitle: self.widgetTitle ) .frame(maxWidth: .infinity) } @@ -41,8 +44,13 @@ struct WidgetScreen: View { } .task { try? await recommender.fetchRecommendations() - if let firstName = model.profile.profile?.firstname { widgetTitle = "Hi, " + firstName } - else { widgetTitle = "Welcome"} + await profileViewModel.getProfile(forcedRefresh: false) + + if case .success(let profile) = profileViewModel.profileState, let firstname = profile.firstname { + self.widgetTitle = "Hi, " + firstname + } else { + self.widgetTitle = "Welcome" + } } .onReceive(timer) { _ in refresh.toggle() @@ -50,15 +58,19 @@ struct WidgetScreen: View { } // Source: https://stackoverflow.com/a/58876712 - private func generateContent(views: [T]) -> some View { + private func generateContent(views: [T], widgetTitle: String) -> some View { var width = CGFloat.zero var height = CGFloat.zero var previousHeight = CGFloat.zero let maxWidth = WidgetSize.bigSquare.dimensions.0 + 2 * WidgetSize.padding - if let firstName = model.profile.profile?.firstname { widgetTitle = "Hi, " + firstName } - else { widgetTitle = "Welcome"} + if case let .success(profile) = profileViewModel.profileState, + let firstName = profile.firstname { + self.widgetTitle = "Hi, " + firstName + } else { + self.widgetTitle = "Welcome" + } return ZStack(alignment: .topLeading) { ForEach(0..