diff --git a/VITTY/Assets.xcassets/Colors/Background.colorset/Contents.json b/VITTY/Assets.xcassets/Colors/Background.colorset/Contents.json index 7ca1468..7501a81 100644 --- a/VITTY/Assets.xcassets/Colors/Background.colorset/Contents.json +++ b/VITTY/Assets.xcassets/Colors/Background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0.149", - "green" : "0.090", - "red" : "0.031" + "blue" : "0x25", + "green" : "0x16", + "red" : "0x07" } }, "idiom" : "universal" diff --git a/VITTY/Assets.xcassets/Colors/Secondary.colorset/Contents.json b/VITTY/Assets.xcassets/Colors/Secondary.colorset/Contents.json index 9785fb8..f917020 100644 --- a/VITTY/Assets.xcassets/Colors/Secondary.colorset/Contents.json +++ b/VITTY/Assets.xcassets/Colors/Secondary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0.192", - "green" : "0.118", - "red" : "0.051" + "blue" : "0x30", + "green" : "0x1E", + "red" : "0x0D" } }, "idiom" : "universal" diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 0fb20d6..91fc5e8 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ 4B8B32DC2D6D75F6004F01BA /* VittyWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4B8B32C82D6D75F4004F01BA /* VittyWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4B8B33752D7029AA004F01BA /* SideBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8B33742D7029A3004F01BA /* SideBar.swift */; }; 4BA03C1D2D7584F3000756B0 /* AddFriend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA03C1C2D7584EA000756B0 /* AddFriend.swift */; }; + 4BA6DFE92E33D5CE00B0411A /* NotesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */; }; 4BBB00312D955163003B8FE2 /* AcademicsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBB00302D95515C003B8FE2 /* AcademicsViewModel.swift */; }; 4BBB00332D957A6A003B8FE2 /* NotesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */; }; 4BC853C32DF693780092B2E2 /* SaveTimeTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC853C22DF6936E0092B2E2 /* SaveTimeTableView.swift */; }; @@ -1248,6 +1249,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4BA6DFE92E33D5CE00B0411A /* NotesModel.swift in Sources */, 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */, 4BC853C42DF6DA7A0092B2E2 /* TimeTable.swift in Sources */, 4B2D64902E20BA6300412CB7 /* NetworkMonitor.swift in Sources */, diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index 615a5eb..926a3a6 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -268,10 +268,10 @@ struct OCourseRefs: View { // Expandable FAB VStack(spacing: 16) { - // Action buttons (shown when expanded) + if showExpandedFAB { VStack(spacing: 12) { - // Set Reminder Button + ExpandableFABButton( icon: "bell.fill", title: "Set Reminder", diff --git a/VITTY/VITTY/Academics/View/CreateReminder.swift b/VITTY/VITTY/Academics/View/CreateReminder.swift index 069b142..d6e6d26 100644 --- a/VITTY/VITTY/Academics/View/CreateReminder.swift +++ b/VITTY/VITTY/Academics/View/CreateReminder.swift @@ -43,6 +43,28 @@ struct ReminderView: View { Spacer() Button("Add") { + // Combine selected date with start and end times + let calendar = Calendar.current + let dateComponents = calendar.dateComponents([.year, .month, .day], from: selectedDate) + let startTimeComponents = calendar.dateComponents([.hour, .minute], from: startTime) + let endTimeComponents = calendar.dateComponents([.hour, .minute], from: endTime) + + let finalStartTime = calendar.date(from: DateComponents( + year: dateComponents.year, + month: dateComponents.month, + day: dateComponents.day, + hour: startTimeComponents.hour, + minute: startTimeComponents.minute + )) ?? startTime + + let finalEndTime = calendar.date(from: DateComponents( + year: dateComponents.year, + month: dateComponents.month, + day: dateComponents.day, + hour: endTimeComponents.hour, + minute: endTimeComponents.minute + )) ?? endTime + let newReminder = Remainder( title: title, subject: courseName, @@ -51,8 +73,8 @@ struct ReminderView: View { date: selectedDate, isCompleted: false, subjectDescription: description, - startTime: startTime, - endTime: endTime + startTime: finalStartTime, + endTime: finalEndTime ) do { @@ -60,10 +82,9 @@ struct ReminderView: View { try modelContext.save() print("Saved successfully") - NotificationManager.shared.scheduleReminderNotifications( title: title, - date: startTime, + date: finalStartTime, subject: courseName ) @@ -84,6 +105,14 @@ struct ReminderView: View { .font(.system(size: 32, weight: .bold)) .foregroundColor(.white) .padding(.bottom) + .onTapGesture { + // Close pickers when tapping on title + withAnimation(.easeInOut(duration: 0.3)) { + showDatePicker = false + showStartTimePicker = false + showEndTimePicker = false + } + } TextField("Title", text: $title) .padding() @@ -114,16 +143,15 @@ struct ReminderView: View { Spacer() Text(selectedDate, style: .date) .foregroundColor(.gray) + .id(selectedDate) Image(systemName: showDatePicker ? "chevron.down" : "chevron.right") .foregroundColor(.gray) - .rotationEffect(.degrees(showDatePicker ? 0 : 0)) } .padding() .background(Color("Secondary")) .cornerRadius(10) .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { - showStartTimePicker = false showEndTimePicker = false showDatePicker.toggle() @@ -131,27 +159,34 @@ struct ReminderView: View { } if showDatePicker { - DatePicker( - "Select Date", - selection: $selectedDate, - displayedComponents: [.date] - ) - .datePickerStyle(.graphical) - .colorScheme(.dark) - .labelsHidden() - .onChange(of: selectedDate) { + VStack(spacing: 12) { + DatePicker( + "Select Date", + selection: $selectedDate, + displayedComponents: [.date] + ) + .datePickerStyle(.graphical) + .colorScheme(.dark) + .labelsHidden() + .onChange(of: selectedDate) { oldValue, newValue in + print("Date changed from \(oldValue) to \(newValue)") + } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Button("Done") { withAnimation(.easeInOut(duration: 0.3)) { showDatePicker = false } } + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .trailing) } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) .transition(.opacity.combined(with: .scale)) } } - VStack(alignment: .leading, spacing: 8) { HStack { Text("Start Time") @@ -167,7 +202,6 @@ struct ReminderView: View { .cornerRadius(10) .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { - showDatePicker = false showEndTimePicker = false showStartTimePicker.toggle() @@ -195,11 +229,13 @@ struct ReminderView: View { .foregroundColor(.red) .frame(maxWidth: .infinity, alignment: .trailing) } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) .transition(.opacity.combined(with: .scale)) } } - VStack(alignment: .leading, spacing: 8) { HStack { Text("End Time") @@ -215,7 +251,6 @@ struct ReminderView: View { .cornerRadius(10) .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { - showDatePicker = false showStartTimePicker = false showEndTimePicker.toggle() @@ -243,6 +278,9 @@ struct ReminderView: View { .foregroundColor(.red) .frame(maxWidth: .infinity, alignment: .trailing) } + .padding() + .background(Color("Secondary")) + .cornerRadius(10) .transition(.opacity.combined(with: .scale)) } } @@ -252,13 +290,5 @@ struct ReminderView: View { } } .preferredColorScheme(.dark) - .onTapGesture { - - withAnimation(.easeInOut(duration: 0.3)) { - showDatePicker = false - showStartTimePicker = false - showEndTimePicker = false - } - } } } diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index b0f204a..f5c42f2 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -162,9 +162,14 @@ struct NoteEditorView: View { } private func handleBackNavigation() { - // Check if there are unsaved changes or if it's a new note + if hasUnsavedChanges || (existingNote == nil && !isEmpty) { - showTitleAlert = true + + if existingNote != nil && !noteTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + saveNoteWithTitle() + } else { + showTitleAlert = true + } } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if presentationMode.wrappedValue.isPresented { @@ -230,8 +235,16 @@ struct NoteEditorView: View { } func saveContent() { - showTitleAlert = true + + if existingNote != nil && !noteTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + saveNoteWithTitle() + } else { + + showTitleAlert = true + } } + + private func saveNoteWithTitle() { guard !noteTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -256,6 +269,7 @@ struct NoteEditorView: View { noteContent: dataString, createdAt: Date.now ) + print("saved with a course id of \(courseCode)") modelContext.insert(newNote) } 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/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index 67db375..3689545 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -9,115 +9,206 @@ import SwiftUI struct FriendsView: View { @State private var searchText = "" @State private var selectedFilterOption = 0 + @State private var showingUnfriendAlert = false + @State private var showingActionAlert = false + @State private var alertMessage = "" + @State private var selectedFriend: Friend? + @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(AuthViewModel.self) private var authViewModel + + private let filterOptions = ["All", "Available", "Ghosted"] var body: some View { - VStack(spacing: 12) { - Spacer().frame(height: 8) - - SearchBar(searchText: $searchText) - Spacer().frame(height: 8) - - - HStack { - FilterPill(title: "Available", isSelected: selectedFilterOption == 0) - .onTapGesture { - selectedFilterOption = 0 - } - FilterPill(title: "View All", isSelected: selectedFilterOption == 1) - .onTapGesture { - selectedFilterOption = 1 - } - Spacer() - } - .padding(.horizontal) - Spacer().frame(height: 7) - - - if communityPageViewModel.errorFreinds { - Spacer() - VStack(spacing: 5) { - Text("No Friends?") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(Color.white) - Text("Add your friends and see their timetable") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundColor(Color.white) - } - Spacer() - } else if communityPageViewModel.loadingFreinds { - Spacer() - ProgressView() - Spacer() - } else { + ZStack { + VStack(spacing: 12) { + Spacer().frame(height: 8) - let filteredFriends = communityPageViewModel.friends.filter { friend in - - let matchesSearch: Bool - if searchText.isEmpty { - matchesSearch = true - } else { - matchesSearch = friend.username.localizedCaseInsensitiveContains(searchText) || - (friend.name.localizedCaseInsensitiveContains(searchText) ?? false) - } - - - let matchesFilter: Bool - switch selectedFilterOption { - case 0: - matchesFilter = friend.currentStatus.status == "free" - case 1: - matchesFilter = true - default: - matchesFilter = true + SearchBar(searchText: $searchText) + Spacer().frame(height: 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0.. String { + if !searchText.isEmpty { + switch selectedFilterOption { + case 0: return "No friends match your search" + case 1: return "No available friends match your search" + case 2: return "No ghosted friends match your search" + default: return "No friends match your search" + } + } else { + switch selectedFilterOption { + case 0: return "You don't have any friends yet" + case 1: return "No friends are currently available" + case 2: return "You haven't ghosted any friends" + default: return "You don't have any friends yet" + } + } + } + + private func cleanName(_ fullName: String) -> String { + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + let range = NSRange(location: 0, length: fullName.utf16.count) + let cleanedName = regex?.stringByReplacingMatches(in: fullName, options: [], range: range, withTemplate: "").trimmingCharacters(in: .whitespaces) ?? fullName + + return cleanedName + } + + private func getFriendRow(for friend: Friend) -> FriendRow? { + return nil } } diff --git a/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift b/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift index c68620f..28ddae7 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/FriendRow.swift @@ -1,50 +1,497 @@ import SwiftUI -struct FriendRow: View { - let friend: Friend - +// MARK: - Unfriend Alert +struct UnfriendAlert: View { + let friendName: String + let onCancel: () -> Void + let onUnfriend: () -> Void + var body: some View { - HStack { - UserImage(url: friend.picture, height: 48, width: 48) - Spacer().frame(width: 20) - VStack(alignment: .leading) { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Unfriend \(friendName)?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) - Text(cleanName(friend.name)) - .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(Color.white) + Text("This action cannot be undone. You'll need to send a new friend request to reconnect.") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) - if friend.currentStatus.status == "free" { - HStack { - Image("available").resizable().frame(width: 20, height: 20) - Text("Available").foregroundStyle(Color("Accent")) + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) } - } else { - HStack { - Image("inclass") - Text(friend.currentStatus.venue ?? "") - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color("Accent")) + + Button(action: onUnfriend) { + Text("Unfriend") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) } } } + .frame(height: 150) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + } +} + +// MARK: - Action Result Alert +struct ActionResultAlert: View { + let message: String + let onDismiss: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 12) { + Text("Action Result") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text(message) + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + Button(action: { + onDismiss() + }) { + Text("OK") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color("Accent")) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .frame(height: 120) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) Spacer() } - .padding().frame(maxWidth: .infinity) .background( - RoundedRectangle(cornerRadius: 15) - .fill(Color("Secondary")) + Color.black.opacity(0.5) + .edgesIgnoringSafeArea(.all) + .onTapGesture { + + } + ) + } +} + +// MARK: - Menu Button Item +struct MenuButtonItem: View { + let icon: String + let title: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundColor(color) + .frame(width: 14, height: 14) + Text(title) + .font(.custom("Poppins-Medium", size: 13)) + .foregroundColor(color) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.clear) + } + } +} + +// MARK: - Friend Menu +struct FriendMenu: View { + let friend: Friend + let isGhosted: Bool + let onTimetable: () -> Void + let onUnfriend: () -> Void + let onToggleGhost: () -> Void + + var body: some View { + VStack(spacing: 0) { + MenuButtonItem( + icon: "person.badge.minus", + title: "Unfriend", + color: .red, + action: onUnfriend + ) + + menuDivider + + MenuButtonItem( + icon: isGhosted ? "eye" : "eye.slash", + title: isGhosted ? "Show" : "Hide", + color: .orange, + action: onToggleGhost + ) + } + .frame(width: 140) + .background(menuBackground) + .offset(x: -75, y: 0) + .zIndex(100) + .transition(menuTransition) + } + + private var menuDivider: some View { + Divider() + .background(Color.gray.opacity(0.3)) + } + + private var menuBackground: some View { + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "#1A2B42")) + .stroke(Color(hex: "#0D1E30"), lineWidth: 1) + .shadow(color: .black.opacity(0.4), radius: 6, x: -2, y: 2) + } + + private var menuTransition: AnyTransition { + .asymmetric( + insertion: .scale(scale: 0.8).combined(with: .opacity), + removal: .scale(scale: 0.8).combined(with: .opacity) ) } +} +// MARK: - Friend Status View +struct FriendStatusView: View { + let friend: Friend + + var body: some View { + VStack(alignment: .leading) { + Text(cleanName(friend.name)) + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(Color.white) + + statusIndicator + } + } + + @ViewBuilder + private var statusIndicator: some View { + if friend.currentStatus.status == "free" { + HStack { + Image("available") + .resizable() + .frame(width: 20, height: 20) + Text("Available") + .foregroundStyle(Color("Accent")) + } + } else { + HStack { + Image("inclass") + Text(friend.currentStatus.venue ?? "") + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color("Accent")) + } + } + } + + private func cleanName(_ fullName: String) -> String { + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + + let range = NSRange(location: 0, length: fullName.utf16.count) + let cleanedName = regex?.stringByReplacingMatches( + in: fullName, + options: [], + range: range, + withTemplate: "" + ).trimmingCharacters(in: .whitespaces) ?? fullName + + return cleanedName + } +} + + +struct FriendRow: View { + let friend: Friend + @State private var showingMenu = false + @State private var isLoading = false + @State private var navigateToTimetable = false + + @Binding var showingUnfriendAlert: Bool + @Binding var showingActionAlert: Bool + @Binding var alertMessage: String + @Binding var selectedFriend: Friend? + + @Environment(CommunityPageViewModel.self) private var communityPageViewModel + @Environment(AuthViewModel.self) private var authViewModel + + var body: some View { + ZStack { + hiddenNavigationLink + + mainContent + .overlay(loadingOverlay, alignment: .center) + .onReceive(NotificationCenter.default.publisher(for: .dismissMenus)) { _ in + showingMenu = false + } + .onTapGesture { + handleTapGesture() + } + } + .animation(.easeInOut(duration: 0.2), value: showingMenu) + } + private var hiddenNavigationLink: some View { + NavigationLink( + destination: FriendsTimeTableView(friend: friend), + isActive: $navigateToTimetable + ) { + EmptyView() + } + .hidden() + } + + private var mainContent: some View { + HStack { + UserImage(url: friend.picture, height: 48, width: 48) + + Spacer() + .frame(width: 20) + + FriendStatusView(friend: friend) + + Spacer() + + actionButtons + } + .padding() + .frame(maxWidth: .infinity) + .background(rowBackground) + } + + private var actionButtons: some View { + HStack(spacing: 12) { + if communityPageViewModel.isGhosted(friend.username) { + ghostIndicator + } + + menuButton + } + } + + private var ghostIndicator: some View { + Image(systemName: "eye.slash.fill") + .foregroundColor(.orange) + .font(.system(size: 16)) + } + + private var menuButton: some View { + Button(action: toggleMenu) { + Image(systemName: "ellipsis") + .foregroundColor(.white) + .font(.system(size: 16, weight: .medium)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .disabled(isLoading) + .zIndex(2) + .overlay(menuOverlay, alignment: .center) + } + + @ViewBuilder + private var menuOverlay: some View { + if showingMenu { + FriendMenu( + friend: friend, + isGhosted: communityPageViewModel.isGhosted(friend.username), + onTimetable: handleTimetableAction, + onUnfriend: handleUnfriendAction, + onToggleGhost: handleToggleGhostAction + ) + } + } + + private var rowBackground: some View { + RoundedRectangle(cornerRadius: 15) + .fill(Color(hex: "#0D1E30")) + .opacity(communityPageViewModel.isGhosted(friend.username) ? 0.6 : 1.0) + } + + @ViewBuilder + private var loadingOverlay: some View { + if isLoading { + RoundedRectangle(cornerRadius: 15) + .fill(Color.black.opacity(0.3)) + .overlay( + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + ) + } + } + + + private func toggleMenu() { + withAnimation(.easeInOut(duration: 0.2)) { + showingMenu.toggle() + } + } + + private func handleTapGesture() { + if showingMenu { + withAnimation(.easeInOut(duration: 0.2)) { + showingMenu = false + } + } else { + navigateToTimetable = true + } + } + + private func handleTimetableAction() { + showingMenu = false + navigateToTimetable = true + } + + private func handleUnfriendAction() { + selectedFriend = friend + showingUnfriendAlert = true + showingMenu = false + } + + private func handleToggleGhostAction() { + toggleGhostMode() + showingMenu = false + } + + // MARK: - Helper Functions func cleanName(_ fullName: String) -> String { - let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" // + let pattern = "\\b\\d{2}[A-Z]+\\d+\\b" let regex = try? NSRegularExpression(pattern: pattern, options: []) let range = NSRange(location: 0, length: fullName.utf16.count) - let cleanedName = regex?.stringByReplacingMatches(in: fullName, options: [], range: range, withTemplate: "").trimmingCharacters(in: .whitespaces) ?? fullName + let cleanedName = regex?.stringByReplacingMatches( + in: fullName, + options: [], + range: range, + withTemplate: "" + ).trimmingCharacters(in: .whitespaces) ?? fullName return cleanedName } + + // MARK: - Action Functions + func unfriendAction() { + guard let token = authViewModel.loggedInBackendUser?.token else { + alertMessage = "Authentication error. Please try again." + showingActionAlert = true + return + } + + isLoading = true + + communityPageViewModel.unfriendUser(username: friend.username, token: token) { success in + DispatchQueue.main.async { + isLoading = false + + if success { + alertMessage = "Successfully unfriended \(cleanName(friend.name))" + } else { + alertMessage = "Failed to unfriend \(cleanName(friend.name)). Please try again." + } + showingActionAlert = true + } + } + } + + private func toggleGhostMode() { + guard let token = authViewModel.loggedInBackendUser?.token else { + alertMessage = "Authentication error. Please try again." + showingActionAlert = true + return + } + + isLoading = true + let isCurrentlyGhosted = communityPageViewModel.isGhosted(friend.username) + + if isCurrentlyGhosted { + communityPageViewModel.makeAlive(username: friend.username, token: token) { success in + DispatchQueue.main.async { + handleGhostResult(success: success, action: "make alive") + } + } + } else { + communityPageViewModel.ghostFriend(username: friend.username, token: token) { success in + DispatchQueue.main.async { + handleGhostResult(success: success, action: "ghost") + } + } + } + } + + private func handleGhostResult(success: Bool, action: String) { + isLoading = false + + if success { + let message = action == "ghost" + ? "\(cleanName(friend.name)) has been ghosted and won't appear in your active friends" + : "\(cleanName(friend.name)) is now visible in your friends list" + alertMessage = message + } else { + alertMessage = "Failed to \(action) \(cleanName(friend.name)). Please try again." + } + + showingActionAlert = true + } } +// MARK: - Color Extension +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +// MARK: - Notification Extension +extension Notification.Name { + static let dismissMenus = Notification.Name("dismissMenus") +} diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 83498d9..b764003 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -35,14 +35,20 @@ class CommunityPageViewModel { var memberTimetable: TimeTable? var loadingMemberTimetable = false var errorMemberTimetable = false + + //MARK: ghost mode + var ghostedFriends = Set() + var activeFriends = Set() + var loadingGhostAction = false private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: CommunityPageViewModel.self) ) + + var hasInitialActiveFriendsFetch = false func fetchFriendsData(from url: String, token: String, loading: Bool = false) { - if loading || friends.isEmpty { self.loadingFreinds = true } @@ -62,6 +68,9 @@ class CommunityPageViewModel { self.friends = data.data self.errorFreinds = false + + self.syncGhostStatusWithFriends() + case .failure(let error): self.logger.error("Error fetching friends: \(error)") if self.friends.isEmpty { @@ -71,6 +80,7 @@ class CommunityPageViewModel { } } } + //MARK: Circle DATA @@ -475,7 +485,7 @@ class CommunityPageViewModel { if data.detail.lowercased().contains("successfully") { self.logger.info("Successfully created circle: \(name)") - // Now fetch the updated circles data and wait for completion + self.fetchCircleDataWithCompletion( from: "\(APIConstants.base_urlv3)circles", token: token, @@ -530,7 +540,7 @@ class CommunityPageViewModel { } } - // MARK: - Updated Circle Invitations with New Endpoint + func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { @@ -701,5 +711,213 @@ class CommunityPageViewModel { } } } + + // MARK: unfreind a user + func unfriendUser(username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)friends/\(username)/" + + AF.request(url, method: .delete, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: APIResponse.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + self.logger.info("Successfully unfriended: \(username) - \(data.data)") + + + self.friends.removeAll { $0.username == username } + self.removeFromGhosted(username) + self.activeFriends.remove(username) + self.saveGhostStateToUserDefaults() + + completion(true) + + case .failure(let error): + self.logger.error("Error unfriending \(username): \(error)") + completion(false) + } + } + } + } + + // MARK: ghost mode funcs + + func fetchActiveFriends(token: String, forceRefresh: Bool = false, completion: @escaping (Bool) -> Void = { _ in }) { + + guard forceRefresh || !hasInitialActiveFriendsFetch else { + completion(true) + return + } + + let url = "\(APIConstants.base_url)friends/active" + + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: ActiveFriendsResponse.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + self.activeFriends = Set(data.data) + self.hasInitialActiveFriendsFetch = true + self.logger.info("Successfully fetched active friends: \(data.data)") + + + if !self.friends.isEmpty { + let allFriendUsernames = Set(self.friends.map { $0.username }) + let activeSet = Set(data.data) + let friendsToGhost = allFriendUsernames.subtracting(activeSet) + + + self.ghostedFriends.formUnion(friendsToGhost) + self.saveGhostStateToUserDefaults() + + self.logger.info("Active friends: \(data.data)") + self.logger.info("Ghosted friends: \(Array(self.ghostedFriends))") + } + + completion(true) + + case .failure(let error): + self.logger.error("Error fetching active friends: \(error)") + + self.loadGhostStateFromUserDefaults() + completion(false) + } + } + } + } + + private func syncGhostStatusWithFriends() { + guard !friends.isEmpty && !activeFriends.isEmpty else { return } + + let allFriendUsernames = Set(friends.map { $0.username }) + let friendsToGhost = allFriendUsernames.subtracting(activeFriends) + + + ghostedFriends.formUnion(friendsToGhost) + saveGhostStateToUserDefaults() } + + func ghostFriend(username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)friends/ghost/\(username)" + self.loadingGhostAction = true + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: APIResponse.self) { response in + DispatchQueue.main.async { + self.loadingGhostAction = false + + switch response.result { + case .success(let data): + self.logger.info("Successfully ghosted: \(username) - \(data.data)") + + + self.ghostedFriends.insert(username) + self.activeFriends.remove(username) + self.saveGhostStateToUserDefaults() + + completion(true) + + case .failure(let error): + self.logger.error("Error ghosting \(username): \(error)") + completion(false) + } + } + } + } + func makeAlive(username: String, token: String, completion: @escaping (Bool) -> Void) { + let url = "\(APIConstants.base_url)friends/alive/\(username)" + self.loadingGhostAction = true + + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: APIResponse.self) { response in + DispatchQueue.main.async { + self.loadingGhostAction = false + + switch response.result { + case .success(let data): + self.logger.info("Successfully made alive: \(username) - \(data.data)") + + + self.ghostedFriends.remove(username) + self.activeFriends.insert(username) + self.saveGhostStateToUserDefaults() + + completion(true) + + case .failure(let error): + self.logger.error("Error making alive \(username): \(error)") + completion(false) + } + } + } + } + + func isGhosted(_ username: String) -> Bool { + return ghostedFriends.contains(username) + } + + func isActive(_ username: String) -> Bool { + return activeFriends.contains(username) && !ghostedFriends.contains(username) + } + + private func removeFromGhosted(_ username: String) { + ghostedFriends.remove(username) + } + + + private func saveGhostStateToUserDefaults() { + UserDefaults.standard.set(Array(ghostedFriends), forKey: "ghostedFriends") + } + + private func loadGhostStateFromUserDefaults() { + if let savedGhosted = UserDefaults.standard.array(forKey: "ghostedFriends") as? [String] { + ghostedFriends = Set(savedGhosted) + } + } + func refreshAllDataWithActiveCheck(token: String, username: String) { + + fetchActiveFriends(token: token) { [weak self] success in + if success { + + self?.fetchFriendsData( + from: "\(APIConstants.base_url)friends/\(username)/", + token: token, + loading: false + ) + + self?.fetchCircleData( + from: "\(APIConstants.base_urlv3)circles", + token: token, + loading: false + ) + + self?.fetchCircleRequests(token: token, loading: false) + } + } + } + func initializeGhostState() { + loadGhostStateFromUserDefaults() + } + + struct ActiveFriendsResponse: Codable { + let data: [String] + } + + struct APIResponse: Codable { + let data: String + } + + + } + +extension CommunityPageViewModel { + + + func dismissAllMenus() { + NotificationCenter.default.post(name: .dismissMenus, object: nil) + } +} 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..cf58049 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) @@ -459,7 +486,8 @@ struct SettingsView: View { let lecturesToCopy = currentTimeTable.lectures(forDay: day) print("Found \(lecturesToCopy.count) lectures to copy from \(day)") - + + // Create backup data let backupData = ( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, @@ -472,9 +500,11 @@ struct SettingsView: View { ) do { + // Delete existing timetable modelContext.delete(currentTimeTable) print("Deleted existing timetable") + // Create new Saturday lectures let newSaturdayLectures = lecturesToCopy.map { originalLecture in Lecture( name: originalLecture.name, @@ -489,6 +519,7 @@ struct SettingsView: View { print("Created \(newSaturdayLectures.count) new lectures for Saturday") + // Create new timetable let newTimeTable = TimeTable( monday: backupData.monday, tuesday: backupData.tuesday, @@ -500,27 +531,39 @@ struct SettingsView: View { saturdaySourceDay: day ) + // Insert new timetable modelContext.insert(newTimeTable) print("Inserted new timetable with Saturday lectures") + // Save changes try modelContext.save() + // Update local state IMMEDIATELY after successful save self.selectedDay = day print("Successfully recreated timetable with \(day) copied to Saturday") print("New Saturday has \(newTimeTable.saturday.count) lectures") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // Force immediate UI update + DispatchQueue.main.async { + // Send notification immediately NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil ) + + // Also send a refresh notification for the timetable view + NotificationCenter.default.post( + name: NSNotification.Name("RefreshTimetableFromSettings"), + object: nil + ) } } catch { print("Error during timetable recreation: \(error)") modelContext.rollback() + // Restore backup data on error print("Attempting to restore backup data...") do { let restoredTimeTable = TimeTable( @@ -543,6 +586,7 @@ struct SettingsView: View { } } + // MARK: - Fixed resetSaturdayClasses method in SettingsView private func resetSaturdayClasses() { guard let currentTimeTable = timeTables.first else { print("No timetable found") @@ -551,6 +595,7 @@ struct SettingsView: View { print("Starting SAFE reset of Saturday classes - DELETE & RECREATE approach") + // Create backup data let backupData = ( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, @@ -563,40 +608,54 @@ struct SettingsView: View { ) do { + // Delete existing timetable modelContext.delete(currentTimeTable) print("Deleted existing timetable") + // Create new timetable with empty Saturday let newTimeTable = TimeTable( monday: backupData.monday, tuesday: backupData.tuesday, wednesday: backupData.wednesday, thursday: backupData.thursday, friday: backupData.friday, - saturday: [], + saturday: [], // Empty Saturday sunday: backupData.sunday, - saturdaySourceDay: nil + saturdaySourceDay: nil // Clear source day ) + // Insert new timetable modelContext.insert(newTimeTable) print("Inserted new timetable with empty Saturday") + // Save changes try modelContext.save() + // Update local state IMMEDIATELY after successful save self.selectedDay = nil print("Successfully recreated timetable with empty Saturday") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // Force immediate UI update + DispatchQueue.main.async { + // Send notification immediately NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil ) + + // Also send a refresh notification for the timetable view + NotificationCenter.default.post( + name: NSNotification.Name("RefreshTimetableFromSettings"), + object: nil + ) } } catch { print("Error during timetable reset: \(error)") modelContext.rollback() + // Restore backup data on error print("Attempting to restore backup data...") do { let restoredTimeTable = TimeTable( @@ -619,6 +678,7 @@ struct SettingsView: View { } } + private var headerView: some View { HStack { Button(action: { @@ -842,6 +902,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/Service/TimeTableAPIService.swift b/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift index 782f472..1f90284 100644 --- a/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift +++ b/VITTY/VITTY/TimeTable/Service/TimeTableAPIService.swift @@ -26,6 +26,7 @@ class TimeTableAPIService { return timeTableRaw.data } + func getCircleMemberTimeTable( circleId: String, memberUsername: String, diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index a41a0a7..75bc23b 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -431,7 +431,7 @@ extension TimeTableView { ) } - // Create new timetable with Saturday lectures + let newTimeTable = TimeTable( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, @@ -449,7 +449,7 @@ extension TimeTableView { context: context ) - // Update UI + self.timeTable = newTimeTable changeDay() @@ -458,7 +458,7 @@ extension TimeTableView { // MARK: - Utility Methods func resetSyncStatus() { - // Simple reset - just refresh current day + changeDay() } @@ -473,7 +473,7 @@ extension TimeTableView { authToken: String, context: ModelContext ) async { - // Redirect to forceSync for consistency + await forceSync( username: username, authToken: authToken, diff --git a/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift b/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift index feff9b5..5f710fe 100644 --- a/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift +++ b/VITTY/VITTY/TimeTable/Views/FriendsTimetableView.swift @@ -1,4 +1,3 @@ -// // FriendsTimetableView.swift // VITTY // @@ -28,178 +27,178 @@ struct FriendsTimeTableView: View { ) var body: some View { - NavigationStack { - ZStack { - BackgroundView() - VStack { + ZStack { + BackgroundView() + VStack { + + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(Color("Accent")) + .font(.title2) + } + + Spacer() + + Text("\(friend.name)'s Timetable") + .font(Font.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Spacer() + + + } + .padding(.horizontal) + .padding(.bottom, 8) + + switch viewModel.stage { + case .loading: + VStack { + Spacer() + ProgressView() + .scaleEffect(1.2) + Text("Loading \(friend.name ?? friend.username)'s timetable...") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 8) + Spacer() + } - HStack { - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")) - .font(.title2) + case .error: + VStack { + Spacer() + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundColor(.orange) + .padding(.bottom, 16) + + Text("Can't show timetable right now") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) + + Text("Unable to display \(friend.name ?? friend.username)'s timetable at the moment") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + .padding(.bottom, 20) + + Button(action: { + + }) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Try Again") + } + .foregroundColor(.black) + .padding() + .background(Color("Accent")) + .cornerRadius(10) } + .disabled(isRefreshing) Spacer() + } + + case .empty: + VStack { + Spacer() + Image(systemName: "calendar.badge.exclamationmark") + .font(.system(size: 50)) + .foregroundColor(.secondary) + .padding(.bottom, 16) - Text("\(friend.name ?? friend.username)'s Timetable") - .font(Font.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(.white) + Text("No timetable available") + .font(Font.custom("Poppins-Bold", size: 24)) + .padding(.bottom, 8) - Spacer() + Text("\(friend.name ?? friend.username) hasn't shared their timetable yet") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) - + Spacer() } - .padding(.horizontal) - .padding(.bottom, 8) - switch viewModel.stage { - case .loading: - VStack { - Spacer() - ProgressView() - .scaleEffect(1.2) - Text("Loading \(friend.name ?? friend.username)'s 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("Couldn't load timetable") - .font(Font.custom("Poppins-Bold", size: 24)) - .padding(.bottom, 8) - - Text("Unable to fetch \(friend.name ?? friend.username)'s timetable") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.bottom, 20) - - Button(action: { - showingRefreshAlert = true - }) { + case .data: + VStack(spacing: 0) { + // Day selector + ScrollViewReader { proxy in + ScrollView(.horizontal) { HStack { - Image(systemName: "arrow.clockwise") - Text("Try Again") + 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) } - .foregroundColor(.white) - .padding() - .background(Color("Accent")) - .cornerRadius(10) } - .disabled(isRefreshing) - - Spacer() } + .background(Color("Secondary")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) - case .empty: - VStack { - Spacer() - Image(systemName: "calendar.badge.exclamationmark") - .font(.system(size: 50)) - .foregroundColor(.secondary) - .padding(.bottom, 16) - - Text("No timetable available") - .font(Font.custom("Poppins-Bold", size: 24)) - .padding(.bottom, 8) - - Text("\(friend.name ?? friend.username) hasn't shared their timetable yet") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Spacer() - } - case .data: - VStack(spacing: 0) { - // Day selector - 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) - } - } + 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("Your friend has no classes on \(daysOfWeek[viewModel.dayNo])") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } - .background(Color("Secondary")) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - - // Lectures list - 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("Your friend has no classes on \(daysOfWeek[viewModel.dayNo])") - .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 - } + 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) } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) } } } @@ -209,17 +208,8 @@ struct FriendsTimeTableView: View { .sheet(item: $selectedLecture) { lecture in LectureDetailView(lecture: lecture) } - .alert("Refresh Timetable", isPresented: $showingRefreshAlert) { - Button("Cancel", role: .cancel) { } - Button("Refresh", role: .destructive) { - Task { - await refreshTimetable() - } - } - } message: { - Text("This will fetch fresh data from the server. Continue?") - } - .navigationBarBackButtonHidden(true) + + .navigationBarHidden(true) .onAppear { logger.debug("FriendsTimeTableView appeared for friend: \(friend.username)") loadFriendsTimetable() @@ -338,11 +328,11 @@ extension FriendsTimeTableView { authToken: String ) async { do { - logger.info("Fetching friend's timetable from API") + logger.info("Fetching friend's timetable from API using /users/\(friendUsername) endpoint") - - let friendTimeTable = try await TimeTableAPIService.shared.getTimeTable( - with: friendUsername, + + let friendTimeTable = try await TimeTableAPIService.shared.getFriendTimeTable( + username: friendUsername, authToken: authToken ) @@ -354,7 +344,6 @@ extension FriendsTimeTableView { return } - self.timeTable = friendTimeTable changeDay() stage = .data @@ -363,6 +352,8 @@ extension FriendsTimeTableView { } catch { logger.error("Failed to fetch friend's timetable: \(error.localizedDescription)") + + stage = .error } } @@ -378,3 +369,61 @@ extension FriendsTimeTableView { } } } + + +extension FriendsTimeTableView { + enum Stage { + case loading + case data + case empty + case error + } +} + + +enum APIError: Error { + case serverError(code: String, message: String) + case networkError + case decodingError + case unauthorized + +} + + +extension TimeTableAPIService { + func getFriendTimeTable(username: String, authToken: String) async throws -> TimeTable { + guard let url = URL(string: "\(APIConstants.base_urlv3)users/\(username)") else { + throw APIError.networkError + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 500 { + + if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data), + errorResponse.code == "1811" { + throw APIError.serverError(code: errorResponse.code, message: errorResponse.error) + } + } + + guard httpResponse.statusCode == 200 else { + throw APIError.serverError(code: "UNKNOWN", message: "HTTP \(httpResponse.statusCode)") + } + } + + let decoder = JSONDecoder() + return try decoder.decode(TimeTable.self, from: data) + } +} + + +struct ErrorResponse: Codable { + let code: String + let error: String +} 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/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 057dc4f..23197d5 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -8,8 +8,7 @@ import SwiftData struct UserProfileSidebar: View { @Environment(AuthViewModel.self) private var authViewModel @Binding var isPresented: Bool - @State private var ghostMode: Bool = false - @State private var isUpdatingGhostMode: Bool = false + @Environment(\.modelContext) private var modelContext @State private var isLoggingOut: Bool = false @@ -65,31 +64,7 @@ struct UserProfileSidebar: View { Divider().background(Color.clear) - VStack(alignment: .leading, spacing: 4) { - Text("Ghost Mode") - .font(Font.custom("Poppins-Medium", size: 16)) - .foregroundColor(.white) - Text("(your timetable will be visible only to you)") - .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundColor(.white.opacity(0.7)) - - HStack { - Toggle("", isOn: $ghostMode) - .labelsHidden() - .toggleStyle(SwitchToggleStyle(tint: Color("Accent"))) - .disabled(isUpdatingGhostMode) - .padding(.top, 4) - .onChange(of: ghostMode) { oldValue, newValue in - updateGhostMode(enabled: newValue) - } - - if isUpdatingGhostMode { - ProgressView() - .scaleEffect(0.8) - .foregroundColor(.white) - } - } - } + Spacer() @@ -128,66 +103,13 @@ struct UserProfileSidebar: View { .transition(.move(edge: .trailing)) } .animation(.easeInOut(duration: 0.3), value: isPresented) - .onAppear { - loadGhostModeState() - } + } // MARK: - Ghost Mode Functions - private func loadGhostModeState() { - - let username = authViewModel.loggedInBackendUser?.username ?? "" - ghostMode = UserDefaults.standard.bool(forKey: "ghostMode_\(username)") - } - - private func updateGhostMode(enabled: Bool) { - guard let username = authViewModel.loggedInBackendUser?.username, - let token = authViewModel.loggedInBackendUser?.token else { - return - } - - isUpdatingGhostMode = true - - - let endpoint = enabled ? "ghost" : "alive" - let urlString = "\(APIConstants.base_urlv3)friends/\(endpoint)/\(username)" - - guard let url = URL(string: urlString) else { - isUpdatingGhostMode = false - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - URLSession.shared.dataTask(with: request) { data, response, error in - DispatchQueue.main.async { - isUpdatingGhostMode = false - - if let error = error { - print("Ghost mode update failed: \(error.localizedDescription)") - - ghostMode = !enabled - return - } - - if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 200 { - - UserDefaults.standard.set(enabled, forKey: "ghostMode_\(username)") - print("Ghost mode \(enabled ? "enabled" : "disabled") successfully") - } else { - print("Ghost mode update failed with status code: \(httpResponse.statusCode)") - - ghostMode = !enabled - } - } - } - }.resume() - } + + private func performLogout() async { isLoggingOut = true @@ -220,8 +142,6 @@ struct UserProfileSidebar: View { print("Failed to delete local data: \(error)") } }.value - } catch { - print("Failed to clear local data: \(error)") } } } diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index 84046c8..eb3ad9d 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -25,18 +25,20 @@ struct VITTYApp: App { @State private var showJoinCircleAlert = false @State private var pendingCircleInvite: (code: String, circleName: String?)? - @State private var isProcessingDeepLink = false + @State private var isLaunchedFromWidget = false - @StateObject private var navigationCoordinator = NavigationCoordinator() - - @StateObject private var toastManager = ToastManager() init() { setupFirebase() NotificationManager.shared.requestAuthorization() + + + if ProcessInfo.processInfo.environment["LAUNCHED_FROM_WIDGET"] != nil { + self._isLaunchedFromWidget = State(initialValue: true) + } } var body: some Scene { @@ -47,6 +49,11 @@ struct VITTYApp: App { .environmentObject(navigationCoordinator) .task { try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)]) + + + if isLaunchedFromWidget { + await handleWidgetLaunch() + } } .onOpenURL { url in handleDeepLink(url) @@ -67,7 +74,6 @@ struct VITTYApp: App { } } - if toastManager.isShowing { CircleToastView( message: toastManager.message, @@ -85,10 +91,44 @@ struct VITTYApp: App { var sharedModelContainer: ModelContainer { let schema = Schema([TimeTable.self, Remainder.self, CreateNoteModel.self, UploadedFile.self]) + + + let appGroupContainerID = "group.com.gdscvit.vittyioswidget" let config = ModelConfiguration( - "group.com.gdscvit.vittyioswidget" + appGroupContainerID, + schema: schema, + isStoredInMemoryOnly: false, + allowsSave: true ) - return try! ModelContainer(for: schema, configurations: config) + + do { + let container = try ModelContainer(for: schema, configurations: config) + logger.info("Model container created successfully with app group: \(appGroupContainerID)") + return container + } catch { + logger.error(" Failed to create model container: \(error)") + fatalError("Failed to create model container: \(error)") + } + } + + // MARK: - Widget Launch Handling + + private func handleWidgetLaunch() async { + logger.info(" App launched from widget, performing data sync...") + + + try? await Task.sleep(nanoseconds: 500_000_000) + + + await MainActor.run { + NotificationCenter.default.post( + name: Notification.Name("RefreshTimetableFromWidget"), + object: nil + ) + } + + + isLaunchedFromWidget = false } } @@ -101,7 +141,6 @@ class ToastManager: ObservableObject { private var hideTimer: Timer? init() { - NotificationCenter.default.addObserver( self, selector: #selector(showToastNotification), @@ -126,7 +165,6 @@ class ToastManager: ObservableObject { self.isError = isError self.isShowing = true - self.hideTimer?.invalidate() self.hideTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in self.hideToast() @@ -208,7 +246,6 @@ extension VITTYApp { private func handleDeepLink(_ url: URL) { logger.info("Deep link received: \(url.absoluteString)") - guard !isProcessingDeepLink else { logger.info("Already processing a deep link, ignoring") return @@ -247,12 +284,10 @@ extension VITTYApp { logger.info("Parsed circle name: \(name)") } - navigationCoordinator.navigateToCirclesForInvite(code: code, circleName: circleName) let invite = (code: code, circleName: circleName) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.pendingCircleInvite = invite self.showJoinCircleAlert = true @@ -317,7 +352,6 @@ extension VITTYApp { let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.impactOccurred() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { NotificationCenter.default.post( name: Notification.Name("CircleJoinedSuccessfully"), @@ -350,7 +384,6 @@ extension VITTYApp { // MARK: - Helper Methods - private func cleanup() { pendingCircleInvite = nil isProcessingDeepLink = false @@ -377,7 +410,6 @@ extension VITTYApp { } private func showToast(message: String, isError: Bool) { - // NEW: Use ToastManager directly toastManager.showToast(message: message, isError: isError) if isError { diff --git a/VITTY/VittyWidget/Providers/ScheduleProvider.swift b/VITTY/VittyWidget/Providers/ScheduleProvider.swift index 3495ea2..a8f2064 100644 --- a/VITTY/VittyWidget/Providers/ScheduleProvider.swift +++ b/VITTY/VittyWidget/Providers/ScheduleProvider.swift @@ -14,11 +14,27 @@ struct Provider: TimelineProvider { private func getSharedContainer() -> ModelContainer? { let appGroupContainerID = "group.com.gdscvit.vittyioswidget" - let config = ModelConfiguration(appGroupContainerID) - return try? ModelContainer(for: TimeTable.self, configurations: config) + + let schema = Schema([TimeTable.self, Remainder.self, CreateNoteModel.self, UploadedFile.self]) + + let config = ModelConfiguration( + appGroupContainerID, + schema: schema, + isStoredInMemoryOnly: false, + allowsSave: true + ) + + do { + + return try ModelContainer(for: schema, configurations: config) + } catch { + print("Failed to create shared container: \(error)") + return nil + } } + // MARK: - Time Parsing and Validation private func parseTimeString(_ timeString: String) -> Date? { @@ -124,13 +140,12 @@ struct Provider: TimelineProvider { let completedCount = calculateCompletedClassesCount() var displayClasses: [Classes] = [] - - // Add current class first + if let current = currentClass { displayClasses.append(current) } - // Add upcoming classes + displayClasses.append(contentsOf: upcomingClasses) return ( @@ -189,7 +204,7 @@ struct Provider: TimelineProvider { dateFormatter.locale = Locale(identifier: "en_US_POSIX") let calendar = Calendar.current - // Find the current or next active class + var pivotIndex = 0 for (index, classItem) in sortedClasses.enumerated() { @@ -200,7 +215,7 @@ struct Provider: TimelineProvider { } } - // Create groups of 4 starting from the beginning + let groupSize = 4 let currentGroupIndex = pivotIndex / groupSize let startIndex = currentGroupIndex * groupSize @@ -225,7 +240,7 @@ struct Provider: TimelineProvider { func getSnapshot(in context: Context, completion: @escaping (ScheduleEntry) -> ()) { let content: (classes: [Classes], total: Int, completed: Int) - // Use different content preparation based on widget size + switch context.family { case .systemLarge: content = prepareLargeContent() @@ -245,7 +260,7 @@ struct Provider: TimelineProvider { let content: (classes: [Classes], total: Int, completed: Int) let currentTime = Date() - // Use different content preparation based on widget size + switch context.family { case .systemLarge: content = prepareLargeContent() @@ -260,7 +275,7 @@ struct Provider: TimelineProvider { completed: content.completed ) - // Calculate next refresh time based on widget size + let nextRefreshTime: Date switch context.family { case .systemLarge: @@ -278,20 +293,20 @@ struct Provider: TimelineProvider { private func calculateNextRefreshTime(currentTime: Date, classes: [Classes]) -> Date { let calendar = Calendar.current - // Find next significant time (class start/end) + var nextSignificantTime: Date? for classItem in classes { let (startTime, endTime) = parseClassTime(classItem.time) - // Check for next class start + if let start = startTime, start > currentTime { if nextSignificantTime == nil || start < nextSignificantTime! { nextSignificantTime = start } } - // Check for current class end + if let end = endTime, end > currentTime { if nextSignificantTime == nil || end < nextSignificantTime! { nextSignificantTime = end @@ -299,7 +314,7 @@ struct Provider: TimelineProvider { } } - // Use significant time if found, otherwise refresh in 15 minutes + if let significantTime = nextSignificantTime { return significantTime } @@ -312,23 +327,23 @@ struct Provider: TimelineProvider { let allClasses = fetchAllTodaysClasses() let sortedClasses = getSortedClasses(allClasses) - // Find when the current group of 4 will change + let currentGroupIndex = getCurrentGroupIndex(sortedClasses, currentTime: currentTime) let groupSize = 4 let currentGroupStart = currentGroupIndex * groupSize let currentGroupEnd = min(currentGroupStart + groupSize, sortedClasses.count) - // Find the next significant time that would cause a group change + var nextGroupChangeTime: Date? - // Check if we need to move to the next group when current classes in this group end + for i in currentGroupStart.. currentTime { - // Check if this would trigger a group change + if isLastClassInGroup(index: i, groupSize: groupSize, totalClasses: sortedClasses.count) { if nextGroupChangeTime == nil || end < nextGroupChangeTime! { nextGroupChangeTime = end @@ -338,19 +353,19 @@ struct Provider: TimelineProvider { } } - // If no group change time found, use regular refresh timing + if let groupChangeTime = nextGroupChangeTime { return groupChangeTime } - // Fallback to regular refresh timing + return calculateNextRefreshTime(currentTime: currentTime, classes: classes) } private func getCurrentGroupIndex(_ sortedClasses: [Classes], currentTime: Date) -> Int { let groupSize = 4 - // Find the current or next active class + var pivotIndex = 0 for (index, classItem) in sortedClasses.enumerated() { diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index a4ada53..e6c87ef 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -4,10 +4,10 @@ // // Created by Rujin Devkota on 2/25/25. // + import SwiftUI import WidgetKit - struct LargeDueWidgetView: View { var entry: SmartDueEntry private let provider = RemindersProvider() @@ -19,7 +19,6 @@ struct LargeDueWidgetView: View { if !entry.isEmpty { let categorizedAssignments = provider.categorizeAssignmentsForLargeWidget(entry.assignments, primaryTitle: entry.widgetTitle) - VStack(alignment: .leading, spacing: 6) { Spacer().frame(height: 3) @@ -30,7 +29,6 @@ struct LargeDueWidgetView: View { } } - if !categorizedAssignments.secondary.isEmpty, let secondaryTitle = categorizedAssignments.secondaryTitle { Spacer().frame(height: 5) VStack(alignment: .leading, spacing: 6) { @@ -92,7 +90,6 @@ struct ScheduleLargeWidgetView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if entry.completed == entry.total { - VStack { Spacer() CircleProgressView( @@ -123,7 +120,6 @@ struct ScheduleLargeWidgetView: View { } .frame(maxWidth: .infinity) } else { - VStack { Spacer() CircleProgressView( @@ -141,16 +137,16 @@ struct ScheduleLargeWidgetView: View { Image("fourclassesline") VStack(alignment: .leading, spacing: 20) { - let displayClasses = getDisplayClasses() - - ForEach(displayClasses, id: \.title) { classItem in + + ForEach(entry.classes, id: \.title) { classItem in ScheduleItemView( title: classItem.title, time: "\(classItem.time) | \(classItem.slot ?? "")" ) } - let remainingCount = getUpcomingClasses().count - displayClasses.count + + let remainingCount = getRemainingClassesCount() if remainingCount > 0 { Text("+\(remainingCount) More") .foregroundColor(.white.opacity(0.6)) @@ -167,66 +163,10 @@ struct ScheduleLargeWidgetView: View { .padding(.vertical, 6).ignoresSafeArea() } - private func getUpcomingClasses() -> [Classes] { - let currentTime = Date() - let calendar = Calendar.current - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "h:mm a" - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - - - let sortedClasses = entry.classes.sorted { class1, class2 in - let time1Components = class1.time.components(separatedBy: " - ") - let time2Components = class2.time.components(separatedBy: " - ") - - guard time1Components.count == 2, time2Components.count == 2 else { - return false - } - - let startTime1Str = time1Components[0].trimmingCharacters(in: .whitespaces) - let startTime2Str = time2Components[0].trimmingCharacters(in: .whitespaces) - - guard let startTime1 = dateFormatter.date(from: startTime1Str), - let startTime2 = dateFormatter.date(from: startTime2Str) else { - return false - } - - return startTime1 < startTime2 - } - - - let now = Date() - let upcomingClasses = sortedClasses.filter { classItem in - let timeComponents = classItem.time.components(separatedBy: " - ") - guard timeComponents.count == 2 else { return true } - - let endTimeStr = timeComponents[1].trimmingCharacters(in: .whitespaces) - guard let endTime = dateFormatter.date(from: endTimeStr) else { return true } - - - let todayEnd = calendar.date( - bySettingHour: calendar.component(.hour, from: endTime), - minute: calendar.component(.minute, from: endTime), - second: 0, - of: now - ) - - - if let todayEnd = todayEnd { - return now <= todayEnd - } - - return true - } - - return upcomingClasses - } - private func getDisplayClasses() -> [Classes] { - let upcomingClasses = getUpcomingClasses() - - - let maxDisplay = min(4, upcomingClasses.count) - return Array(upcomingClasses.prefix(maxDisplay)) + private func getRemainingClassesCount() -> Int { + let totalUpcoming = entry.total - entry.completed + let currentBatchSize = entry.classes.count + return max(0, totalUpcoming - currentBatchSize) } }