diff --git a/PennMobile/Home/HomeView.swift b/PennMobile/Home/HomeView.swift index 13ca97310..b4f02b0e6 100644 --- a/PennMobile/Home/HomeView.swift +++ b/PennMobile/Home/HomeView.swift @@ -24,98 +24,63 @@ struct HomeView: View { var body: some View { Group { - switch viewModel.data { - case .none: - ProgressView() - .controlSize(.large) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - case .some(.success(let data)): - NavigationStack { - ScrollView { - TimelineView(.periodic(from: Date.midnightYesterday, by: 24 * 60 * 60)) { context in - VStack(spacing: 0) { - VStack { - Text("\(context.date, format: dateFormatStyle)") - .font(.largeTitle) - .fontWeight(.bold) - .background(GeometryReader { geometry in - let minY = geometry.frame(in: .global).minY - Color.clear.onChange(of: minY) { minY in - showTitle = minY <= 16 - } - }) - - if let splashText { - HStack(alignment: .top) { - Text(splashText) - .fontWeight(.medium) - .opacity(0.7) + NavigationStack { + ScrollView { + TimelineView(.periodic(from: Date.midnightYesterday, by: 24 * 60 * 60)) { context in + VStack(spacing: 0) { + VStack { + Text("\(context.date, format: dateFormatStyle)") + .font(.largeTitle) + .fontWeight(.bold) + .background(GeometryReader { geometry in + let minY = geometry.frame(in: .global).minY + Color.clear.onChange(of: minY) { minY in + showTitle = minY <= 16 } + }) + + if let splashText { + HStack(alignment: .top) { + Text(splashText) + .fontWeight(.medium) + .opacity(0.7) } } - .offset(y: -16) - .padding(.bottom) - .multilineTextAlignment(.center) - - data.content(for: context.date) - .frame(maxWidth: 480) - .frame(maxWidth: .infinity) } + .offset(y: -16) .padding(.bottom) - // Hack for forcing the navbar to always render - .navigationTitle(Text(showTitle ? "\(context.date, format: dateFormatStyle)" : "\u{200C}")) - .navigationBarTitleDisplayMode(.inline) - #if DEBUG - .toolbar { - if (!(viewModel is MockHomeViewModel)) { - ToolbarItem(placement: .primaryAction) { - NavigationLink("Debug") { - HomeView() - } + .multilineTextAlignment(.center) + + viewModel.data.content(for: context.date) + .frame(maxWidth: 480) + .frame(maxWidth: .infinity) + } + .padding(.bottom) + // Hack for forcing the navbar to always render + .navigationTitle(Text(showTitle ? "\(context.date, format: dateFormatStyle)" : "\u{200C}")) + .navigationBarTitleDisplayMode(.inline) +#if DEBUG + .toolbar { + if (!(viewModel is MockHomeViewModel)) { + ToolbarItem(placement: .primaryAction) { + NavigationLink("Debug") { + HomeView() } } } - #endif - .onAppear { - chooseSplashText(data: data, for: context.date) - } - .onChange(of: context.date) { date in - chooseSplashText(data: data, for: date) - } } - } - .refreshable { - try? await viewModel.fetchData(force: true) - } - } - case .some(.failure(let error)): - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle.fill") - .resizable() - .scaledToFit() - .frame(height: 50) - .foregroundStyle(.red) - - Text("Something went wrong.") - .font(.headline) - .fontWeight(.medium) - - Text(error.localizedDescription) - .font(.caption) - .frame(maxWidth: 400) - - Button("Retry") { - Task { - viewModel.clearData() - try? await viewModel.fetchData(force: true) +#endif + .onAppear { + chooseSplashText(data: viewModel.data, for: context.date) + } + .onChange(of: context.date) { date in + chooseSplashText(data: viewModel.data, for: date) } } - .buttonStyle(BorderedProminentButtonStyle()) } - .multilineTextAlignment(.center) - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) + .refreshable { + try? await viewModel.fetchData(force: true) + } } }.onAppear { Task { diff --git a/PennMobile/Home/HomeViewModel.swift b/PennMobile/Home/HomeViewModel.swift index 86ddd214c..9de2e3b49 100644 --- a/PennMobile/Home/HomeViewModel.swift +++ b/PennMobile/Home/HomeViewModel.swift @@ -11,10 +11,10 @@ import SwiftUI struct HomeViewData { var firstName: String? - var polls: [PollQuestion] - var posts: [Post] - var newsArticles: [NewsArticle] - var events: [CalendarEvent] + var polls: Result<[PollQuestion], Error>? + var posts: Result<[Post], Error>? + var newsArticles: Result<[NewsArticle], Error>? + var events: [CalendarEvent] = [] var onPollResponse: ((Int, Int) -> Void)? @@ -27,18 +27,50 @@ struct HomeViewData { } } + func sectionContent(_ result: Result?, description: LocalizedStringResource, @ViewBuilder content: (Item) -> Content) -> some View { + return Group { + switch result { + case .some(.success(let item)): + content(item) + case .some(.failure): + HomeCardView { + Text("Couldn't load \(description)") + .padding() + } + case nil: + SwiftUI.EmptyView() + } + } + } + + var shouldShowLoadingSpinner: Bool { + newsArticles == nil + } + func content(for date: Date) -> some View { VStack(spacing: 16) { - ForEach(polls) { poll in - PollView(poll: poll, onResponse: onPollResponse.map { callback in { callback(poll.id, $0) } }) + if shouldShowLoadingSpinner { + ProgressView() + .controlSize(.large) + .padding(.bottom) } - ForEach(posts) { post in - PostCardView(post: post) + if case .some(.success(let polls)) = polls { + ForEach(polls) { poll in + PollView(poll: poll, onResponse: onPollResponse.map { callback in { callback(poll.id, $0) } }) + } } - ForEach(newsArticles) { article in - NewsCardView(article: article) + sectionContent(posts, description: "posts") { posts in + ForEach(posts) { post in + PostCardView(post: post) + } + } + + sectionContent(newsArticles, description: "article") { articles in + ForEach(articles) { article in + NewsCardView(article: article) + } } if !events.isEmpty { @@ -49,13 +81,14 @@ struct HomeViewData { static let mock = HomeViewData( firstName: "TEST", - polls: [.mock], - posts: [ + polls: .success([.mock]), + posts: .success([ Post(id: 1, title: "Congratulations!", subtitle: "You are our lucky winner", postUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", imageUrl: "https://www.nps.gov/common/uploads/cropped_image/primary/D1721D51-A497-281A-72B8C06573F9327A.jpg?width=1600&quality=90&mode=crop", createdDate: Date(), startDate: Date.midnightYesterday, expireDate: Date.midnightToday, source: "Totally Legit Source") - ], - newsArticles: [ + ]), + newsArticles: .success([ NewsArticle(data: .init(labsArticle: .init(slug: "a", headline: "AAAAAAAA", abstract: "AAAAAAAAAAAAAA", published_at: "1d ago", authors: [], dominantMedia: .init(imageUrl: "https://www.upenn.edu/sites/default/files/styles/default/public/2020-11/p-100297-master-v1-075a-1600x800.jpg?itok=apAkEATX", authors: []), tag: "", content: "AAAAAAAAAAAA"))) - ], events: [ + ]), + events: [ .init(event: "Test Event 1", date: "October 21"), .init(event: "Test Event 2", date: "October 27-29 (Other University)"), .init(event: "Test Event With A Really Long Name", date: "Really Really Really Long Date Wow It's So Long!") @@ -63,7 +96,7 @@ struct HomeViewData { ) mutating func markPollResponse(questionId: Int, optionId: Int) { - if let pollIndex = polls.firstIndex(where: { $0.id == questionId }) { + if case .some(.success(var polls)) = polls, let pollIndex = polls.firstIndex(where: { $0.id == questionId }) { var poll = polls[pollIndex] poll.optionChosenId = optionId @@ -72,24 +105,24 @@ struct HomeViewData { } polls[pollIndex] = poll + self.polls = .success(polls) } } } -protocol HomeViewModel: ObservableObject { - var data: Result? { get } - func clearData() +@MainActor protocol HomeViewModel: ObservableObject { + var data: HomeViewData { get } func fetchData(force: Bool) async throws } -class StandardHomeViewModel: HomeViewModel { - @Published private(set) var data: Result? +@MainActor class StandardHomeViewModel: HomeViewModel { + @Published private(set) var data = HomeViewData() var isFetching = false var lastFetch: Date? var account: Account? func clearData() { - data = nil + data = HomeViewData() } func fetchData(force: Bool) async throws { @@ -100,7 +133,7 @@ class StandardHomeViewModel: HomeViewModel { return } - if case .success = data, let lastFetch, lastFetch.timeIntervalSinceNow > -60 * 60, account == self.account { + if let lastFetch, lastFetch.timeIntervalSinceNow > -60 * 60, account == self.account { return } } @@ -111,61 +144,90 @@ class StandardHomeViewModel: HomeViewModel { print("Fetching HomeViewModel (force = \(force), isFetching = \(isFetching))") - async let polls = (try? PollsNetworkManager.instance.getActivePolls().get()) ?? [] + data.firstName = account?.firstName + + async let pollsTask = Task { + let polls = await PollsNetworkManager.instance.getActivePolls().mapError { $0 as Error } + await MainActor.run { + data.polls = polls + } + } - async let posts = withCheckedThrowingContinuation { continuation in + async let postsTask: () = withCheckedContinuation { continuation in OAuth2NetworkManager.instance.getAccessToken { token in - guard let token = token else { continuation.resume(returning: [Post]()); return } + guard let token = token else { + DispatchQueue.main.async { + self.data.posts = .success([]) + } + continuation.resume() + return + } let url = URLRequest(url: URL(string: "https://pennmobile.org/api/portal/posts/browse/")!, accessToken: token) let task = URLSession.shared.dataTask(with: url) { data, _, _ in - guard let data = data else { continuation.resume(returning: [Post]()); return } + guard let data = data else { + DispatchQueue.main.async { + self.data.posts = .success([]) + } + continuation.resume() + return + } do { let posts = try JSONDecoder(keyDecodingStrategy: .convertFromSnakeCase, dateDecodingStrategy: .iso8601).decode([Post].self, from: data) - continuation.resume(returning: posts) - } catch let error { - continuation.resume(throwing: error) + DispatchQueue.main.async { + self.data.posts = .success(posts) + } + } catch { + DispatchQueue.main.async { + self.data.posts = .failure(error) + } } + + continuation.resume() } task.resume() } } - async let newsArticles = Task { - let (data, _) = try await URLSession.shared.data(from: URL(string: "https://labs-graphql-295919.ue.r.appspot.com/graphql?query=%7BlabsArticle%7Bslug,headline,abstract,published_at,authors%7Bname%7D,dominantMedia%7BimageUrl,authors%7Bname%7D%7D,tag,content%7D%7D")!) + async let newsArticlesTask = Task { + let newsArticles: Result<[NewsArticle], Error> + + do { + let (data, _) = try await URLSession.shared.data(from: URL(string: "https://labs-graphql-295919.ue.r.appspot.com/graphql?query=%7BlabsArticle%7Bslug,headline,abstract,published_at,authors%7Bname%7D,dominantMedia%7BimageUrl,authors%7Bname%7D%7D,tag,content%7D%7D")!) + + newsArticles = try .success([JSONDecoder().decode(NewsArticle.self, from: data)]) + } catch { + newsArticles = .failure(error) + } - return try [JSONDecoder().decode(NewsArticle.self, from: data)] - }.value + await MainActor.run { + data.newsArticles = newsArticles + } + } - async let events = withCheckedContinuation { continuation in + async let eventsTask: () = withCheckedContinuation { continuation in CalendarAPI.instance.fetchCalendar { events in - continuation.resume(returning: events ?? []) + DispatchQueue.main.async { + self.data.events = events ?? [] + } + + continuation.resume() } } - data = .success(HomeViewData( - firstName: Account.getAccount()?.firstName, - polls: await polls, - posts: (try? await posts) ?? [], - newsArticles: (try? await newsArticles) ?? [], - events: await events, - onPollResponse: { [weak self] question, option in - self?.respondToPoll(questionId: question, optionId: option) - } - )) + _ = await pollsTask + _ = await postsTask + _ = await newsArticlesTask + _ = await eventsTask lastFetch = Date() } func respondToPoll(questionId: Int, optionId: Int) { - guard case .success(var data) = data else { return } - data.markPollResponse(questionId: questionId, optionId: optionId) - self.data = .success(data) - Task { await PollsNetworkManager.instance.answerPoll(withId: PollsNetworkManager.id, response: optionId) } @@ -173,13 +235,6 @@ class StandardHomeViewModel: HomeViewModel { } class MockHomeViewModel: HomeViewModel { - @Published private(set) var data: Result? - - func clearData() { - data = nil - } - - func fetchData(force: Bool) async throws { - self.data = .success(.mock) - } + @Published private(set) var data = HomeViewData.mock + func fetchData(force: Bool) async throws {} }