diff --git a/VITTY/.gitignore b/VITTY/.gitignore new file mode 100644 index 0000000..1e88b58 --- /dev/null +++ b/VITTY/.gitignore @@ -0,0 +1,2 @@ +# Google Services configuration file +GoogleService-Info.plist diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 4fdd5ae..be0b60d 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; 4B1BDBCC2E1396B1008C2DE9 /* ToolTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */; }; + 4B2D1F0F2E26060C002AFD25 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B2D1F0E2E26060C002AFD25 /* GoogleService-Info.plist */; }; 4B2D648F2E20BA6300412CB7 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */; }; 4B2D64902E20BA6300412CB7 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */; }; 4B2D64922E20C1AC00412CB7 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */; }; @@ -192,6 +193,7 @@ 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = ""; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = ""; }; 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolTip.swift; sourceTree = ""; }; + 4B2D1F0E2E26060C002AFD25 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleRequests.swift; sourceTree = ""; }; @@ -406,6 +408,7 @@ 4BC853C52DF6F71B0092B2E2 /* VittyWidgetExtension.entitlements */, 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */, 52EE849D2CB9CD1F00CD864C /* GoogleService-Info.plist */, + 4B2D1F0E2E26060C002AFD25 /* GoogleService-Info.plist */, 5251A7FF2B46E3C000D44CFE /* .swift-format */, 314A408E27383BEC0058082F /* VITTYApp.swift */, 314A409027383BEC0058082F /* ContentView.swift */, @@ -1097,6 +1100,7 @@ 31128CFE2772F57E0084C9EA /* Poppins-MediumItalic.ttf in Resources */, 31128CFD2772F57E0084C9EA /* Poppins-SemiBold.ttf in Resources */, 31128CF92772F57E0084C9EA /* Poppins-Medium.ttf in Resources */, + 4B2D1F0F2E26060C002AFD25 /* GoogleService-Info.plist in Resources */, 31128CFA2772F57E0084C9EA /* Poppins-SemiBoldItalic.ttf in Resources */, 31128CFC2772F57E0084C9EA /* Poppins-Regular.ttf in Resources */, 52EE849E2CB9CD1F00CD864C /* GoogleService-Info.plist in Resources */, diff --git a/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift b/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift index 79a5a25..03e2df0 100644 --- a/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift +++ b/VITTY/VITTY/EmptyClassroom/View/EmptyClass.swift @@ -1,3 +1,10 @@ +// +// EmptyClassAPIService.swift +// VITTY +// +// Created by Rujin Devkota on 2/27/25. +// + import SwiftUI struct EmptyClassRoom: View { @@ -88,83 +95,172 @@ struct EmptyClassRoom: View { private var contentView: some View { Group { if viewModel.isLoading { - VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.2) - .tint(.white) - Text("Loading classrooms...") - .foregroundColor(.white.opacity(0.8)) - .font(.subheadline) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() + loadingView + } else if viewModel.isGenerating { + generatingView } else if let errorMessage = viewModel.errorMessage { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 40)) - .foregroundColor(.red.opacity(0.8)) - Text("Error") - .font(.headline) - .foregroundColor(.white) - Text(errorMessage) - .foregroundColor(.red.opacity(0.8)) - .multilineTextAlignment(.center) - .font(.subheadline) - Button("Retry") { - Task { - await viewModel.fetchEmptyClassrooms(slot: selectedSlot, authToken: authViewModel.loggedInBackendUser?.token ?? "") - } - } + errorView(errorMessage) + } else if viewModel.emptyClassrooms.isEmpty { + emptyStateView + } else if filteredClassrooms.isEmpty && !searchText.isEmpty { + noResultsView + } else { + classroomsGrid + } + } + } + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.2) + .tint(.white) + Text("Loading classrooms...") + .foregroundColor(.white.opacity(0.8)) + .font(.subheadline) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private var generatingView: some View { + VStack(spacing: 24) { + // Animated hourglass icon + Image(systemName: "hourglass") + .font(.system(size: 50)) + .foregroundColor(.blue.opacity(0.7)) + .rotationEffect(.degrees(viewModel.isGenerating ? 180 : 0)) + .animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: viewModel.isGenerating) + + VStack(spacing: 16) { + Text("Preparing Your Data") + .font(.title2) + .fontWeight(.semibold) .foregroundColor(.white) + + Text("Our system is currently generating the latest classroom information for slot \(selectedSlot).") + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .font(.body) .padding(.horizontal, 20) - .padding(.vertical, 8) - .background(Color.blue.opacity(0.7)) - .cornerRadius(8) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } else if viewModel.emptyClassrooms.isEmpty { - VStack(spacing: 16) { - Image(systemName: "building.2") - .font(.system(size: 40)) + + VStack(spacing: 8) { + Text("This process may take a few moments") + .foregroundColor(.blue.opacity(0.8)) + .font(.subheadline) + + Text("Please try reloading in a moment") .foregroundColor(.white.opacity(0.6)) - Text("No Classrooms Available") - .font(.headline) - .foregroundColor(.white) - Text("There are no empty classrooms for slot \(selectedSlot) at this time.") - .foregroundColor(.white.opacity(0.8)) - .multilineTextAlignment(.center) + .font(.caption) + } + .padding(.top, 8) + } + + Button(action: { + viewModel.reload(slot: selectedSlot, authToken: authViewModel.loggedInBackendUser?.token ?? "") + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .medium)) + Text("Reload") .font(.subheadline) + .fontWeight(.medium) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } else if filteredClassrooms.isEmpty && !searchText.isEmpty { - VStack(spacing: 16) { - Image(systemName: "magnifyingglass") - .font(.system(size: 40)) - .foregroundColor(.white.opacity(0.6)) - Text("No Results Found") - .font(.headline) - .foregroundColor(.white) - Text("No classrooms match '\(searchText)'") - .foregroundColor(.white.opacity(0.8)) - .multilineTextAlignment(.center) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.blue.opacity(0.7)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + ) + } + .padding(.top, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private func errorView(_ errorMessage: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 40)) + .foregroundColor(.orange.opacity(0.8)) + + Text("Oops!") + .font(.headline) + .foregroundColor(.white) + + Text(errorMessage) + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .font(.subheadline) + .padding(.horizontal) + + Button(action: { + viewModel.reload(slot: selectedSlot, authToken: authViewModel.loggedInBackendUser?.token ?? "") + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .medium)) + Text("Reload") .font(.subheadline) - Button("Clear Search") { - searchText = "" - } - .foregroundColor(.white) - .padding(.horizontal, 20) - .padding(.vertical, 8) - .background(Color.blue.opacity(0.7)) - .cornerRadius(8) + .fontWeight(.medium) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } else { - classroomsGrid + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.orange.opacity(0.7)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + Image(systemName: "building.2") + .font(.system(size: 40)) + .foregroundColor(.white.opacity(0.6)) + Text("No Classrooms Available") + .font(.headline) + .foregroundColor(.white) + Text("There are no empty classrooms for slot \(selectedSlot) at this time.") + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .font(.subheadline) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private var noResultsView: some View { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 40)) + .foregroundColor(.white.opacity(0.6)) + Text("No Results Found") + .font(.headline) + .foregroundColor(.white) + Text("No classrooms match '\(searchText)'") + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .font(.subheadline) + Button("Clear Search") { + searchText = "" } + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color.blue.opacity(0.7)) + .cornerRadius(8) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() } private var classroomsGrid: some View { diff --git a/VITTY/VITTY/EmptyClassroom/ViewModel/EmptyClassRoomViewModel.swift b/VITTY/VITTY/EmptyClassroom/ViewModel/EmptyClassRoomViewModel.swift index bc138d4..d9059d1 100644 --- a/VITTY/VITTY/EmptyClassroom/ViewModel/EmptyClassRoomViewModel.swift +++ b/VITTY/VITTY/EmptyClassroom/ViewModel/EmptyClassRoomViewModel.swift @@ -12,6 +12,7 @@ class EmptyClassroomViewModel: ObservableObject { @Published var emptyClassrooms: [String] = [] @Published var isLoading: Bool = false @Published var errorMessage: String? + @Published var isGenerating: Bool = false private var currentSlot: String = "A1" func fetchEmptyClassrooms(slot: String, authToken: String) async { @@ -19,16 +20,44 @@ class EmptyClassroomViewModel: ObservableObject { isLoading = true errorMessage = nil + isGenerating = false currentSlot = slot do { let classrooms = try await EmptyClassRoomAPIService.shared.getEmptyClassrooms(slot: slot, authToken: authToken) emptyClassrooms = classrooms + isGenerating = false } catch { - errorMessage = "Failed to load empty classrooms: \(error.localizedDescription)" + handleError(error) } isLoading = false } + + private func handleError(_ error: Error) { + let errorDescription = error.localizedDescription.lowercased() + + // Check if this is a server error (likely generation in progress) + if let nsError = error as NSError?, + nsError.code >= 500 || + errorDescription.contains("contact vitty support") || + errorDescription.contains("failed to parse error response") || + errorDescription.contains("generating") || + errorDescription.contains("server error") { + + // Show generation state instead of error + isGenerating = true + errorMessage = nil + } else { + // Only show actual errors for client-side issues + isGenerating = false + errorMessage = "Failed to load empty classrooms: \(error.localizedDescription)" + } + } + + func reload(slot: String, authToken: String) { + Task { + await fetchEmptyClassrooms(slot: slot, authToken: authToken) + } + } } -