diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index ee29412..e8418ff 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -149,7 +149,7 @@ struct ConnectPage: View { selectedTab = 0 } communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: authViewModel.loggedInBackendUser?.token ?? "", loading: true ) @@ -177,7 +177,7 @@ struct ConnectPage: View { if communityPageViewModel.circles.isEmpty || !hasLoadedInitialData { communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: authViewModel.loggedInBackendUser?.token ?? "", loading: shouldShowLoading ) @@ -185,7 +185,7 @@ struct ConnectPage: View { if communityPageViewModel.circleRequests.isEmpty || !hasLoadedInitialData { friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)requests/")!, + from: URL(string: "\(APIConstants.base_urlv3)requests/")!, authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: shouldShowLoading ) diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 44b2b01..844ff25 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -48,7 +48,7 @@ class CampusUpdateService { } } -// MARK: - Campus Selection Dialog + import SwiftUI struct CampusSelectionDialog: View { @@ -59,7 +59,6 @@ struct CampusSelectionDialog: View { @State private var showError: Bool = false @State private var errorMessage: String = "" - private let campusOptions = [ ("VIT Chennai", "chennai"), ("VIT Vellore", "vellore"), @@ -68,22 +67,26 @@ struct CampusSelectionDialog: View { var body: some View { ZStack { - Color.black.opacity(0.4) + Color.black.opacity(0.5) .ignoresSafeArea() + .onTapGesture { + + } - VStack(spacing: 20) { - + VStack(spacing: 24) { + // Header Section VStack(spacing: 8) { Text("Select Your Campus") .font(.custom("Poppins-Bold", size: 20)) - .foregroundColor(.primary) + .foregroundColor(.white) Text("Please select your campus to continue") .font(.custom("Poppins-Regular", size: 14)) - .foregroundColor(.secondary) + .foregroundColor(.white.opacity(0.7)) + .multilineTextAlignment(.center) } - + VStack(spacing: 12) { ForEach(campusOptions, id: \.0) { campus in Button(action: { @@ -92,23 +95,31 @@ struct CampusSelectionDialog: View { HStack { Text(campus.0) .font(.custom("Poppins-Medium", size: 16)) - .foregroundColor(.primary) + .foregroundColor(.white) Spacer() if selectedCampus == campus.1 { Image(systemName: "checkmark.circle.fill") - .foregroundColor(.blue) + .foregroundColor(Color("Accent")) + .font(.system(size: 20)) } } .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.vertical, 14) .background( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 12) .fill(selectedCampus == campus.1 ? - Color.blue.opacity(0.1) : Color.gray.opacity(0.1)) + Color("Accent").opacity(0.15) : Color("Secondary").opacity(0.6)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(selectedCampus == campus.1 ? + Color("Accent") : Color.clear, lineWidth: 1) ) } + .buttonStyle(PlainButtonStyle()) + .disabled(isUpdating) } } @@ -118,39 +129,61 @@ struct CampusSelectionDialog: View { .font(.custom("Poppins-Regular", size: 12)) .foregroundColor(.red) .padding(.horizontal) + .multilineTextAlignment(.center) } - - HStack(spacing: 16) { - Button("Update") { + + if isUpdating { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) + } + + + HStack(spacing: 12) { + + Button("Skip for now") { + isPresented = false + } + .disabled(isUpdating) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color("Secondary").opacity(0.8)) + ) + .foregroundColor(.white.opacity(0.8)) + .font(.custom("Poppins-Medium", size: 14)) + + + Button("Update Campus") { Task { await updateCampus() } } .disabled(selectedCampus.isEmpty || isUpdating) - .padding(.horizontal, 32) + .padding(.horizontal, 20) .padding(.vertical, 12) .background( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 10) .fill(selectedCampus.isEmpty || isUpdating ? - Color.gray.opacity(0.3) : Color.blue) + Color.gray.opacity(0.3) : Color("Accent")) ) .foregroundColor(.white) - .font(.custom("Poppins-Medium", size: 16)) - } - - if isUpdating { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + .font(.custom("Poppins-Medium", size: 14)) } + .padding(.top, 8) } .padding(24) .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(UIColor.systemBackground)) + RoundedRectangle(cornerRadius: 20) + .fill(Color("Background")) + .shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 10) ) - .padding(.horizontal, 40) + .padding(.horizontal, 32) } + .animation(.easeInOut(duration: 0.3), value: isUpdating) + .animation(.easeInOut(duration: 0.3), value: showError) } private func updateCampus() async { @@ -168,7 +201,6 @@ struct CampusSelectionDialog: View { token: token ) - DispatchQueue.main.async { authViewModel.updateUserCampus(selectedCampus) isPresented = false diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 1e122d3..1409ee0 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -8,7 +8,7 @@ struct SettingsView: View { @Query private var timeTables: [TimeTable] @StateObject private var viewModel = SettingsViewModel() - @StateObject private var settingsTipManager = SettingsTipManager() + @StateObject private var settingsTipManager = SettingsTipManager() @State private var showDaySelection = false @State private var selectedDay: String? = nil @@ -160,8 +160,35 @@ struct SettingsView: View { } SettingsSectionView(title: "About") { - AboutLinkView(image: "github-icon", title: "GitHub Repository", url: URL(string: "https://github.com/GDGVIT/vitty-ios")) - AboutLinkView(image: "gdsc-logo", title: "GDSC VIT", url: URL(string: "https://dscvit.com/")) + VStack(alignment: .leading, spacing: 12) { + AboutLinkView(image: "github-icon", title: "GitHub Repository", url: URL(string: "https://github.com/GDGVIT/vitty-ios")) + AboutLinkView(image: "gdsc-logo", title: "GDSC VIT", url: URL(string: "https://dscvit.com/")) + + // Support Email + HStack(spacing: 12) { + Image(systemName: "envelope.fill") + .foregroundColor(.white) + .frame(width: 30, height: 30) + + VStack(alignment: .leading, spacing: 4) { + Text("Support") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + Text("dscvit.vitty@gmail.com") + .font(.system(size: 12)) + .foregroundColor(.gray.opacity(0.8)) + } + + Spacer() + } + .padding(.vertical, 6) + .onTapGesture { + if let url = URL(string: "mailto:dscvit.vitty@gmail.com") { + UIApplication.shared.open(url) + } + } + } } } .scrollContentBackground(.hidden) @@ -842,6 +869,7 @@ struct DeleteUserAlert: View { } } + struct SyncAlert: View { let message: String let isSuccess: Bool diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index a19c746..bcaf102 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -246,21 +246,227 @@ extension TimeTable { } private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + + if let formattedTime = parseWithISO8601(time: time) { + return formattedTime + } else if let formattedTime = parseWithCustomFormat(time: time) { + return formattedTime + } else { + + return parseTimeOnlyFallback(time: time) + } + } + + private func parseWithISO8601(time: String) -> String? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } + + private func parseWithCustomFormat(time: String) -> String? { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + + if let date = dateFormatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } + + private func parseTimeOnlyFallback(time: String) -> String { + + var timeComponents = time.components(separatedBy: "T").last ?? time + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + + let timePattern = "\\d{2}:\\d{2}" + if let range = timeComponents.range(of: timePattern, options: .regularExpression) { + let timeOnly = String(timeComponents[range]) + dateFormatter.dateFormat = "HH:mm" + + if let date = dateFormatter.date(from: timeOnly) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + } + + return "Invalid Time" + } + - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return (formattedTime) + + private func parseTime(_ timeString: String) -> Int? { + + if let minutes = parseTimeWithISO8601(timeString) { + return minutes + } + + + return parseTimeCustom(timeString) + } + + private func parseTimeWithISO8601(_ timeString: String) -> Int? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: timeString) { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: date) + if let hour = components.hour, let minute = components.minute { + return hour * 60 + minute } - else { - return ("Failed to parse the time string.") + } + + return nil + } + + private func parseTimeCustom(_ timeString: String) -> Int? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] } } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let hour = calendar.component(.hour, from: date) + let minute = calendar.component(.minute, from: date) + return hour * 60 + minute + } + + return nil + } + + + + private func parseTimeToDate(_ timeString: String) -> Date? { + + if let date = parseTimeToDateISO8601(timeString) { + return date + } + + + return parseTimeToDateCustom(timeString) + } + + private func parseTimeToDateISO8601(_ timeString: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let originalDate = formatter.date(from: timeString) { + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: originalDate) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date()) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComponents.hour + combinedComponents.minute = timeComponents.minute + combinedComponents.second = timeComponents.second + + return calendar.date(from: combinedComponents) + } + + return nil + } + + private func parseTimeToDateCustom(_ timeString: String) -> Date? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let time = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let now = Date() + + let todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + let timeComps = calendar.dateComponents([.hour, .minute, .second], from: time) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComps.hour + combinedComponents.minute = timeComps.minute + combinedComponents.second = timeComps.second + + return calendar.date(from: combinedComponents) + } + + return nil + } func isDifferentFrom(_ other: TimeTable) -> Bool { return monday != other.monday || diff --git a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift index 989713c..2883195 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift @@ -88,21 +88,228 @@ struct LectureDetailView: View { return CLLocationCoordinate2D(latitude: 12.96972, longitude: 79.15658) } } - + private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + + if let formattedTime = parseWithISO8601(time: time) { + return formattedTime + } else if let formattedTime = parseWithCustomFormat(time: time) { + return formattedTime + } else { + + return parseTimeOnlyFallback(time: time) + } + } + + private func parseWithISO8601(time: String) -> String? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } + + private func parseWithCustomFormat(time: String) -> String? { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + + if let date = dateFormatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } + + private func parseTimeOnlyFallback(time: String) -> String { + + var timeComponents = time.components(separatedBy: "T").last ?? time + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + + let timePattern = "\\d{2}:\\d{2}" + if let range = timeComponents.range(of: timePattern, options: .regularExpression) { + let timeOnly = String(timeComponents[range]) + dateFormatter.dateFormat = "HH:mm" + + if let date = dateFormatter.date(from: timeOnly) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + } + + return "Invalid Time" + } + - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return (formattedTime) + + private func parseTime(_ timeString: String) -> Int? { + + if let minutes = parseTimeWithISO8601(timeString) { + return minutes + } + + + return parseTimeCustom(timeString) + } + + private func parseTimeWithISO8601(_ timeString: String) -> Int? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: timeString) { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: date) + if let hour = components.hour, let minute = components.minute { + return hour * 60 + minute } - else { - return ("Failed to parse the time string.") + } + + return nil + } + + private func parseTimeCustom(_ timeString: String) -> Int? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let hour = calendar.component(.hour, from: date) + let minute = calendar.component(.minute, from: date) + return hour * 60 + minute + } + + return nil + } + + + + private func parseTimeToDate(_ timeString: String) -> Date? { + + if let date = parseTimeToDateISO8601(timeString) { + return date + } + + + return parseTimeToDateCustom(timeString) + } + + private func parseTimeToDateISO8601(_ timeString: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let originalDate = formatter.date(from: timeString) { + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: originalDate) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date()) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComponents.hour + combinedComponents.minute = timeComponents.minute + combinedComponents.second = timeComponents.second + + return calendar.date(from: combinedComponents) + } + + return nil + } + + private func parseTimeToDateCustom(_ timeString: String) -> Date? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] } } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let time = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let now = Date() + + let todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + let timeComps = calendar.dateComponents([.hour, .minute, .second], from: time) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComps.hour + combinedComponents.minute = timeComps.minute + combinedComponents.second = timeComps.second + + return calendar.date(from: combinedComponents) + } + + return nil + } + } diff --git a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift index 4673ac5..0b540a9 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift @@ -199,67 +199,232 @@ struct LectureItemView: View { // MARK: - Helper Functions - private func parseTime(_ timeString: String) -> Int? { - var timeComponents = timeString.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + + + + + + + private func formatTime(time: String) -> String { + + if let formattedTime = parseWithISO8601(time: time) { + return formattedTime + } else if let formattedTime = parseWithCustomFormat(time: time) { + return formattedTime + } else { + + return parseTimeOnlyFallback(time: time) + } + } - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" + private func parseWithISO8601(time: String) -> String? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let date = formatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil + } - if let date = dateFormatter.date(from: timeComponents) { - let calendar = Calendar.current - let hour = calendar.component(.hour, from: date) - let minute = calendar.component(.minute, from: date) - return hour * 60 + minute + private func parseWithCustomFormat(time: String) -> String? { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + + if let date = dateFormatter.date(from: time) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + return nil } - return nil - } - - private func parseTimeToDate(_ timeString: String) -> Date? { - var timeComponents = timeString.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" + private func parseTimeOnlyFallback(time: String) -> String { + + var timeComponents = time.components(separatedBy: "T").last ?? time + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + + + let timePattern = "\\d{2}:\\d{2}" + if let range = timeComponents.range(of: timePattern, options: .regularExpression) { + let timeOnly = String(timeComponents[range]) + dateFormatter.dateFormat = "HH:mm" + + if let date = dateFormatter.date(from: timeOnly) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.locale = Locale(identifier: "en_US_POSIX") + return displayFormatter.string(from: date) + } + } + + return "Invalid Time" + } - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" + - if let time = dateFormatter.date(from: timeComponents) { - let calendar = Calendar.current - let now = Date() + private func parseTime(_ timeString: String) -> Int? { + + if let minutes = parseTimeWithISO8601(timeString) { + return minutes + } - // Combine today's date with the parsed time - let todayComponents = calendar.dateComponents([.year, .month, .day], from: now) - let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: time) + + return parseTimeCustom(timeString) + } + + private func parseTimeWithISO8601(_ timeString: String) -> Int? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] - var combinedComponents = DateComponents() - combinedComponents.year = todayComponents.year - combinedComponents.month = todayComponents.month - combinedComponents.day = todayComponents.day - combinedComponents.hour = timeComponents.hour - combinedComponents.minute = timeComponents.minute - combinedComponents.second = timeComponents.second + if let date = formatter.date(from: timeString) { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: date) + if let hour = components.hour, let minute = components.minute { + return hour * 60 + minute + } + } - return calendar.date(from: combinedComponents) + return nil + } + + private func parseTimeCustom(_ timeString: String) -> Int? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let date = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let hour = calendar.component(.hour, from: date) + let minute = calendar.component(.minute, from: date) + return hour * 60 + minute + } + + return nil } - return nil - } - - private func formatTime(time: String) -> String { - var timeComponents = time.components(separatedBy: "T").last ?? "" - timeComponents = timeComponents.components(separatedBy: "+").first ?? "" - timeComponents = timeComponents.components(separatedBy: "Z").first ?? "" - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm:ss" - if let date = dateFormatter.date(from: timeComponents) { - dateFormatter.dateFormat = "h:mm a" - let formattedTime = dateFormatter.string(from: date) - return formattedTime - } else { - return "Failed to parse the time string." + + private func parseTimeToDate(_ timeString: String) -> Date? { + + if let date = parseTimeToDateISO8601(timeString) { + return date + } + + + return parseTimeToDateCustom(timeString) + } + + private func parseTimeToDateISO8601(_ timeString: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + + if let originalDate = formatter.date(from: timeString) { + let calendar = Calendar.current + let timeComponents = calendar.dateComponents([.hour, .minute, .second], from: originalDate) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: Date()) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComponents.hour + combinedComponents.minute = timeComponents.minute + combinedComponents.second = timeComponents.second + + return calendar.date(from: combinedComponents) + } + + return nil + } + + private func parseTimeToDateCustom(_ timeString: String) -> Date? { + var timeComponents = timeString.components(separatedBy: "T").last ?? timeString + + + if timeComponents.contains("+") { + timeComponents = timeComponents.components(separatedBy: "+").first ?? timeComponents + } + if timeComponents.contains("Z") { + timeComponents = timeComponents.components(separatedBy: "Z").first ?? timeComponents + } + if timeComponents.contains("-") && timeComponents.count > 8 { + let parts = timeComponents.components(separatedBy: "-") + if parts.count > 1 && parts[0].count >= 8 { + timeComponents = parts[0] + } + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + + if let time = dateFormatter.date(from: timeComponents) { + let calendar = Calendar.current + let now = Date() + + let todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + let timeComps = calendar.dateComponents([.hour, .minute, .second], from: time) + + var combinedComponents = DateComponents() + combinedComponents.year = todayComponents.year + combinedComponents.month = todayComponents.month + combinedComponents.day = todayComponents.day + combinedComponents.hour = timeComps.hour + combinedComponents.minute = timeComps.minute + combinedComponents.second = timeComps.second + + return calendar.date(from: combinedComponents) + } + + return nil } - } } diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index c091469..0782046 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -13,13 +13,12 @@ struct TimeTableView: View { @State private var selectedLecture: Lecture? = nil @State private var isRefreshing = false @State private var showingRefreshAlert = false + @State private var scrollPosition: Int? = 0 @Query private var timetableItem: [TimeTable] @Environment(\.dismiss) private var dismiss let friend: Friend? - - private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String( @@ -31,160 +30,9 @@ struct TimeTableView: View { NavigationStack { ZStack { BackgroundView() + VStack { - - - switch viewModel.stage { - case .loading: - VStack { - Spacer() - ProgressView() - .scaleEffect(1.2) - Text("Loading timetable...") - .font(.caption) - .foregroundColor(.secondary) - .padding(.top, 8) - Spacer() - } - case .error: - VStack { - Spacer() - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 50)) - .foregroundColor(.orange) - .padding(.bottom, 16) - - Text("Something went wrong!") - .font(Font.custom("Poppins-Bold", size: 24)) - .padding(.bottom, 8) - - Text("Sorry if you are late for your class!") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.bottom, 20) - - Button(action: { - showingRefreshAlert = true - }) { - HStack { - Image(systemName: "arrow.clockwise") - Text("Refresh Timetable") - } - .foregroundColor(.white) - .padding() - .background(Color("Accent")) - .cornerRadius(10) - } - .disabled(isRefreshing) - - Spacer() - } - case .empty: - // Show empty timetable view with reload functionality - EmptyTimetableView( - onReload: { - Task { - await refreshTimetable() - } - }, - isRefreshing: isRefreshing - ) - case .data: - if viewModel.isEmpty{ - EmptyTimetableView( - onReload: { - Task { - await refreshTimetable() - } - }, - isRefreshing: isRefreshing - ) - } else{ - VStack(spacing: 0) { - - ScrollViewReader { proxy in - ScrollView(.horizontal) { - HStack { - ForEach(daysOfWeek, id: \.self) { day in - Text(day) - .foregroundStyle(daysOfWeek[viewModel.dayNo] == day - ? Color("Background") : Color("Accent")) - .frame(width: 60, height: 54) - .background( - daysOfWeek[viewModel.dayNo] == day - ? Color("Accent") : Color.clear - ) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.dayNo = daysOfWeek.firstIndex( - of: day - )! - viewModel.changeDay() - - proxy.scrollTo(day, anchor: .center) - } - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - .id(day) - } - } - .padding(.horizontal, 8) - } - .scrollIndicators(.hidden) - .onAppear { - let currentDay = daysOfWeek[viewModel.dayNo] - proxy.scrollTo(currentDay, anchor: .center) - } - .onChange(of: viewModel.dayNo) { oldValue, newValue in - let selectedDay = daysOfWeek[newValue] - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(selectedDay, anchor: .center) - } - } - } - .background(Color("Secondary")) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - - if viewModel.lectures.isEmpty { - Spacer() - VStack(spacing: 16) { - Image(systemName: "calendar.badge.exclamationmark") - .font(.system(size: 50)) - .foregroundColor(.secondary) - - Text("No classes today!") - .font(Font.custom("Poppins-Bold", size: 24)) - - Text(StringConstants.noClassQuotesOffline.randomElement() ?? "Enjoy your free time!") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - Spacer() - - } else { - ScrollView { - VStack(spacing: 12) { - ForEach(viewModel.lectures.sorted()) { lecture in - LectureItemView( - lecture: lecture, - selectedDayIndex: viewModel.dayNo, - allLectures: viewModel.lectures - ) { - selectedLecture = lecture - } - } - } - .padding(.horizontal) - .padding(.top, 12) - .padding(.bottom, 100) - } - } - } - } - } + contentView } } } @@ -192,12 +40,7 @@ struct TimeTableView: View { LectureDetailView(lecture: lecture) } .alert("Refresh Timetable", isPresented: $showingRefreshAlert) { - Button("Cancel", role: .cancel) { } - Button("Refresh", role: .destructive) { - Task { - await refreshTimetable() - } - } + alertButtons } message: { Text("This will clear your local timetable and fetch fresh data from the server. Continue?") } @@ -207,27 +50,303 @@ struct TimeTableView: View { loadTimetable() } .onChange(of: timetableItem) { oldValue, newValue in - logger.debug("Timetable data changed, reloading view.") + handleTimetableChange(oldValue: oldValue, newValue: newValue) + } + .onChange(of: scenePhase) { _, newPhase in + handleScenePhaseChange(newPhase) + } + } + + @ViewBuilder + private var contentView: some View { + switch viewModel.stage { + case .loading: + loadingView + case .error: + errorView + case .empty: + emptyView + case .data: + dataView + } + } + + @ViewBuilder + private var loadingView: some View { + VStack { + Spacer() + ProgressView() + .scaleEffect(1.2) + Text("Loading timetable...") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + Spacer() + } + } + + @ViewBuilder + private var errorView: some View { + VStack { + Spacer() - // Simplified change detection - if oldValue.count != newValue.count { - // Data was added or removed - loadTimetable() - } else if let newTable = newValue.first { - // Data content changed - viewModel.refreshFromDatabase(newTable) + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundColor(.orange) + .padding(.bottom, 16) + + Text("Something went wrong!") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) + + Text("Sorry if you are late for your class!") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.bottom, 20) + + refreshButton + + Spacer() + } + } + + @ViewBuilder + private var refreshButton: some View { + Button(action: { + showingRefreshAlert = true + }) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Refresh Timetable") } + .foregroundColor(.white) + .padding() + .background(Color("Accent")) + .cornerRadius(10) } - .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { - viewModel.resetSyncStatus() - - // Reload if in error state or empty state - if viewModel.stage == .error || viewModel.stage == .empty { - loadTimetable() + .disabled(isRefreshing) + } + + @ViewBuilder + private var emptyView: some View { + EmptyTimetableView( + onReload: { + Task { + await refreshTimetable() + } + }, + isRefreshing: isRefreshing + ) + } + + @ViewBuilder + private var dataView: some View { + if viewModel.isEmpty { + emptyView + } else { + timetableContentView + } + } + + @ViewBuilder + private var timetableContentView: some View { + VStack(spacing: 0) { + daysSelectorView + lecturesContentView + } + } + + @ViewBuilder + private var daysSelectorView: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { index, day in + dayTabView(day: day, index: index) + } + } + } + .scrollTargetBehavior(.paging) + .scrollIndicators(.hidden) + .scrollPosition(id: $scrollPosition) + .onChange(of: scrollPosition) { oldValue, newValue in + handleScrollPositionChange(newValue: newValue) + } + .onAppear { + if let currentPosition = scrollPosition { + proxy.scrollTo(currentPosition, anchor: .center) } } } + .frame(height: 54) + .background(Color("Secondary")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + } + + @ViewBuilder + private func dayTabView(day: String, index: Int) -> some View { + let isSelected = viewModel.dayNo == index + + GeometryReader { geometry in + Text(day) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(isSelected ? Color("Background") : Color("Accent")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(isSelected ? Color("Accent") : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .onTapGesture { + handleDayTap(index: index) + } + } + + .frame(width: UIScreen.main.bounds.width / 6, height: 54) + .id(index) + } + + @ViewBuilder + private var lecturesContentView: some View { + if viewModel.lectures.isEmpty { + noClassesView + } else { + lecturesListView + } + } + + @ViewBuilder + private var noClassesView: some View { + Spacer() + + VStack(spacing: 16) { + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + + Text("No classes today!") + .font(Font.custom("Poppins-Bold", size: 24)) + + Text(StringConstants.noClassQuotesOffline.randomElement() ?? "Enjoy your free time!") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + Spacer() + } + + @ViewBuilder + private var lecturesListView: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 12) { + ForEach(viewModel.lectures.sorted()) { lecture in + LectureItemView( + lecture: lecture, + selectedDayIndex: viewModel.dayNo, + allLectures: viewModel.lectures + ) { + selectedLecture = lecture + } + } + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) + } + + .gesture( + DragGesture() + .onEnded { value in + handleSwipeGesture(value) + } + ) + } + + @ViewBuilder + private var alertButtons: some View { + Button("Cancel", role: .cancel) { } + Button("Refresh", role: .destructive) { + Task { + await refreshTimetable() + } + } + } + + // MARK: - Helper Methods + + private func handleSwipeGesture(_ value: DragGesture.Value) { + let horizontalMovement = value.translation.width + let minimumSwipeDistance: CGFloat = 50 + + + if abs(horizontalMovement) > minimumSwipeDistance { + if horizontalMovement > 0 { + + switchToPreviousDay() + } else { + + switchToNextDay() + } + } + } + + private func switchToNextDay() { + let nextDay = min(viewModel.dayNo + 1, daysOfWeek.count - 1) + if nextDay != viewModel.dayNo { + withAnimation(.easeInOut(duration: 0.3)) { + scrollPosition = nextDay + viewModel.dayNo = nextDay + viewModel.changeDay() + } + } + } + + private func switchToPreviousDay() { + let previousDay = max(viewModel.dayNo - 1, 0) + if previousDay != viewModel.dayNo { + withAnimation(.easeInOut(duration: 0.3)) { + scrollPosition = previousDay + viewModel.dayNo = previousDay + viewModel.changeDay() + } + } + } + + private func handleDayTap(index: Int) { + withAnimation(.easeInOut(duration: 0.3)) { + scrollPosition = index + viewModel.dayNo = index + viewModel.changeDay() + } + } + + private func handleScrollPositionChange(newValue: Int?) { + guard let newValue = newValue, newValue != viewModel.dayNo else { return } + + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.dayNo = newValue + viewModel.changeDay() + } + } + + private func handleTimetableChange(oldValue: [TimeTable], newValue: [TimeTable]) { + logger.debug("Timetable data changed, reloading view.") + + if oldValue.count != newValue.count { + loadTimetable() + } else if let newTable = newValue.first { + viewModel.refreshFromDatabase(newTable) + } + } + + private func handleScenePhaseChange(_ newPhase: ScenePhase) { + if newPhase == .active { + viewModel.resetSyncStatus() + + if viewModel.stage == .error || viewModel.stage == .empty { + loadTimetable() + } + } } private func loadTimetable() { @@ -240,8 +359,10 @@ struct TimeTableView: View { if dayIndex >= 0 && dayIndex < daysOfWeek.count { viewModel.dayNo = dayIndex + scrollPosition = dayIndex } else { viewModel.dayNo = 0 + scrollPosition = 0 } Task { diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index a4ada53..c2686dc 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -4,6 +4,7 @@ // // Created by Rujin Devkota on 2/25/25. // + import SwiftUI import WidgetKit