From 3449a0aeb3c13df926c349c449156c82de0ada30 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sat, 5 Jul 2025 16:40:02 +0545 Subject: [PATCH 01/10] fix : merge issues --- .../xcshareddata/swiftpm/Package.resolved | 2 +- VITTY/VITTY/Academics/View/CourseRefs.swift | 580 +----------------- VITTY/VITTY/Academics/View/Notes.swift | 196 ------ .../VITTY/Academics/View/RemindersData.swift | 9 - .../VITTY/Auth/ViewModels/AuthViewModel.swift | 7 - VITTY/VITTY/Connect/Models/CircleModel.swift | 1 - .../View/Circles/Components/CirclesRow.swift | 59 -- .../View/Circles/Components/CreateGroup.swift | 5 - .../View/Circles/Components/JoinGroup.swift | 19 - .../View/Circles/View/InsideCircle.swift | 5 - VITTY/VITTY/Connect/View/ConnectPage.swift | 12 - .../Connect/View/Freinds/View/Freinds.swift | 37 -- .../ViewModel/CommunityPageViewModel.swift | 76 --- .../ViewModel/SettingsViewModel.swift | 35 -- VITTY/VITTY/TimeTable/Models/TimeTable.swift | 46 -- .../ViewModel/TimeTableViewModel.swift | 14 - .../TimeTable/Views/LectureDetailView.swift | 11 - .../VITTY/TimeTable/Views/TimeTableView.swift | 25 - VITTY/VITTY/UserProfileSideBar/SideBar.swift | 98 --- 19 files changed, 22 insertions(+), 1215 deletions(-) diff --git a/VITTY/VITTY.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/VITTY/VITTY.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ad0468c..898596b 100644 --- a/VITTY/VITTY.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/VITTY/VITTY.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5b3893af96db1c7a5e8d26d4b6f7b87fb1b4833e12c80c07842b211a8ba6e753", + "originHash" : "639d5d90ba6550e8200b28dab400da314adf7395e4d9c9622a0c3a880ab8780b", "pins" : [ { "identity" : "abseil-cpp-binary", diff --git a/VITTY/VITTY/Academics/View/CourseRefs.swift b/VITTY/VITTY/Academics/View/CourseRefs.swift index e43f3ab..615a5eb 100644 --- a/VITTY/VITTY/Academics/View/CourseRefs.swift +++ b/VITTY/VITTY/Academics/View/CourseRefs.swift @@ -5,17 +5,9 @@ // Created by Rujin Devkota on 2/27/25. -// -// Academics.swift -// VITTY -// -// Created by Rujin Devkota on 2/27/25. - - import SwiftUI import SwiftData -struct OCourseRefs: View { struct OCourseRefs: View { var courseName: String var courseInstitution: String @@ -25,7 +17,6 @@ struct OCourseRefs: View { @State private var showBottomSheet = false @State private var showReminderSheet = false @State private var showNotes = false - @State private var showNotes = false @State private var navigateToNotesEditor = false @State var showCourseNotes: Bool = false @State private var selectedNote: CreateNoteModel? @@ -45,7 +36,6 @@ struct OCourseRefs: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext - @Environment(\.modelContext) private var modelContext private let maxVisible = 4 @@ -53,19 +43,6 @@ struct OCourseRefs: View { @Query private var courseNotes: [CreateNoteModel] @Query private var courseFiles: [UploadedFile] - enum ContentType: String, CaseIterable { - case notes = "Notes" - case files = "Files" - - var icon: String { - switch self { - case .notes: return "doc.text" - case .files: return "folder" - } - } - } - @Query private var courseFiles: [UploadedFile] - enum ContentType: String, CaseIterable { case notes = "Notes" case files = "Files" @@ -93,7 +70,6 @@ struct OCourseRefs: View { let notesPredicate = #Predicate { $0.courseId == courseCode - $0.courseId == courseCode } _courseNotes = Query( FetchDescriptor(predicate: notesPredicate, sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) @@ -118,34 +94,6 @@ struct OCourseRefs: View { } } - private var filteredFiles: [UploadedFile] { - if searchText.isEmpty { - return courseFiles - } else { - return courseFiles.filter { file in - file.fileName.localizedCaseInsensitiveContains(searchText) - } - } - - - let filesPredicate = #Predicate { - $0.courseCode == courseCode - } - _courseFiles = Query( - FetchDescriptor(predicate: filesPredicate, sortBy: [SortDescriptor(\.uploadDate, order: .reverse)]) - ) - } - - private var filteredNotes: [CreateNoteModel] { - if searchText.isEmpty { - return courseNotes - } else { - return courseNotes.filter { note in - note.noteName.localizedCaseInsensitiveContains(searchText) - } - } - } - private var filteredFiles: [UploadedFile] { if searchText.isEmpty { return courseFiles @@ -180,14 +128,6 @@ struct OCourseRefs: View { Spacer() - if selectedContentType == .files && !courseFiles.isEmpty { - Button("View All") { - showFileGallery = true - } - .font(.system(size: 14, weight: .medium)) - .foregroundColor(Color("Secondary")) - - if selectedContentType == .files && !courseFiles.isEmpty { Button("View All") { showFileGallery = true @@ -200,7 +140,6 @@ struct OCourseRefs: View { HStack { Spacer() - TextField(selectedContentType == .notes ? "Search notes..." : "Search files...", text: $searchText) TextField(selectedContentType == .notes ? "Search notes..." : "Search files...", text: $searchText) .padding(10) .frame(width: UIScreen.main.bounds.width * 0.85) @@ -208,18 +147,12 @@ struct OCourseRefs: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) .foregroundColor(.white) - .foregroundColor(.white) Spacer() } - Spacer().frame(height: 15) - - - - Spacer().frame(height: 15) Text("\(courseName) - \(courseInstitution)") @@ -245,24 +178,6 @@ struct OCourseRefs: View { } .padding(.horizontal) .padding(.top, 10) - - HStack(spacing: 12) { - ForEach(ContentType.allCases, id: \.self) { contentType in - ContentTypeTab( - contentType: contentType, - isSelected: selectedContentType == contentType, - count: contentType == .notes ? filteredNotes.count : filteredFiles.count - ) { - withAnimation(.easeInOut(duration: 0.2)) { - selectedContentType = contentType - searchText = "" - } - } - } - Spacer() - } - .padding(.horizontal) - .padding(.top, 10) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -284,16 +199,6 @@ struct OCourseRefs: View { VStack(alignment: .leading, spacing: 15) { if selectedContentType == .notes { - if filteredNotes.isEmpty { - EmptyStateView( - icon: searchText.isEmpty ? "doc.text" : "magnifyingglass", - title: searchText.isEmpty ? "No notes found for this course" : "No notes match your search", - subtitle: searchText.isEmpty ? nil : "Try searching with different keywords" - ) - } else { - ForEach(filteredNotes, id: \.createdAt) { note in - if selectedContentType == .notes { - if filteredNotes.isEmpty { EmptyStateView( icon: searchText.isEmpty ? "doc.text" : "magnifyingglass", @@ -339,50 +244,6 @@ struct OCourseRefs: View { } } - if filteredFiles.count > 6 { - Button("View All \(filteredFiles.count) Files") { - showFileGallery = true - } - .font(.system(size: 14, weight: .medium)) - .foregroundColor(Color("Secondary")) - .padding(.top, 8) - .frame(maxWidth: .infinity) - } - description: note.cachedPlainText, - isLoading: loadingNoteId == note.createdAt, - onDelete: { - noteToDelete = note - showDeleteAlert = true - } - ) - .onTapGesture { - openNote(note) - } - } - } - } else { - - if filteredFiles.isEmpty { - EmptyStateView( - icon: searchText.isEmpty ? "folder" : "magnifyingglass", - title: searchText.isEmpty ? "No files found for this course" : "No files match your search", - subtitle: searchText.isEmpty ? "Upload some files to get started" : "Try searching with different keywords" - ) - } else { - LazyVGrid(columns: [ - GridItem(.flexible()), - GridItem(.flexible()) - ], spacing: 12) { - ForEach(Array(filteredFiles.prefix(6)), id: \.id) { file in - CompactFileCard(file: file) { - - - showimgDeleteAlert = true - fileToDelete = file - } - } - } - if filteredFiles.count > 6 { Button("View All \(filteredFiles.count) Files") { showFileGallery = true @@ -535,27 +396,9 @@ struct OCourseRefs: View { .sheet(isPresented: $showFileGallery) { FileGalleryView(courseCode: courseCode) } - .sheet(isPresented: $showFileUpload) { - FileUploadView(courseName: courseName, courseCode: courseCode) - } - .sheet(isPresented: $showFileGallery) { - FileGalleryView(courseCode: courseCode) - } .navigationDestination(isPresented: $navigateToNotesEditor) { NoteEditorView(courseCode: courseCode, courseName: courseName, courseIns: courseInstitution, courseSlot: slot) } - .sheet(isPresented: $showNotes, content: { - NoteEditorView( - existingNote: selectedNote, - preloadedAttributedString: preloadedAttributedString, - courseCode: courseCode, - courseName: courseName, - courseIns: courseInstitution, - courseSlot: slot - ) - }) - NoteEditorView(courseCode: courseCode, courseName: courseName, courseIns: courseInstitution, courseSlot: slot) - } .sheet(isPresented: $showNotes, content: { NoteEditorView( existingNote: selectedNote, @@ -963,361 +806,37 @@ struct CompactFileCard: View { enum NoteLoadingError: Error { case invalidData case unarchiveFailed - guard let data = Data(base64Encoded: note.noteContent) else { - throw NoteLoadingError.invalidData - } - - if let attributedString = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: data) { - return attributedString - } else { - throw NoteLoadingError.unarchiveFailed - } - } - - private func deleteNote() { - guard let note = noteToDelete else { return } - - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.impactOccurred() - - modelContext.delete(note) - - do { - try modelContext.save() - } catch { - print("Failed to delete note: \(error)") - } - - showDeleteAlert = false - noteToDelete = nil - } - - private func deleteFile(){ - guard let file = fileToDelete else{return} - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.impactOccurred() - - modelContext.delete(file) - do { - try modelContext.save() - } catch { - print("Failed to delete file: \(error)") - } - showimgDeleteAlert = false - - } - } - -struct ContentTypeTab: View { - let contentType: OCourseRefs.ContentType - let isSelected: Bool - let count: Int - let action: () -> Void +struct BottomSheetButton: View { + var icon: String + var title: String + var action: (() -> Void)? = nil var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: contentType.icon) - .font(.system(size: 14)) - - Text(contentType.rawValue) - .font(.system(size: 14, weight: .medium)) - - if count > 0 { - Text("(\(count))") - .font(.system(size: 12)) - .opacity(0.8) - } + Button(action: { + action?() + }) { + VStack { + Image(icon) + .font(.title) + .padding() + .background(Color.white) + .clipShape(Circle()) + Text(title) + .font(.footnote) + .foregroundColor(.white) } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(isSelected ? Color("Accent") : Color("Secondary")) - .foregroundColor(isSelected ? .black : .white) - .cornerRadius(20) + .frame(maxWidth: .infinity) + .padding(.bottom, 10) } } } -struct EmptyStateView: View { - let icon: String - let title: String - let subtitle: String? - - var body: some View { - VStack(spacing: 16) { - Image(systemName: icon) - .font(.system(size: 48)) - .foregroundColor(.gray.opacity(0.6)) - - Text(title) - .foregroundColor(.gray) - .font(.system(size: 16, weight: .medium)) - .multilineTextAlignment(.center) - - if let subtitle = subtitle { - Text(subtitle) - .foregroundColor(.gray.opacity(0.8)) - .font(.system(size: 14)) - .multilineTextAlignment(.center) - } - } - .frame(maxWidth: .infinity) - .padding(.top, 60) - } -} -struct CompactFileCard: View { - let file: UploadedFile - let onDelete: (() -> Void)? - - @State private var showFileViewer = false - @State private var showActionSheet = false - @State private var fileImage: UIImage? - @State private var imageLoadError = false - @State private var isLoading = true - - init(file: UploadedFile, onDelete: (() -> Void)? = nil) { - self.file = file - self.onDelete = onDelete - } - - var body: some View { - VStack(spacing: 8) { - - if file.isImage && !imageLoadError { - Group { - if let image = fileImage { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - } else if isLoading { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .overlay( - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .scaleEffect(0.8) - ) - } else { - Rectangle() - .fill(Color.red.opacity(0.3)) - .overlay( - VStack(spacing: 4) { - Image(systemName: "exclamationmark.triangle") - .foregroundColor(.red) - .font(.system(size: 16)) - Text("Not found") - .font(.caption2) - .foregroundColor(.red) - } - ) - } - } - .frame(height: 80) - .clipped() - .cornerRadius(8) - } else { - Rectangle() - .fill(getFileTypeColor(file.fileType).opacity(0.2)) - .frame(height: 80) - .overlay( - VStack(spacing: 4) { - Image(systemName: getFileTypeIcon(file.fileType)) - .font(.system(size: 24)) - .foregroundColor(getFileTypeColor(file.fileType)) - - Text(file.fileType.uppercased()) - .font(.caption2) - .fontWeight(.bold) - .foregroundColor(getFileTypeColor(file.fileType)) - } - ) - .cornerRadius(8) - } - - - VStack(alignment: .leading, spacing: 2) { - Text(file.fileName) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.white) - .lineLimit(2) - .multilineTextAlignment(.leading) - - Text(FileManagerHelper.shared.formatFileSize(file.fileSize)) - .font(.caption2) - .foregroundColor(.gray) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .onTapGesture { - showFileViewer = true - } - .onLongPressGesture(minimumDuration: 0.5) { - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.impactOccurred() - showActionSheet = true - } - .onAppear { - if file.isImage { - loadImageFile() - } - } - .sheet(isPresented: $showFileViewer) { - EnhancedFileViewerSheet(file: file) - } - .confirmationDialog("File Options", isPresented: $showActionSheet, titleVisibility: .visible) { - Button("Share") { - shareFile() - } - - if let onDelete = onDelete { - Button("Delete", role: .destructive) { - onDelete() - } - } - - Button("Cancel", role: .cancel) {} - } message: { - Text("Choose an action for \(file.fileName)") - } - } - - // MARK: - File Loading Methods - - private func loadImageFile() { - isLoading = true - imageLoadError = false - - Task { - let imagePaths = [file.thumbnailPath, file.localPath].compactMap { $0 } - var loadedImage: UIImage? - - for path in imagePaths { - if let data = FileManagerHelper.shared.loadFileWithFallback(from: path, courseCode: file.courseCode), - let image = UIImage(data: data) { - loadedImage = image - break - } - } - - await MainActor.run { - if let image = loadedImage { - self.fileImage = image - self.imageLoadError = false - } else { - self.imageLoadError = true - } - self.isLoading = false - } - } - } - - private func shareFile() { - guard let data = FileManagerHelper.shared.loadFileWithFallback(from: file.localPath, courseCode: file.courseCode) else { - print("Cannot share file: File not found") - return - } - - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(file.fileName) - - do { - if FileManager.default.fileExists(atPath: tempURL.path) { - try FileManager.default.removeItem(at: tempURL) - } - try data.write(to: tempURL) - - let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootViewController = window.rootViewController { - - if let popover = activityVC.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect(x: window.bounds.midX, y: window.bounds.midY, width: 0, height: 0) - popover.permittedArrowDirections = [] - } - - rootViewController.present(activityVC, animated: true) - } - } catch { - print("Error sharing file: \(error)") - } - } - - private func getFileTypeIcon(_ fileType: String) -> String { - switch fileType.lowercased() { - case "pdf": - return "doc.richtext.fill" - case "txt": - return "doc.text.fill" - case "rtf", "rtfd": - return "doc.richtext.fill" - case "doc", "docx": - return "doc.fill" - case "jpg", "jpeg", "png", "gif", "heic": - return "photo.fill" - default: - return "doc.fill" - } - } - - private func getFileTypeColor(_ fileType: String) -> Color { - switch fileType.lowercased() { - case "pdf": - return .red - case "txt": - return .blue - case "rtf", "rtfd": - return .purple - case "doc", "docx": - return .blue - case "jpg", "jpeg", "png", "gif", "heic": - return .green - default: - return .gray - } - } -} - -// MARK: - Error Handling -enum NoteLoadingError: Error { - case invalidData - case unarchiveFailed -} - - -struct BottomSheetButton: View { - var icon: String - var title: String - var action: (() -> Void)? = nil - - var body: some View { - Button(action: { - action?() - }) { - VStack { - Image(icon) - .font(.title) - .padding() - .background(Color.white) - .clipShape(Circle()) - Text(title) - .font(.footnote) - .foregroundColor(.white) - } - .frame(maxWidth: .infinity) - .padding(.bottom, 10) - } - } -} - -struct TagView: View { - var reminder: Remainder - +struct TagView: View { + var reminder: Remainder + var body: some View { HStack { Circle() @@ -1346,7 +865,6 @@ struct TagView: View { } } - struct MoreTagView: View { var count: Int @@ -1369,11 +887,6 @@ struct CourseCardNotes: View { @State private var showComingSoonAlert = false - var isLoading: Bool = false - var onDelete: () -> Void - - @State private var showComingSoonAlert = false - var body: some View { HStack { VStack(alignment: .leading, spacing: 6) { @@ -1389,52 +902,6 @@ struct CourseCardNotes: View { Spacer() - if isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .scaleEffect(0.8) - .padding(.trailing, 8) - } else { - Menu { - Button(role: .destructive) { - let feedback = UISelectionFeedbackGenerator() - feedback.selectionChanged() - onDelete() - } label: { - Label("Delete", systemImage: "trash") - } - - Button { - let feedback = UISelectionFeedbackGenerator() - feedback.selectionChanged() - showComingSoonAlert = true - } label: { - Label("Export Markdown", systemImage: "square.and.arrow.down") - } - - } label: { - Image(systemName: "ellipsis") - .rotationEffect(.degrees(90)) - .foregroundColor(.white) - .font(.system(size: 20, weight: .medium)) - .padding(8) - .clipShape(Circle()) - } - } - HStack { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.headline) - .foregroundColor(.white) - - Text(description) - .font(.subheadline) - .foregroundColor(.gray) - .lineLimit(2) - } - - Spacer() - if isLoading { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) @@ -1477,11 +944,6 @@ struct CourseCardNotes: View { .alert("Feature coming soon", isPresented: $showComingSoonAlert) { Button("OK", role: .cancel) { } } - .opacity(isLoading ? 0.7 : 1.0) - .animation(.easeInOut(duration: 0.2), value: isLoading) - .alert("Feature coming soon", isPresented: $showComingSoonAlert) { - Button("OK", role: .cancel) { } - } } } diff --git a/VITTY/VITTY/Academics/View/Notes.swift b/VITTY/VITTY/Academics/View/Notes.swift index 50465eb..0058060 100644 --- a/VITTY/VITTY/Academics/View/Notes.swift +++ b/VITTY/VITTY/Academics/View/Notes.swift @@ -4,12 +4,6 @@ // // Created by Rujin Devkota on 2/27/25. -// -// Academics.swift -// VITTY -// -// Created by Rujin Devkota on 2/27/25. - import SwiftUI import UIKit @@ -19,7 +13,6 @@ struct RichTextView: UIViewRepresentable { @Binding var typingAttributes: [NSAttributedString.Key: Any] @Binding var isEmpty: Bool - func makeUIView(context: Context) -> UITextView { let textView = UITextView() @@ -32,7 +25,6 @@ struct RichTextView: UIViewRepresentable { textView.textColor = .white - textView.attributedText = attributedText textView.selectedRange = selectedRange @@ -41,33 +33,26 @@ struct RichTextView: UIViewRepresentable { func updateUIView(_ uiView: UITextView, context: Context) { - if context.coordinator.isUpdating { return } - if !uiView.attributedText.isEqual(to: attributedText) { let previousSelectedRange = uiView.selectedRange context.coordinator.isUpdating = true uiView.attributedText = attributedText - if previousSelectedRange.location <= uiView.attributedText.length { let maxRange = min(previousSelectedRange.location + previousSelectedRange.length, uiView.attributedText.length) let validRange = NSRange(location: previousSelectedRange.location, length: maxRange - previousSelectedRange.location) uiView.selectedRange = validRange - let maxRange = min(previousSelectedRange.location + previousSelectedRange.length, uiView.attributedText.length) - let validRange = NSRange(location: previousSelectedRange.location, length: maxRange - previousSelectedRange.location) - uiView.selectedRange = validRange } context.coordinator.isUpdating = false } - if !NSEqualRanges(uiView.selectedRange, selectedRange) && selectedRange.location <= uiView.attributedText.length && NSMaxRange(selectedRange) <= uiView.attributedText.length { @@ -77,7 +62,6 @@ struct RichTextView: UIViewRepresentable { } - if !NSDictionary(dictionary: uiView.typingAttributes).isEqual(to: typingAttributes) { uiView.typingAttributes = typingAttributes } @@ -97,27 +81,21 @@ struct RichTextView: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { - guard !isUpdating else { return } isUpdating = true defer { isUpdating = false } - parent.attributedText = NSMutableAttributedString(attributedString: textView.attributedText) parent.isEmpty = textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - parent.typingAttributes = textView.typingAttributes - - parent.typingAttributes = textView.typingAttributes } func textViewDidChangeSelection(_ textView: UITextView) { - guard !isUpdating else { return } isUpdating = true @@ -140,28 +118,11 @@ struct RichTextView: UIViewRepresentable { - - - if textView.selectedRange.length == 0 && textView.selectedRange.location > 0 { - - let location = min(textView.selectedRange.location - 1, textView.attributedText.length - 1) - if location >= 0 { - let attributes = textView.attributedText.attributes(at: location, effectiveRange: nil) - parent.typingAttributes = attributes - } - } - } - } -} - - - struct NoteEditorView: View { @Environment(\.dismiss) private var dismiss @Environment(AcademicsViewModel.self) private var academicsViewModel @Environment(AuthViewModel.self) private var authViewModel @Environment(\.presentationMode) var presentationMode - @Environment(\.presentationMode) var presentationMode @State private var attributedText = NSMutableAttributedString() @State private var selectedRange = NSRange(location: 0, length: 0) @@ -172,7 +133,6 @@ struct NoteEditorView: View { let existingNote: CreateNoteModel? let preloadedAttributedString: NSAttributedString? // Pre-processed content - let preloadedAttributedString: NSAttributedString? // Pre-processed content @State private var selectedFont: UIFont = UIFont.systemFont(ofSize: 18) @State private var selectedColor: Color = .white @State private var showFontPicker = false @@ -181,21 +141,16 @@ struct NoteEditorView: View { @State private var hasUnsavedChanges = false @State private var isInitialized = false @State private var goback = false - @State private var goback = false @Environment(\.modelContext) private var modelContext let courseCode: String let courseName: String let courseIns : String let courseSlot : String - let courseIns : String - let courseSlot : String - init(existingNote: CreateNoteModel? = nil, preloadedAttributedString: NSAttributedString? = nil, courseCode: String, courseName: String,courseIns: String , courseSlot: String) { init(existingNote: CreateNoteModel? = nil, preloadedAttributedString: NSAttributedString? = nil, courseCode: String, courseName: String,courseIns: String , courseSlot: String) { self.existingNote = existingNote self.preloadedAttributedString = preloadedAttributedString - self.preloadedAttributedString = preloadedAttributedString self.courseCode = existingNote?.courseId ?? courseCode self.courseName = existingNote?.courseName ?? courseName self.courseIns = courseIns @@ -221,17 +176,6 @@ struct NoteEditorView: View { isInitialized = true } else { - Task { @MainActor in - await loadNoteContent(note) - isInitialized = true - } - if let preloaded = preloadedAttributedString { - - attributedText = NSMutableAttributedString(attributedString: preloaded) - isEmpty = preloaded.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - isInitialized = true - } else { - Task { @MainActor in await loadNoteContent(note) isInitialized = true @@ -239,7 +183,6 @@ struct NoteEditorView: View { } } else { - attributedText = NSMutableAttributedString() isEmpty = true isInitialized = true @@ -256,15 +199,6 @@ struct NoteEditorView: View { } - do { - - if let cachedAttributedString = note.cachedAttributedString { - attributedText = NSMutableAttributedString(attributedString: cachedAttributedString) - isEmpty = cachedAttributedString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - return - } - - do { guard let data = Data(base64Encoded: note.noteContent) else { print("Failed to decode base64 data") @@ -290,12 +224,10 @@ struct NoteEditorView: View { func saveContent() { guard hasUnsavedChanges || existingNote == nil else { - handleBackNavigation() handleBackNavigation() return } - do { let data = try NSKeyedArchiver.archivedData(withRootObject: attributedText, requiringSecureCoding: false) let dataString = data.base64EncodedString() @@ -307,8 +239,6 @@ struct NoteEditorView: View { note.createdAt = Date.now CreateNoteModel.clearCache() - - CreateNoteModel.clearCache() } else { let newNote = CreateNoteModel( noteName: title, @@ -356,26 +286,21 @@ struct NoteEditorView: View { if isInitialized { VStack { - headerView - textEditorView - toolbarView } } else { - ProgressView("Loading...") .foregroundColor(.white) } - if showFontPicker { fontPickerOverlay } @@ -402,11 +327,9 @@ struct NoteEditorView: View { private var headerView: some View { HStack { - Button(action: { handleBackNavigation() }) { Button(action: { handleBackNavigation() }) { Image(systemName: "chevron.left") .foregroundColor(Color("Accent")).font(.title2) - .foregroundColor(Color("Accent")).font(.title2) } Spacer() Text("Note") @@ -447,7 +370,6 @@ struct NoteEditorView: View { private var toolbarView: some View { HStack(spacing: 20) { - Button(action: { showFontPicker.toggle() showFontSizePicker = false @@ -457,7 +379,6 @@ struct NoteEditorView: View { } - Button(action: { showFontSizePicker.toggle() showFontPicker = false @@ -473,13 +394,11 @@ struct NoteEditorView: View { } - formatButton(action: toggleBold, icon: "bold", isActive: isBoldActive()) formatButton(action: toggleItalic, icon: "italic", isActive: isItalicActive()) formatButton(action: toggleUnderline, icon: "underline", isActive: isUnderlineActive()) - ColorPicker("", selection: $selectedColor, supportsOpacity: false) .labelsHidden() .frame(width: 30, height: 30) @@ -488,7 +407,6 @@ struct NoteEditorView: View { } - Button(action: addBulletPoints) { Image(systemName: "list.bullet") .foregroundColor(Color("Accent")) @@ -588,7 +506,6 @@ struct NoteEditorView: View { } - func addBulletPoints() { guard selectedRange.length > 0 else { return } @@ -606,12 +523,10 @@ struct NoteEditorView: View { func isBoldActive() -> Bool { return checkTraitActive(.traitBold) - return checkTraitActive(.traitBold) } func isItalicActive() -> Bool { return checkTraitActive(.traitItalic) - return checkTraitActive(.traitItalic) } func isUnderlineActive() -> Bool { @@ -641,28 +556,6 @@ struct NoteEditorView: View { } } - private func checkTraitActive(_ trait: UIFontDescriptor.SymbolicTraits) -> Bool { - if selectedRange.length > 0 { - var hasTraitThroughout = true - let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) - - attributedText.enumerateAttribute(.font, in: NSRange(location: selectedRange.location, length: endLocation - selectedRange.location), options: []) { value, range, stop in - if let font = value as? UIFont { - if !font.fontDescriptor.symbolicTraits.contains(trait) { - hasTraitThroughout = false - stop.pointee = true - } - } - } - return hasTraitThroughout - } else { - if let font = typingAttributes[.font] as? UIFont { - return font.fontDescriptor.symbolicTraits.contains(trait) - } - return false - } - } - private func getCurrentFont() -> UIFont { if selectedRange.length > 0 && selectedRange.location < attributedText.length { return attributedText.attribute(.font, at: selectedRange.location, effectiveRange: nil) as? UIFont ?? UIFont.systemFont(ofSize: 18) @@ -680,8 +573,6 @@ struct NoteEditorView: View { } func applyFontFamily(_ font: UIFont) { - let size = getCurrentFont().pointSize - let newFont = UIFont(name: font.fontName, size: size) ?? font let size = getCurrentFont().pointSize let newFont = UIFont(name: font.fontName, size: size) ?? font applyAttribute(.font, value: newFont) @@ -699,38 +590,6 @@ struct NoteEditorView: View { let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) - mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in - if let font = value as? UIFont { - var traits = font.fontDescriptor.symbolicTraits - if traits.contains(.traitBold) { - traits.remove(.traitBold) - } else { - traits.insert(.traitBold) - } - if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) - mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) - } - } - } - attributedText = mutableAttributedString - } else { - let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) - var traits = currentFont.fontDescriptor.symbolicTraits - if traits.contains(.traitBold) { - traits.remove(.traitBold) - } else { - traits.insert(.traitBold) - } - if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) - typingAttributes[.font] = newFont - } - if selectedRange.length > 0 { - let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) - let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) - let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) - mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in if let font = value as? UIFont { var traits = font.fontDescriptor.symbolicTraits @@ -760,7 +619,6 @@ struct NoteEditorView: View { } } hasUnsavedChanges = true - hasUnsavedChanges = true } func toggleItalic() { @@ -769,38 +627,6 @@ struct NoteEditorView: View { let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) - mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in - if let font = value as? UIFont { - var traits = font.fontDescriptor.symbolicTraits - if traits.contains(.traitItalic) { - traits.remove(.traitItalic) - } else { - traits.insert(.traitItalic) - } - if let newFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: font.pointSize) - mutableAttributedString.addAttribute(.font, value: newFont, range: subRange) - } - } - } - attributedText = mutableAttributedString - } else { - let currentFont = typingAttributes[.font] as? UIFont ?? UIFont.systemFont(ofSize: 18) - var traits = currentFont.fontDescriptor.symbolicTraits - if traits.contains(.traitItalic) { - traits.remove(.traitItalic) - } else { - traits.insert(.traitItalic) - } - if let newFontDescriptor = currentFont.fontDescriptor.withSymbolicTraits(traits) { - let newFont = UIFont(descriptor: newFontDescriptor, size: currentFont.pointSize) - typingAttributes[.font] = newFont - } - if selectedRange.length > 0 { - let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) - let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) - let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) - mutableAttributedString.enumerateAttribute(.font, in: range, options: []) { value, subRange, _ in if let font = value as? UIFont { var traits = font.fontDescriptor.symbolicTraits @@ -830,10 +656,8 @@ struct NoteEditorView: View { } } hasUnsavedChanges = true - hasUnsavedChanges = true } - func toggleUnderline() { if selectedRange.length > 0 { let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) @@ -852,23 +676,6 @@ struct NoteEditorView: View { typingAttributes[.underlineStyle] = newUnderline } hasUnsavedChanges = true - if selectedRange.length > 0 { - let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText) - let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) - let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) - - mutableAttributedString.enumerateAttribute(.underlineStyle, in: range, options: []) { value, subRange, _ in - let currentUnderline = value as? Int ?? 0 - let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue - mutableAttributedString.addAttribute(.underlineStyle, value: newUnderline, range: subRange) - } - attributedText = mutableAttributedString - } else { - let currentUnderline = typingAttributes[.underlineStyle] as? Int ?? 0 - let newUnderline = currentUnderline == NSUnderlineStyle.single.rawValue ? 0 : NSUnderlineStyle.single.rawValue - typingAttributes[.underlineStyle] = newUnderline - } - hasUnsavedChanges = true } func applyAttribute(_ key: NSAttributedString.Key, value: Any) { @@ -877,9 +684,6 @@ struct NoteEditorView: View { let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) mutableAttributedString.addAttribute(key, value: value, range: range) - let endLocation = min(selectedRange.location + selectedRange.length, attributedText.length) - let range = NSRange(location: selectedRange.location, length: endLocation - selectedRange.location) - mutableAttributedString.addAttribute(key, value: value, range: range) attributedText = mutableAttributedString } else { typingAttributes[key] = value diff --git a/VITTY/VITTY/Academics/View/RemindersData.swift b/VITTY/VITTY/Academics/View/RemindersData.swift index f6937a5..08722b0 100644 --- a/VITTY/VITTY/Academics/View/RemindersData.swift +++ b/VITTY/VITTY/Academics/View/RemindersData.swift @@ -7,12 +7,10 @@ import SwiftUI import SwiftData - struct RemindersView: View { @Environment(\.modelContext) private var modelContext @Query private var allReminders: [Remainder] @Query private var timeTables: [TimeTable] - @Query private var timeTables: [TimeTable] @State private var searchText = "" @State private var selectedTab = 0 @@ -67,17 +65,10 @@ struct RemindersView: View { }.sorted { $0.daysToGo < $1.daysToGo } } - // Extract courses from timetable - private var availableCourses: [Course] { - let courses = timeTables.first.map { extractCourses(from: $0) } ?? [] - return courses - } - var body: some View { ScrollView { VStack(spacing: 0) { - HStack { Image(systemName: "magnifyingglass") .foregroundColor(.gray) diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index 5181cb3..7805525 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -57,9 +57,6 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { - - - var isLoading: Bool = false var isLoadingApple: Bool = false let firebaseAuth = Auth.auth() @@ -166,11 +163,9 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { func signInServer(username: String, regNo: String) async { - logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") logger.info("Signing into server... from uuid \(self.loggedInFirebaseUser?.uid ?? "empty")") do { - self.loggedInBackendUser = try await AuthAPIService.shared .signInUser( with: AuthRequestBody( @@ -189,8 +184,6 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { } print("this is kinda empty : \(self.loggedInBackendUser?.name ?? "")") logger.info("Signed into server \(self.loggedInBackendUser?.name ?? "empty")") - print("this is kinda empty : \(self.loggedInBackendUser?.name ?? "")") - logger.info("Signed into server \(self.loggedInBackendUser?.name ?? "empty")") } diff --git a/VITTY/VITTY/Connect/Models/CircleModel.swift b/VITTY/VITTY/Connect/Models/CircleModel.swift index 7323d35..eaea8f2 100644 --- a/VITTY/VITTY/Connect/Models/CircleModel.swift +++ b/VITTY/VITTY/Connect/Models/CircleModel.swift @@ -82,7 +82,6 @@ struct CircleUserTemp: Codable { struct CircleUserResponseTemp: Codable { let data: [CircleUserTemp] - enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey { case data } diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift index 94420d9..6808ae4 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CirclesRow.swift @@ -18,30 +18,6 @@ struct CirclesRow: View { } - private var busyCount: Int { - circleMembers.filter { - $0.status != nil && $0.status != "available" && $0.status != "free" - }.count - } - - private var availableCount: Int { - circleMembers.filter { - $0.status == nil || $0.status == "available" || $0.status == "free" - }.count - } - - private var isLoadingMembers: Bool { - communityPageViewModel.isLoadingCircleMembers(for: circle.circleID) - } - @Environment(CommunityPageViewModel.self) private var communityPageViewModel - @Environment(AuthViewModel.self) private var authViewModel - - - private var circleMembers: [CircleUserTemp] { - communityPageViewModel.circleMembers(for: circle.circleID) - } - - private var busyCount: Int { circleMembers.filter { $0.status != nil && $0.status != "available" && $0.status != "free" @@ -99,40 +75,6 @@ struct CirclesRow: View { } - if circleMembers.isEmpty && !isLoadingMembers { - Text("No members") - .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundStyle(Color("Accent").opacity(0.7)) - } - } - } - if isLoadingMembers { - HStack { - ProgressView() - .scaleEffect(0.7) - Text("Loading...") - .font(Font.custom("Poppins-Regular", size: 12)) - .foregroundStyle(Color("Accent")) - } - } else { - HStack { - - if busyCount > 0 { - Image("inclass").resizable().frame(width: 20, height: 20) - Text("\(busyCount) busy").foregroundStyle(Color("Accent")) - - if availableCount > 0 { - Spacer().frame(width: 20) - } - } - - - if availableCount > 0 { - Image("available").resizable().frame(width: 20, height: 20) - Text("\(availableCount) available").foregroundStyle(Color("Accent")) - } - - if circleMembers.isEmpty && !isLoadingMembers { Text("No members") .font(Font.custom("Poppins-Regular", size: 12)) @@ -163,7 +105,6 @@ struct CirclesRow: View { 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: []) diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift index 1b2df7b..61567ce 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift @@ -6,7 +6,6 @@ import SwiftUI import Alamofire -import Alamofire struct CreateGroup: View { let screenHeight = UIScreen.main.bounds.height @@ -44,7 +43,6 @@ struct CreateGroup: View { Spacer().frame(height: 20) - Button(action: { showImagePicker = true }) { @@ -122,7 +120,6 @@ struct CreateGroup: View { Button(action: { showFriendSelector = true - showFriendSelector = true }) { Image(systemName: "person.badge.plus") .foregroundColor(.white) @@ -226,7 +223,6 @@ struct CreateGroup: View { Spacer() Button(action: { createGroup() - createGroup() }) { HStack { if isCreatingGroup { @@ -243,7 +239,6 @@ struct CreateGroup: View { .cornerRadius(10) } .disabled(groupName.isEmpty || isCreatingGroup) - .disabled(groupName.isEmpty || isCreatingGroup) .padding(.trailing, 20) } .padding(.bottom, 20) diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index 3ed9272..00a4f94 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -3,21 +3,14 @@ // // Created by Rujin Devkota on 2/28/25. // -// JoinGroup.swift -// VITTY -// -// Created by Rujin Devkota on 2/28/25. -// import SwiftUI import AVFoundation import UIKit -import UIKit struct JoinGroup: View { let screenHeight = UIScreen.main.bounds.height let screenWidth = UIScreen.main.bounds.width - @Binding var groupCode: String @State private var isScanning = false @State private var scannedCode: String = "" @@ -33,18 +26,6 @@ struct JoinGroup: View { @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(\.dismiss) private var dismiss - @State private var showingAlert = false - @State private var alertMessage = "" - @State private var isJoining = false - @State private var showToast = false - @State private var toastMessage = "" - @State private var circleName = "" - @State private var localGroupCode = "" - - @Environment(AuthViewModel.self) private var authViewModel - @Environment(CommunityPageViewModel.self) private var communityPageViewModel - @Environment(\.dismiss) private var dismiss - var body: some View { ZStack { VStack(spacing: 20) { diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index 24f0fac..3056419 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -382,7 +382,6 @@ struct InsideCircle: View { }) { Image(systemName: "chevron.left") .foregroundColor(.white).font(.title2) - .foregroundColor(.white).font(.title2) } Spacer() Text("Circle") @@ -391,13 +390,10 @@ struct InsideCircle: View { Spacer() Button(action: { showCircleMenu = true - showCircleMenu = true }) { - Image(systemName: "ellipsis") Image(systemName: "ellipsis") .foregroundColor(.white) .font(.system(size: 18)) - .font(.system(size: 18)) } } .padding() @@ -413,7 +409,6 @@ struct InsideCircle: View { .foregroundColor(.white) Spacer() - } Spacer().frame(height: 5) HStack { diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index 238cb83..d04ac36 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -34,16 +34,12 @@ struct ConnectPage: View { @State private var activeSheet: SheetType? @State private var showCircleMenu = false @Environment(\.dismiss) private var dismiss - @State private var activeSheet: SheetType? - @State private var showCircleMenu = false - @Environment(\.dismiss) private var dismiss @Binding var isCreatingGroup : Bool @State private var isAddFriendsViewPresented = false @State private var selectedTab = 0 @State private var hasLoadedInitialData = false - @State private var hasLoadedInitialData = false var body: some View { ZStack { @@ -72,7 +68,6 @@ struct ConnectPage: View { .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) } - if isCircleView == false { Button(action: { isShowingRequestView.toggle() @@ -106,22 +101,15 @@ struct ConnectPage: View { } ) .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) - } else { - ) - .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } else { Button(action: { showCircleMenu = true - showCircleMenu = true }) { - Image(systemName: "ellipsis") Image(systemName: "ellipsis") .foregroundColor(.white) .font(.system(size: 18)) - .font(.system(size: 18)) } .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) - .offset(x: UIScreen.main.bounds.width*0.4228, y: UIScreen.main.bounds.height*0.38901*(-1)) } } .overlay( diff --git a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift index 5c2a02c..f477d6a 100644 --- a/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift +++ b/VITTY/VITTY/Connect/View/Freinds/View/Freinds.swift @@ -6,7 +6,6 @@ // import SwiftUI - struct FriendsView: View { @State private var searchText = "" @State private var selectedFilterOption = 0 @@ -21,8 +20,6 @@ struct FriendsView: View { Spacer().frame(height: 8) - - HStack { FilterPill(title: "Available", isSelected: selectedFilterOption == 0) .onTapGesture { @@ -38,7 +35,6 @@ struct FriendsView: View { Spacer().frame(height: 7) - if communityPageViewModel.errorFreinds { Spacer() VStack(spacing: 5) { @@ -58,35 +54,17 @@ struct FriendsView: View { Spacer() } else { - let filteredFriends = communityPageViewModel.friends.filter { friend in - let matchesSearch: Bool - let matchesSearch: Bool if searchText.isEmpty { matchesSearch = true - matchesSearch = true } else { - matchesSearch = friend.username.localizedCaseInsensitiveContains(searchText) || 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 - } - - return matchesSearch && matchesFilter - - let matchesFilter: Bool switch selectedFilterOption { case 0: @@ -116,26 +94,11 @@ struct FriendsView: View { .font(Font.custom("Poppins-Regular", size: 16)) .foregroundColor(.white) .multilineTextAlignment(.center) - VStack(spacing: 5) { - if selectedFilterOption == 0 && !searchText.isEmpty { - Text("No available friends match your search") - } else if selectedFilterOption == 0 { - Text("No friends are currently available") - } else if !searchText.isEmpty { - Text("No friends match your search") - } else { - Text("You don't have any friends yet") - } - } - .font(Font.custom("Poppins-Regular", size: 16)) - .foregroundColor(.white) - .multilineTextAlignment(.center) Spacer() } else { ScrollView { VStack(spacing: 10) { ForEach(filteredFriends, id: \.username) { friend in - NavigationLink(destination: TimeTableView(friend: friend,isFriendsTimeTable: true)) { NavigationLink(destination: TimeTableView(friend: friend,isFriendsTimeTable: true)) { FriendRow(friend: friend) } diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 186343a..8ed78a1 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -5,7 +5,6 @@ // Created by Chandram Dutta on 04/01/24. // // -// import Foundation import Alamofire @@ -17,28 +16,19 @@ class CommunityPageViewModel { var circles = [CircleModel]() var circleRequests = [CircleRequest]() - var circleRequests = [CircleRequest]() - var loadingFreinds = false var loadingCircle = false var loadingCircleMembers = false var loadingCircleRequests = false var loadingRequestAction = false - var loadingCircleRequests = false - var loadingRequestAction = false var errorFreinds = false var errorCircle = false var errorCircleMembers = false var errorCircleRequests = false - var errorCircleRequests = false - var circleMembers = [CircleUserTemp]() - var circleMembersDict: [String: [CircleUserTemp]] = [:] - var loadingCircleMembersDict: [String: Bool] = [:] - var circleMembersDict: [String: [CircleUserTemp]] = [:] var loadingCircleMembersDict: [String: Bool] = [:] @@ -66,26 +56,14 @@ class CommunityPageViewModel { DispatchQueue.main.async { self.loadingFreinds = false - switch response.result { - DispatchQueue.main.async { - self.loadingFreinds = false - switch response.result { case .success(let data): self.friends = data.data self.errorFreinds = false - self.errorFreinds = false - case .failure(let error): self.logger.error("Error fetching friends: \(error)") - if self.friends.isEmpty { - self.errorFreinds = true - } - } - self.logger.error("Error fetching friends: \(error)") - if self.friends.isEmpty { self.errorFreinds = true } @@ -103,15 +81,6 @@ class CommunityPageViewModel { } - self.errorCircle = false - - func fetchCircleData(from url: String, token: String, loading: Bool = false) { - - if loading || circles.isEmpty { - self.loadingCircle = true - } - - self.errorCircle = false AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) @@ -120,19 +89,12 @@ class CommunityPageViewModel { DispatchQueue.main.async { self.loadingCircle = false - switch response.result { - DispatchQueue.main.async { - self.loadingCircle = false - switch response.result { case .success(let data): self.circles = data.data self.errorCircle = false print("Successfully fetched circles: \(data.data)") - self.errorCircle = false - print("Successfully fetched circles: \(data.data)") - case .failure(let error): self.logger.error("Error fetching circles: \(error)") @@ -285,8 +247,6 @@ class CommunityPageViewModel { AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in - DispatchQueue.main.async { - switch response.result { DispatchQueue.main.async { switch response.result { case .success(let data): @@ -299,29 +259,9 @@ class CommunityPageViewModel { } print("Successfully fetched circle members: \(data.data)") - if let circleID = circleID { - self.circleMembersDict[circleID] = data.data - self.loadingCircleMembersDict[circleID] = false - } else { - self.circleMembers = data.data - self.loadingCircleMembers = false - } - print("Successfully fetched circle members: \(data.data)") - case .failure(let error): self.logger.error("Error fetching circle members: \(error)") - if let circleID = circleID { - self.loadingCircleMembersDict[circleID] = false - } else { - self.loadingCircleMembers = false - if self.circleMembers.isEmpty { - self.errorCircleMembers = true - } - } - } - self.logger.error("Error fetching circle members: \(error)") - if let circleID = circleID { self.loadingCircleMembersDict[circleID] = false } else { @@ -335,12 +275,7 @@ class CommunityPageViewModel { } } - //MARK : Circle Leave - func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { - if loading { - self.loadingCircleMembers = true - } func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { if loading { self.loadingCircleMembers = true @@ -349,31 +284,20 @@ class CommunityPageViewModel { AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() .responseDecodable(of: CircleUserResponseTemp.self) { response in - DispatchQueue.main.async { - self.loadingCircleMembers = false DispatchQueue.main.async { self.loadingCircleMembers = false - switch response.result { switch response.result { case .success(let data): self.circleMembers = data.data print("Successfully fetched circle members after leave: \(data.data)") - self.circleMembers = data.data - print("Successfully fetched circle members after leave: \(data.data)") - case .failure(let error): self.logger.error("Error fetching circle members: \(error)") if self.circleMembers.isEmpty { self.errorCircleMembers = true } } - self.logger.error("Error fetching circle members: \(error)") - if self.circleMembers.isEmpty { - self.errorCircleMembers = true - } - } } } } diff --git a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift index af0eb8a..ae92f18 100644 --- a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -3,7 +3,6 @@ import SwiftUI import UserNotifications class SettingsViewModel : ObservableObject{ - @Published var notificationsEnabled: Bool = false { @Published var notificationsEnabled: Bool = false { didSet { UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") @@ -18,15 +17,12 @@ class SettingsViewModel : ObservableObject{ } } - @Published var timetable: TimeTable? - @Published var showNotificationDisabledAlert = false @Published var timetable: TimeTable? @Published var showNotificationDisabledAlert = false init(timetable: TimeTable? = nil) { self.timetable = timetable - self.notificationsEnabled = UserDefaults.standard.bool(forKey: "notificationsEnabled") checkNotificationAuthorization() } @@ -59,9 +55,6 @@ class SettingsViewModel : ObservableObject{ // Clear existing notifications first UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - // Clear existing notifications first - UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - let weekdays: [(Int, [Lecture])] = [ (2, timetable.monday), // Monday = 2 (3, timetable.tuesday), // Tuesday = 3 @@ -70,13 +63,6 @@ class SettingsViewModel : ObservableObject{ (6, timetable.friday), // Friday = 6 (7, timetable.saturday), // Saturday = 7 (1, timetable.sunday) // Sunday = 1 - (2, timetable.monday), // Monday = 2 - (3, timetable.tuesday), // Tuesday = 3 - (4, timetable.wednesday), // Wednesday = 4 - (5, timetable.thursday), // Thursday = 5 - (6, timetable.friday), // Friday = 6 - (7, timetable.saturday), // Saturday = 7 - (1, timetable.sunday) // Sunday = 1 ] for (weekday, lectures) in weekdays { @@ -87,24 +73,14 @@ class SettingsViewModel : ObservableObject{ } - guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { - print("Failed to parse time for lecture: \(lecture.name) with time: \(lecture.startTime)") - continue - } - - scheduleNotification(for: lecture.name, at: startDate, title: "Class Starting", minutesBefore: 0) - - scheduleNotification(for: lecture.name, at: startDate, title: "Upcoming Class", minutesBefore: 10) } } print("Scheduled notifications for all lectures") - - print("Scheduled notifications for all lectures") } private func scheduleNotification(for lectureName: String, at date: Date, title: String, minutesBefore: Int) { @@ -118,10 +94,8 @@ class SettingsViewModel : ObservableObject{ let triggerComponents = Calendar.current.dateComponents([.weekday, .hour, .minute], from: triggerDate) let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: true) - let identifier = "\(lectureName)-\(title)-\(minutesBefore)min-weekday\(triggerComponents.weekday ?? 0)" let identifier = "\(lectureName)-\(title)-\(minutesBefore)min-weekday\(triggerComponents.weekday ?? 0)" let request = UNNotificationRequest( - identifier: identifier, identifier: identifier, content: content, trigger: trigger @@ -134,18 +108,9 @@ class SettingsViewModel : ObservableObject{ print("Successfully scheduled notification: \(identifier)") } } - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - print("Error scheduling notification: \(error)") - } else { - print("Successfully scheduled notification: \(identifier)") - } - } } - - private func parseLectureTime(_ timeString: String, weekday: Int) -> Date? { let formattedTimeString = formatTime(time: timeString) diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index d958067..da1201d 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -209,7 +209,6 @@ class Lecture: Codable, Identifiable, Comparable { } } - extension TimeTable { var isEmpty: Bool { monday.isEmpty && tuesday.isEmpty && wednesday.isEmpty && @@ -220,26 +219,14 @@ extension TimeTable { let formattedTime = formatTime(time: lecture.startTime) - guard formattedTime != "Failed to parse the time string." else { return nil } - - private func extractStartTime(from lecture: Lecture) -> Date? { - let formattedTime = formatTime(time: lecture.startTime) - - guard formattedTime != "Failed to parse the time string." else { return nil } - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.locale = Locale(identifier: "en_US_POSIX") let formatter = DateFormatter() formatter.dateFormat = "h:mm a" formatter.locale = Locale(identifier: "en_US_POSIX") return formatter.date(from: formattedTime) } - return formatter.date(from: formattedTime) - } - func classesFor(date: Date) -> [Classes] { @@ -266,10 +253,6 @@ extension TimeTable { ) } - // Sort using the original lecture objects instead of formatted strings - return lectures.sorted { lecture1, lecture2 in - guard let time1 = extractStartTime(from: lecture1), - let time2 = extractStartTime(from: lecture2) else { // Sort using the original lecture objects instead of formatted strings return lectures.sorted { lecture1, lecture2 in guard let time1 = extractStartTime(from: lecture1), @@ -285,15 +268,6 @@ extension TimeTable { ) } } - return time1 < time2 - }.map { - Classes( - title: $0.name, - time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", - slot: $0.slot - ) - } - } private func formatTime(time: String) -> String { var timeComponents = time.components(separatedBy: "T").last ?? "" @@ -312,26 +286,6 @@ extension TimeTable { } } - func isDifferentFrom(_ other: TimeTable) -> Bool { - return monday != other.monday || - tuesday != other.tuesday || - wednesday != other.wednesday || - thursday != other.thursday || - friday != other.friday || - saturday != other.saturday || - sunday != other.sunday - 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.") - } - } - func isDifferentFrom(_ other: TimeTable) -> Bool { return monday != other.monday || tuesday != other.tuesday || diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index 7eb83d0..e86dcac 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -14,12 +14,8 @@ public enum Stage { case loading case error case data - case loading - case error - case data } - extension TimeTableView { @Observable class TimeTableViewModel { @@ -248,9 +244,6 @@ extension TimeTableView { } } - var updatedTimeTable: TimeTable? { - timeTable - } var updatedTimeTable: TimeTable? { timeTable } @@ -260,11 +253,4 @@ extension TimeTableView { logger.debug("Sync status reset") } } - func resetSyncStatus() { - hasSyncedThisSession = false - logger.debug("Sync status reset") - } - } } - - diff --git a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift index 4140f97..989713c 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureDetailView.swift @@ -105,15 +105,4 @@ struct LectureDetailView: View { return ("Failed to parse the time string.") } } - 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.") - } - } } diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index f7ec92a..2795344 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -1,5 +1,4 @@ - import OSLog import SwiftData import SwiftUI @@ -8,22 +7,15 @@ struct TimeTableView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.modelContext) private var context @Environment(\.scenePhase) private var scenePhase - @Environment(\.scenePhase) private var scenePhase private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - @State private var viewModel = TimeTableViewModel() @State private var selectedLecture: Lecture? = nil @Query private var timetableItem: [TimeTable] @Environment(\.dismiss) private var dismiss - @Query private var timetableItem: [TimeTable] - @Environment(\.dismiss) private var dismiss let friend: Friend? - var isFriendsTimeTable: Bool - - var isFriendsTimeTable: Bool private let logger = Logger( @@ -33,9 +25,7 @@ struct TimeTableView: View { ) ) - var body: some View { - NavigationStack { NavigationStack { ZStack { BackgroundView() @@ -50,18 +40,6 @@ struct TimeTableView: View { }.padding(8) } - switch viewModel.stage { - VStack { - if isFriendsTimeTable { - HStack { - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .foregroundColor(Color("Accent")).font(.title2) - } - Spacer() - }.padding(8) - } - switch viewModel.stage { case .loading: VStack { @@ -86,12 +64,10 @@ struct TimeTableView: View { Text(day) .foregroundStyle(daysOfWeek[viewModel.dayNo] == day ? Color("Background") : Color("Accent")) - ? Color("Background") : Color("Accent")) .frame(width: 60, height: 54) .background( daysOfWeek[viewModel.dayNo] == day ? Color("Accent") : Color.clear - ? Color("Accent") : Color.clear ) .onTapGesture { withAnimation { @@ -110,7 +86,6 @@ struct TimeTableView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) - if viewModel.lectures.isEmpty { Spacer() Text("No classes today!") diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 8bc6646..f6fa929 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -16,14 +16,12 @@ struct UserProfileSidebar: View { ZStack(alignment: .topTrailing) { Button { isPresented = false - isPresented = false } label: { Image(systemName: "xmark") .foregroundColor(.white) .padding() } - VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 8) { UserImage( @@ -40,34 +38,22 @@ struct UserProfileSidebar: View { } .padding(.top, 40) - Divider().background(Color.clear) - NavigationLink { EmptyClassRoom() } label: { MenuOption(icon: "emptyclassroom", title: "Find Empty Classroom") } - NavigationLink { SettingsView() } label: { MenuOption(icon: "settings", title: "Settings") } - Divider().background(Color.clear) -// MenuOption(icon: "share", title: "Share") - MenuOption(icon: "support", title: "Support").onTapGesture { - let supportUrl = URL(string: "https://github.com/GDGVIT/vitty-ios/issues/new?template=bug_report.md") - UIApplication.shared.open(supportUrl!) - } -// MenuOption(icon: "about", title: "About") - - // MenuOption(icon: "share", title: "Share") MenuOption(icon: "support", title: "Support").onTapGesture { let supportUrl = URL(string: "https://github.com/GDGVIT/vitty-ios/issues/new?template=bug_report.md") @@ -77,7 +63,6 @@ struct UserProfileSidebar: View { Divider().background(Color.clear) - VStack(alignment: .leading, spacing: 4) { Text("Ghost Mode") .font(Font.custom("Poppins-Medium", size: 16)) @@ -102,29 +87,10 @@ struct UserProfileSidebar: View { .foregroundColor(.white) } } - - 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() - Button { authViewModel.signOut() do{ @@ -176,68 +142,6 @@ struct UserProfileSidebar: View { isUpdatingGhostMode = true - let endpoint = enabled ? "ghost" : "alive" - let urlString = "\(APIConstants.base_url)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() - .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_url)friends/\(endpoint)/\(username)" @@ -283,8 +187,6 @@ struct MenuOption: View { let title: String - - var body: some View { HStack(spacing: 16) { Image(icon) From c04133411f116cff1b0fb17836e8e0cf25fc32ed Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sun, 6 Jul 2025 01:04:46 +0545 Subject: [PATCH 02/10] fix: sat timetable craches --- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 1 + VITTY/VITTY/Settings/View/SettingsView.swift | 358 ++++++++++++----- VITTY/VITTY/Shared/Constants.swift | 2 +- VITTY/VITTY/TimeTable/Models/TimeTable.swift | 101 ++--- .../ViewModel/TimeTableViewModel.swift | 370 ++++++++++++------ .../TimeTable/Views/LectureItemView.swift | 2 +- .../VITTY/TimeTable/Views/TimeTableView.swift | 135 ++++++- .../Utilities/Constants/APIConstants.swift | 2 +- VITTY/VittyWidget/Views/LargeWidget.swift | 57 +-- VITTY/VittyWidget/Views/SmallWidget.swift | 2 + .../VittyWidget/Views/WidgetComponents.swift | 1 + 11 files changed, 692 insertions(+), 339 deletions(-) diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index 7805525..3133b69 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -338,6 +338,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { do { try firebaseAuth.signOut() UserDefaults.resetDefaults() + } catch { logger.error("Error Signing Out: \(error)") diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 19fbd36..2392331 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -1,9 +1,6 @@ import SwiftUI import SwiftData - - - struct SettingsView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.dismiss) private var dismiss @@ -12,12 +9,12 @@ struct SettingsView: View { @StateObject private var viewModel = SettingsViewModel() - @State private var showDaySelection = false @State private var selectedDay: String? = nil @State private var showResetAlert = false + @State private var showDeleteUserAlert = false + @State private var isDeletingUser = false - private let selectedDayKey = "SelectedSaturdayDay" var body: some View { @@ -51,12 +48,14 @@ struct SettingsView: View { SettingsSectionView(title: "Class Settings") { VStack(alignment: .leading, spacing: 12) { Button { - showDaySelection.toggle() + withAnimation { + showDaySelection.toggle() + } } label: { SettingsRowView( icon: "calendar.badge.plus", title: "Saturday Class", - subtitle: selectedDay == nil ? "Select a day to copy classes to Saturday" : "Copy \(selectedDay!) classes to Saturday" + subtitle: selectedDay == nil ? "Select a day to copy to Saturday" : "Saturday classes are a copy of \(selectedDay!)" ) } .buttonStyle(PlainButtonStyle()) @@ -67,31 +66,24 @@ struct SettingsView: View { HStack(spacing: 12) { Image(systemName: selectedDay == day ? "largecircle.fill.circle" : "circle") .foregroundColor(.blue) - .font(.system(size: 16)) Text(day) .foregroundColor(.white) - .font(.system(size: 14)) Spacer() } - .padding(.leading, 16) - .padding(.vertical, 4) + .padding([.leading, .vertical], 4) .contentShape(Rectangle()) .onTapGesture { - selectedDay = day - UserDefaults.standard.set(day, forKey: selectedDayKey) copyLecturesToSaturday(from: day) - showDaySelection = false + withAnimation { + showDaySelection = false + } } } } .padding(.top, 8) - .transition(.asymmetric( - insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)), - removal: .opacity.combined(with: .scale(scale: 0.95, anchor: .top)) - )) + .transition(.opacity.combined(with: .scale(scale: 0.95, anchor: .top))) } - Button { showResetAlert = true } label: { @@ -102,6 +94,7 @@ struct SettingsView: View { ) } .buttonStyle(PlainButtonStyle()) + Button { if let url = URL(string: "https://vitty.dscvit.com") { UIApplication.shared.open(url) @@ -130,6 +123,20 @@ struct SettingsView: View { .toggleStyle(SwitchToggleStyle(tint: .green)) } + SettingsSectionView(title: "Account Management") { + Button { + showDeleteUserAlert = true + } label: { + SettingsRowView( + icon: "person.badge.minus", + title: "Delete Account", + subtitle: "Permanently delete your account and all data" + ) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isDeletingUser) + } + 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/")) @@ -138,12 +145,9 @@ struct SettingsView: View { .scrollContentBackground(.hidden) } - if showResetAlert { ResetSaturdayAlert( - onCancel: { - showResetAlert = false - }, + onCancel: { showResetAlert = false }, onReset: { resetSaturdayClasses() showResetAlert = false @@ -151,6 +155,19 @@ struct SettingsView: View { ) .zIndex(1) } + + if showDeleteUserAlert { + DeleteUserAlert( + isDeleting: isDeletingUser, + onCancel: { + showDeleteUserAlert = false + }, + onDelete: { + deleteUser() + } + ) + .zIndex(1) + } } .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) @@ -158,8 +175,7 @@ struct SettingsView: View { viewModel.timetable = timeTables.first viewModel.checkNotificationAuthorization() loadSelectedDay() - print("Saturday before save:", timeTables[0].saturday.map { $0.name }) - + print("Saturday before save:", timeTables.first?.saturday.map { $0.name } ?? []) } .alert("Notifications Disabled", isPresented: $viewModel.showNotificationDisabledAlert) { Button("OK", role: .cancel) {} @@ -169,95 +185,193 @@ struct SettingsView: View { } } - - private func loadSelectedDay() { - selectedDay = UserDefaults.standard.string(forKey: selectedDayKey) + selectedDay = timeTables.first?.saturdaySourceDay } - private func resetSaturdayClasses() { - guard let timeTable = timeTables.first else { return } + private func deleteUser() { + guard let username = authViewModel.loggedInBackendUser?.username else { + print("No username found") + return + } - - let newTimeTable = TimeTable( - monday: timeTable.monday, - tuesday: timeTable.tuesday, - wednesday: timeTable.wednesday, - thursday: timeTable.thursday, - friday: timeTable.friday, - saturday: [], // Empty Saturday - sunday: timeTable.sunday - ) + isDeletingUser = true + + Task { + do { + try await deleteUserFromServer(username: username) + + await MainActor.run { + cleanupLocalData() + authViewModel.signOut() + showDeleteUserAlert = false + isDeletingUser = false + } + } catch { + await MainActor.run { + isDeletingUser = false + print("Failed to delete user: \(error)") + } + } + } + } + + private func deleteUserFromServer(username: String) async throws { + guard let url = URL(string: "\(APIConstants.base_url)/users/\(username)") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" - modelContext.delete(timeTable) - modelContext.insert(newTimeTable) + let token = authViewModel.loggedInBackendUser?.token ?? "" - + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard 200...299 ~= httpResponse.statusCode else { + throw URLError(.badServerResponse) + } + } + + private func cleanupLocalData() { do { + try modelContext.delete(model: TimeTable.self) + try modelContext.delete(model: Remainder.self) + try modelContext.delete(model: CreateNoteModel.self) + try modelContext.delete(model: UploadedFile.self) try modelContext.save() - print("Successfully reset Saturday classes") - - - UserDefaults.standard.removeObject(forKey: selectedDayKey) - selectedDay = nil - + print("Successfully cleaned up local data") } catch { - print("Error saving context: \(error)") + print("Failed to clean up local data: \(error)") } } + private func copyLecturesToSaturday(from day: String) { - guard let timeTable = timeTables.first else { return } + guard let currentTimeTable = timeTables.first else { + print("No timetable found") + return + } + + + + + let lecturesToCopy = currentTimeTable.lectures(forDay: day) + print("Found \(lecturesToCopy.count) lectures to copy") - let lecturesToCopy: [Lecture] - switch day { - case "Monday": - lecturesToCopy = timeTable.monday - case "Tuesday": - lecturesToCopy = timeTable.tuesday - case "Wednesday": - lecturesToCopy = timeTable.wednesday - case "Thursday": - lecturesToCopy = timeTable.thursday - case "Friday": - lecturesToCopy = timeTable.friday - default: - lecturesToCopy = [] + let newSaturdayLectures = lecturesToCopy.map { originalLecture in + let newLecture = Lecture( + name: originalLecture.name, + code: originalLecture.code, + venue: originalLecture.venue, + slot: originalLecture.slot, + type: originalLecture.type, + startTime: originalLecture.startTime, + endTime: originalLecture.endTime + ) + return newLecture } - + let newTimeTable = TimeTable( - monday: timeTable.monday, - tuesday: timeTable.tuesday, - wednesday: timeTable.wednesday, - thursday: timeTable.thursday, - friday: timeTable.friday, - saturday: lecturesToCopy.map { lecture in - Lecture( - name: lecture.name, - code: lecture.code, - venue: lecture.venue, - slot: lecture.slot, - type: lecture.type, - startTime: lecture.startTime, - endTime: lecture.endTime - ) - }, - sunday: timeTable.sunday + monday: currentTimeTable.monday.map { $0.deepCopy() }, + tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, + wednesday: currentTimeTable.wednesday.map { $0.deepCopy() }, + thursday: currentTimeTable.thursday.map { $0.deepCopy() }, + friday: currentTimeTable.friday.map { $0.deepCopy() }, + saturday: newSaturdayLectures, + sunday: currentTimeTable.sunday.map { $0.deepCopy() }, + saturdaySourceDay: day ) - modelContext.delete(timeTable) - modelContext.insert(newTimeTable) + do { + print("Deleting old timetable") + modelContext.delete(currentTimeTable) + + + print("Inserting new timetable with Saturday lectures") + modelContext.insert(newTimeTable) + + + try modelContext.save() + + + self.selectedDay = day + + print("Successfully copied \(day) to Saturday using orthodox method") + print("New Saturday has \(newTimeTable.saturday.count) lectures") + + + Task { @MainActor in + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) + } + + } catch { + print("Error during orthodox copy: \(error)") + + modelContext.rollback() + } + } + + + private func resetSaturdayClasses() { + guard let currentTimeTable = timeTables.first else { + print("No timetable found") + return + } - + print("Starting orthodox reset of Saturday classes") + + + let newTimeTable = TimeTable( + monday: currentTimeTable.monday.map { $0.deepCopy() }, + tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, + wednesday: currentTimeTable.wednesday.map { $0.deepCopy() }, + thursday: currentTimeTable.thursday.map { $0.deepCopy() }, + friday: currentTimeTable.friday.map { $0.deepCopy() }, + saturday: [], + sunday: currentTimeTable.sunday.map { $0.deepCopy() }, + saturdaySourceDay: nil + ) + + do { + print("Deleting old timetable") + modelContext.delete(currentTimeTable) + + + print("Inserting new timetable with empty Saturday") + modelContext.insert(newTimeTable) + + try modelContext.save() - print("Successfully replaced timetable with copied lectures from \(day) to Saturday") + + + self.selectedDay = nil + + print("Successfully reset Saturday classes using orthodox method") + + + Task { @MainActor in + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) + } + } catch { - print("Error saving context: \(error)") + print("Error during orthodox reset: \(error)") + + modelContext.rollback() } } @@ -398,7 +512,73 @@ struct ResetSaturdayAlert: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .onTapGesture { - + // Empty tap gesture to prevent dismissal + } + } +} + +// Custom Delete User Alert Component +struct DeleteUserAlert: View { + let isDeleting: Bool + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 40)) + .foregroundColor(.red) + + Text("Delete Account?") + .font(.custom("Poppins-SemiBold", size: 18)) + .foregroundColor(.white) + + Text("This action will permanently delete your account and all associated data. This cannot be undone.") + .font(.custom("Poppins-Regular", size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + if isDeleting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(height: 40) + } else { + HStack(spacing: 10) { + Button(action: onCancel) { + Text("Cancel") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.3)) + .foregroundColor(.white) + .cornerRadius(8) + } + + Button(action: onDelete) { + Text("Delete Account") + .font(.custom("Poppins-Regular", size: 14)) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + } + } + .frame(minHeight: 200) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .onTapGesture { + // Empty tap gesture to prevent dismissal } } } diff --git a/VITTY/VITTY/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index 27d0a12..2f8ca1f 100644 --- a/VITTY/VITTY/Shared/Constants.swift +++ b/VITTY/VITTY/Shared/Constants.swift @@ -12,7 +12,7 @@ class Constants { // "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" - "https://9b66-2409-40e3-1ee-9039-75b8-20ad-89e9-248a.ngrok-free.app/api/v2/" + "http://localhost:80/api/v2/" // "https://f4df-2409-40e3-30a4-8539-6d49-631b-ddd8-60a3.ngrok-free.app/api/v2/" diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index da1201d..ca90c05 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -36,6 +36,9 @@ class TimeTable: Codable { var friday: [Lecture] var saturday: [Lecture] var sunday: [Lecture] + + // NEW property + var saturdaySourceDay: String? @Transient var logger = Logger( @@ -51,7 +54,8 @@ class TimeTable: Codable { thursday: [Lecture], friday: [Lecture], saturday: [Lecture], - sunday: [Lecture] + sunday: [Lecture], + saturdaySourceDay: String? = nil ) { self.monday = monday self.tuesday = tuesday @@ -60,6 +64,7 @@ class TimeTable: Codable { self.friday = friday self.saturday = saturday self.sunday = sunday + self.saturdaySourceDay = saturdaySourceDay // Set in initializer } enum CodingKeys: String, CodingKey,Codable { @@ -73,64 +78,29 @@ class TimeTable: Codable { } required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - do { - monday = try container.decode([Lecture].self, forKey: .monday) - } - catch { - logger.error("Error decoding Monday lectures: \(error)") - monday = [] - } - - do { - tuesday = try container.decode([Lecture].self, forKey: .tuesday) - } - catch { - logger.error("Error decoding Tuesday lectures: \(error)") - tuesday = [] - } - - do { - wednesday = try container.decode([Lecture].self, forKey: .wednesday) - } - catch { - logger.error("Error decoding Wednesday lectures: \(error)") - wednesday = [] - } - - do { - thursday = try container.decode([Lecture].self, forKey: .thursday) - } - catch { - logger.error("Error decoding Thursday lectures: \(error)") - thursday = [] - } - - do { - friday = try container.decode([Lecture].self, forKey: .friday) - } - catch { - logger.error("Error decoding Friday lectures: \(error)") - friday = [] - } - - do { - saturday = try container.decode([Lecture].self, forKey: .saturday) - } - catch { - logger.error("Error decoding Saturday lectures: \(error)") - saturday = [] - } - - do { - sunday = try container.decode([Lecture].self, forKey: .sunday) - } - catch { - logger.error("Error decoding Sunday lectures: \(error)") - sunday = [] - } - } + let container = try decoder.container(keyedBy: CodingKeys.self) + + monday = (try? container.decode([Lecture].self, forKey: .monday)) ?? [] + tuesday = (try? container.decode([Lecture].self, forKey: .tuesday)) ?? [] + wednesday = (try? container.decode([Lecture].self, forKey: .wednesday)) ?? [] + thursday = (try? container.decode([Lecture].self, forKey: .thursday)) ?? [] + friday = (try? container.decode([Lecture].self, forKey: .friday)) ?? [] + saturday = (try? container.decode([Lecture].self, forKey: .saturday)) ?? [] + sunday = (try? container.decode([Lecture].self, forKey: .sunday)) ?? [] + + self.saturdaySourceDay = nil + } + //MARK: NEW FUNC + func lectures(forDay day: String) -> [Lecture] { + switch day { + case "Monday": return self.monday + case "Tuesday": return self.tuesday + case "Wednesday": return self.wednesday + case "Thursday": return self.thursday + case "Friday": return self.friday + default: return [] + } + } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -185,6 +155,18 @@ class Lecture: Codable, Identifiable, Comparable { case startTime = "start_time" case endTime = "end_time" } + + func deepCopy() -> Lecture { + return Lecture( + name: self.name, + code: self.code, + venue: self.venue, + slot: self.slot, + type: self.type, + startTime: self.startTime, + endTime: self.endTime + ) + } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -296,3 +278,4 @@ extension TimeTable { sunday != other.sunday } } + diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index e86dcac..ec4cf2b 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -5,7 +5,6 @@ // Created by Chandram Dutta on 09/02/24. // - import Foundation import OSLog import SwiftData @@ -27,7 +26,9 @@ extension TimeTableView { private var hasSyncedThisSession = false private var isSyncing = false - private var currentContext: ModelContext? + + // Serial queue for database operations to prevent race conditions + private let databaseQueue = DispatchQueue(label: "com.vitty.database", qos: .userInitiated) private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, @@ -36,66 +37,111 @@ extension TimeTableView { ) ) + private var notificationObserver: NSObjectProtocol? + + init() { + setupNotificationObserver() + } + + deinit { + if let observer = notificationObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + private func setupNotificationObserver() { + notificationObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("TimetableDidChange"), + object: nil, + queue: .main + ) { [weak self] _ in + self?.forceRefreshCurrentDay() + } + } + private func forceRefreshCurrentDay() { + logger.info("Forcing refresh of current day due to timetable change") + changeDay() + } + // NEW: Method to refresh the timetable data from the database + @MainActor + func refreshFromDatabase(_ updatedTimeTable: TimeTable?) { + guard let updatedTimeTable = updatedTimeTable else { + self.timeTable = nil + self.lectures = [] + self.stage = .error + return + } + + // Update our cached copy with the fresh data + self.timeTable = updatedTimeTable + changeDay() // Refresh the current day's lectures + + logger.info("Timetable refreshed from database") + } + func changeDay() { + guard let timeTable = timeTable else { + self.lectures = [] + return + } + switch dayNo { case 0: - self.lectures = timeTable?.monday ?? [] + self.lectures = timeTable.monday case 1: - self.lectures = timeTable?.tuesday ?? [] + self.lectures = timeTable.tuesday case 2: - self.lectures = timeTable?.wednesday ?? [] + self.lectures = timeTable.wednesday case 3: - self.lectures = timeTable?.thursday ?? [] + self.lectures = timeTable.thursday case 4: - self.lectures = timeTable?.friday ?? [] + self.lectures = timeTable.friday case 5: - self.lectures = timeTable?.saturday ?? [] + self.lectures = timeTable.saturday case 6: - self.lectures = timeTable?.sunday ?? [] + self.lectures = timeTable.sunday default: self.lectures = [] } } + @MainActor - func loadTimeTable( - existingTimeTable: TimeTable?, - username: String, - authToken: String, - context: ModelContext - ) async { - logger.info("Starting timetable loading process") - - // Store context for later use - currentContext = context - - if let existing = existingTimeTable { - logger.debug("Using existing local timetable") - timeTable = existing - changeDay() - stage = .data - print("\(existing)") - - // Start background sync if not already done - if !hasSyncedThisSession && !isSyncing { - Task { - await backgroundSync( - localTimeTable: existing, - username: username, - authToken: authToken, - context: context - ) - } - } - } else { - logger.debug("No local timetable, fetching from API") - await fetchTimeTableFromAPI( - username: username, - authToken: authToken, - context: context - ) - } - } + func loadTimeTable( + existingTimeTable: TimeTable?, + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Starting timetable loading process") + + if let existing = existingTimeTable { + logger.debug("Using existing local timetable.") + self.timeTable = existing + changeDay() + self.stage = .data + + + if !hasSyncedThisSession && !isSyncing && !username.isEmpty && !authToken.isEmpty { + + Task { [weak self] in + await self?.backgroundSync( + localTimeTable: existing, + username: username, + authToken: authToken, + context: context + ) + } + } + } else { + logger.debug("No local timetable, fetching from API.") + await fetchTimeTableFromAPI( + username: username, + authToken: authToken, + context: context + ) + } + } private func backgroundSync( localTimeTable: TimeTable, @@ -103,154 +149,218 @@ extension TimeTableView { authToken: String, context: ModelContext ) async { - guard !isSyncing else { return } + guard !isSyncing else { + logger.info("Sync already in progress. Skipping.") + return + } - isSyncing = true - hasSyncedThisSession = true + + await MainActor.run { + isSyncing = true + } - logger.info("Starting background sync") + logger.info("Starting background sync.") + + defer { + Task { @MainActor in + isSyncing = false + } + } do { let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( with: username, authToken: authToken ) + logger.info("Background sync: Fetched remote timetable.") + + + let mergedTimeTable = await createMergedTimeTable( + remote: remoteTimeTable, + local: localTimeTable + ) - logger.info("Background sync: Fetched remote timetable") + + await updateLocalDatabaseSafely( + with: mergedTimeTable, + oldTimeTable: localTimeTable, + context: context + ) - if shouldUpdateLocalTimeTable(local: localTimeTable, remote: remoteTimeTable) { - logger.info("Background sync: Timetables differ, updating local data") - await updateLocalTimeTableWithPersistence( - oldTimeTable: localTimeTable, - newTimeTable: remoteTimeTable, - context: context - ) - } else { - logger.info("Background sync: Timetables are identical, no update needed") + await MainActor.run { + hasSyncedThisSession = true } } catch { - logger.error("Background sync failed: \(error)") + logger.error("Background sync failed: \(error.localizedDescription)") + } - - isSyncing = false - } - - private func shouldUpdateLocalTimeTable(local: TimeTable, remote: TimeTable) -> Bool { - let daysToCompare = [ - (local.monday, remote.monday), - (local.tuesday, remote.tuesday), - (local.wednesday, remote.wednesday), - (local.thursday, remote.thursday), - (local.friday, remote.friday), - (local.saturday, remote.saturday), - (local.sunday, remote.sunday) - ] - - for (localDay, remoteDay) in daysToCompare { - if !areLectureArraysEqual(localDay, remoteDay) { - return true - } - } - - return false } - private func areLectureArraysEqual(_ local: [Lecture], _ remote: [Lecture]) -> Bool { - guard local.count == remote.count else { return false } + private func createMergedTimeTable( + remote: TimeTable, + local: TimeTable + ) async -> TimeTable { + let saturdaySourceDay = local.saturdaySourceDay - let sortedLocal = local.sorted { $0.startTime < $1.startTime } - let sortedRemote = remote.sorted { $0.startTime < $1.startTime } + let finalTimeTable = TimeTable( + monday: remote.monday.map { $0.deepCopy() }, + tuesday: remote.tuesday.map { $0.deepCopy() }, + wednesday: remote.wednesday.map { $0.deepCopy() }, + thursday: remote.thursday.map { $0.deepCopy() }, + friday: remote.friday.map { $0.deepCopy() }, + saturday: [], + sunday: remote.sunday.map { $0.deepCopy() } + ) - for (localLecture, remoteLecture) in zip(sortedLocal, sortedRemote) { - if !areLecturesEqual(localLecture, remoteLecture) { - return false - } + + if let sourceDay = saturdaySourceDay { + logger.info("Re-applying Saturday rule from source day: \(sourceDay).") + let lecturesToCopy = finalTimeTable.lectures(forDay: sourceDay) + finalTimeTable.saturday = lecturesToCopy.map { $0.deepCopy() } + finalTimeTable.saturdaySourceDay = sourceDay } - return true - } - - private func areLecturesEqual(_ local: Lecture, _ remote: Lecture) -> Bool { - return local.name == remote.name && - local.code == remote.code && - local.venue == remote.venue && - local.slot == remote.slot && - local.type == remote.type && - local.startTime == remote.startTime && - local.endTime == remote.endTime + return finalTimeTable } @MainActor - private func updateLocalTimeTableWithPersistence( + private func updateLocalDatabaseSafely( + with newTimeTable: TimeTable, oldTimeTable: TimeTable, - newTimeTable: TimeTable, context: ModelContext ) async { - logger.info("Updating local timetable with persistence") + logger.info("Updating local database with merged timetable.") + do { - // Delete the old timetable from persistent storage + context.delete(oldTimeTable) - - // Insert the new timetable context.insert(newTimeTable) - - // Save the context to persist changes try context.save() - // Update the in-memory reference - timeTable = newTimeTable + + self.timeTable = newTimeTable changeDay() - - logger.info("Local timetable successfully updated and persisted") + logger.info("Local database successfully updated and persisted.") } catch { - logger.error("Failed to update local timetable: \(error)") - // Rollback: if save fails, re-insert the old timetable - context.insert(oldTimeTable) - try? context.save() + logger.error("Failed to save merged timetable: \(error.localizedDescription)") + + do { + + context.rollback() + + + context.insert(oldTimeTable) + try context.save() + + + self.timeTable = oldTimeTable + changeDay() + logger.info("Rollback successful.") + + } catch { + logger.error("Rollback also failed: \(error.localizedDescription)") + // In this case, trigger a fresh fetch + await handleDatabaseCorruption(context: context) + } } } + @MainActor + private func handleDatabaseCorruption(context: ModelContext) async { + logger.warning("Handling potential database corruption.") + + + self.timeTable = nil + self.lectures = [] + self.stage = .error + + + hasSyncedThisSession = false + isSyncing = false + + logger.info("Database corruption handled. User will need to reload.") + } + @MainActor private func fetchTimeTableFromAPI( username: String, authToken: String, context: ModelContext ) async { - logger.info("Fetching TimeTable from API") + logger.info("Fetching TimeTable from API for initial load.") + stage = .loading + + guard !username.isEmpty && !authToken.isEmpty else { + logger.error("Username or auth token is empty") + stage = .error + return + } do { - stage = .loading - let data = try await TimeTableAPIService.shared.getTimeTable( + let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( with: username, authToken: authToken ) + logger.info("TimeTable fetched from API.") - logger.info("TimeTable fetched from API") + + context.insert(remoteTimeTable) + try context.save() - timeTable = data + self.timeTable = remoteTimeTable changeDay() stage = .data - - context.insert(data) - try context.save() hasSyncedThisSession = true } catch { - logger.error("API fetch failed: \(error)") + logger.error("API fetch failed: \(error.localizedDescription)") stage = .error } } + func resetSyncStatus() { + hasSyncedThisSession = false + logger.debug("Sync status reset.") + } + var updatedTimeTable: TimeTable? { timeTable } - func resetSyncStatus() { + @MainActor + func forceRefresh( + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Force refreshing timetable data.") hasSyncedThisSession = false - logger.debug("Sync status reset") + isSyncing = false + + + if let existingTimeTable = timeTable { + do { + context.delete(existingTimeTable) + try context.save() + } catch { + logger.error("Failed to delete existing timetable: \(error.localizedDescription)") + + } + } + + + self.timeTable = nil + self.lectures = [] + + + await fetchTimeTableFromAPI( + username: username, + authToken: authToken, + context: context + ) } } } diff --git a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift index 83171b2..4673ac5 100644 --- a/VITTY/VITTY/TimeTable/Views/LectureItemView.swift +++ b/VITTY/VITTY/TimeTable/Views/LectureItemView.swift @@ -60,7 +60,7 @@ struct LectureItemView: View { .padding(.horizontal, 16) HStack { - Text("\(formatTime(time: lecture.startTime)) - \(formatTime(time: lecture.endTime))") + Text("\(formatTime(time: lecture.startTime)) - \(formatTime(time: lecture.endTime)) | \(lecture.slot)") .font(Font.custom("Poppins-Regular", size: 14)) .foregroundColor(Color("Accent")) diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index 2795344..5b8d181 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -1,4 +1,3 @@ - import OSLog import SwiftData import SwiftUI @@ -7,24 +6,27 @@ struct TimeTableView: View { @Environment(AuthViewModel.self) private var authViewModel @Environment(\.modelContext) private var context @Environment(\.scenePhase) private var scenePhase - + private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - + @State private var viewModel = TimeTableViewModel() @State private var selectedLecture: Lecture? = nil + @State private var isRefreshing = false + @State private var showingRefreshAlert = false + @Query private var timetableItem: [TimeTable] @Environment(\.dismiss) private var dismiss let friend: Friend? - + var isFriendsTimeTable: Bool - + private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String( describing: TimeTableView.self ) ) - + var body: some View { NavigationStack { ZStack { @@ -39,20 +41,50 @@ struct TimeTableView: View { Spacer() }.padding(8) } - + 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() - Text("It's an error!\(String(describing: authViewModel.loggedInBackendUser?.username))") + 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 .data: @@ -63,14 +95,14 @@ struct TimeTableView: View { ForEach(daysOfWeek, id: \.self) { day in Text(day) .foregroundStyle(daysOfWeek[viewModel.dayNo] == day - ? Color("Background") : Color("Accent")) + ? Color("Background") : Color("Accent")) .frame(width: 60, height: 54) .background( daysOfWeek[viewModel.dayNo] == day ? Color("Accent") : Color.clear ) .onTapGesture { - withAnimation { + withAnimation(.easeInOut(duration: 0.2)) { viewModel.dayNo = daysOfWeek.firstIndex( of: day )! @@ -85,18 +117,28 @@ struct TimeTableView: View { .background(Color("Secondary")) .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) - + if viewModel.lectures.isEmpty { Spacer() - Text("No classes today!") - .font(Font.custom("Poppins-Bold", size: 24)) - Text(StringConstants.noClassQuotesOffline.randomElement()!) + 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, @@ -104,7 +146,6 @@ struct TimeTableView: View { ) { selectedLecture = lecture } - } } .padding(.horizontal) @@ -120,21 +161,58 @@ struct TimeTableView: 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 clear your local timetable and fetch fresh data from the server. Continue?") + } .navigationBarBackButtonHidden(true) .onAppear { logger.debug("onAppear triggered") loadTimetable() } + .onChange(of: timetableItem) { oldValue, newValue in + logger.debug("Timetable data changed, reloading view.") + + // NEW: Check if this is a meaningful change + let oldCount = oldValue.count + let newCount = newValue.count + + // Handle different change scenarios + if oldCount != newCount { + // Data was added or removed + loadTimetable() + } else if let oldTable = oldValue.first, let newTable = newValue.first { + // Check if the actual content changed (especially Saturday) + if oldTable.isDifferentFrom(newTable) { + logger.debug("Timetable content changed, refreshing ViewModel") + // Directly refresh the ViewModel with the new data + viewModel.refreshFromDatabase(newTable) + } + } + } .onChange(of: scenePhase) { _, newPhase in - if newPhase == .active { viewModel.resetSyncStatus() + + // Check if we need to reload due to potential data corruption + if viewModel.stage == .error || viewModel.timeTable == nil { + loadTimetable() + } } } } - + private func loadTimetable() { - Task { + guard !isRefreshing else { return } + + + Task { @MainActor in await viewModel.loadTimeTable( existingTimeTable: timetableItem.first, username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), @@ -142,6 +220,23 @@ struct TimeTableView: View { context: context ) } - print("this is users token is \(authViewModel.loggedInBackendUser?.token ?? "")") + + logger.debug("User token: \(authViewModel.loggedInBackendUser?.token ?? "empty")") + } + + private func refreshTimetable() async { + await MainActor.run { + isRefreshing = true + } + + await viewModel.forceRefresh( + username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), + authToken: authViewModel.loggedInBackendUser?.token ?? "", + context: context + ) + + await MainActor.run { + isRefreshing = false + } } } diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index 1cbadeb..f55add2 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -10,7 +10,7 @@ import Foundation struct APIConstants { - static let base_url = "https://9b66-2409-40e3-1ee-9039-75b8-20ad-89e9-248a.ngrok-free.app/api/v2/" + static let base_url = "http://localhost:80/api/v2/" static let createCircle = "circles/create/" static let sendRequest = "circles/sendRequest/" static let acceptRequest = "circles/acceptRequest/" diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index d59f08a..9b3755c 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -150,7 +150,7 @@ struct ScheduleLargeWidgetView: View { ) } - let remainingCount = entry.classes.count - displayClasses.count + let remainingCount = getUpcomingClasses().count - displayClasses.count if remainingCount > 0 { Text("+\(remainingCount) More") .foregroundColor(.white.opacity(0.6)) @@ -167,14 +167,14 @@ struct ScheduleLargeWidgetView: View { .padding(.vertical, 6) } - private func getDisplayClasses() -> [Classes] { + 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") - // Sort all classes by their start time + let sortedClasses = entry.classes.sorted { class1, class2 in let time1Components = class1.time.components(separatedBy: " - ") let time2Components = class2.time.components(separatedBy: " - ") @@ -194,27 +194,15 @@ struct ScheduleLargeWidgetView: View { return startTime1 < startTime2 } - // Find the next upcoming class or current class - var currentIndex = 0 + let now = Date() - - for (index, classItem) in sortedClasses.enumerated() { + let upcomingClasses = sortedClasses.filter { classItem in let timeComponents = classItem.time.components(separatedBy: " - ") - guard timeComponents.count == 2 else { continue } + guard timeComponents.count == 2 else { return true } - let startTimeStr = timeComponents[0].trimmingCharacters(in: .whitespaces) let endTimeStr = timeComponents[1].trimmingCharacters(in: .whitespaces) + guard let endTime = dateFormatter.date(from: endTimeStr) else { return true } - guard let startTime = dateFormatter.date(from: startTimeStr), - let endTime = dateFormatter.date(from: endTimeStr) else { continue } - - // Convert to today's date - let todayStart = calendar.date( - bySettingHour: calendar.component(.hour, from: startTime), - minute: calendar.component(.minute, from: startTime), - second: 0, - of: now - ) let todayEnd = calendar.date( bySettingHour: calendar.component(.hour, from: endTime), @@ -223,29 +211,22 @@ struct ScheduleLargeWidgetView: View { of: now ) - if let todayStart = todayStart, let todayEnd = todayEnd { - // If current time is before this class starts, or if we're currently in this class - if now <= todayEnd { - currentIndex = index - break - } + + if let todayEnd = todayEnd { + return now <= todayEnd } - // If we've passed all classes, start from the beginning for next day - if index == sortedClasses.count - 1 { - currentIndex = 0 - } + return true } - // Get up to 4 classes starting from the current position - let maxDisplay = min(4, sortedClasses.count) - var displayClasses: [Classes] = [] - - for i in 0.. [Classes] { + let upcomingClasses = getUpcomingClasses() - return displayClasses + + let maxDisplay = min(4, upcomingClasses.count) + return Array(upcomingClasses.prefix(maxDisplay)) } } diff --git a/VITTY/VittyWidget/Views/SmallWidget.swift b/VITTY/VittyWidget/Views/SmallWidget.swift index 7f0a2ad..6a2957f 100644 --- a/VITTY/VittyWidget/Views/SmallWidget.swift +++ b/VITTY/VittyWidget/Views/SmallWidget.swift @@ -27,6 +27,8 @@ struct DueSmallWidgetView: View { .font(.system(size: 12, weight: .bold)) .foregroundColor(.white) Spacer() + Image("widgetIcon").resizable().frame(width: 25, height: 10) + } if entry.isEmpty { diff --git a/VITTY/VittyWidget/Views/WidgetComponents.swift b/VITTY/VittyWidget/Views/WidgetComponents.swift index d832078..5a07cce 100644 --- a/VITTY/VittyWidget/Views/WidgetComponents.swift +++ b/VITTY/VittyWidget/Views/WidgetComponents.swift @@ -53,6 +53,7 @@ struct AssignmentRow: View { .foregroundColor(.white) Spacer() + } Text(assignment.subject) From 644095161accdb65de3e298df542fd3faa953769 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sun, 6 Jul 2025 01:31:39 +0545 Subject: [PATCH 03/10] fix: logout state issue --- VITTY/ContentView.swift | 4 -- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 37 ++++++++--- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 61 ++++++++++++++++--- 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/VITTY/ContentView.swift b/VITTY/ContentView.swift index edec9e3..0da0ef7 100644 --- a/VITTY/ContentView.swift +++ b/VITTY/ContentView.swift @@ -43,7 +43,3 @@ struct ContentView: View { } } - -#Preview { - ContentView() -} diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index 3133b69..05a21c3 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -187,13 +187,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { } - private func firebaseUserAuthUpdate(with auth: Auth, user: User?) { - logger.info("Firebase User Auth State Updated") - DispatchQueue.main.async { - guard user != self.loggedInFirebaseUser else { return } - self.loggedInFirebaseUser = user - } - } + func login(with loginOptions: LoginOptions) async { logger.info("Loging In...") @@ -337,13 +331,38 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { func signOut() { do { try firebaseAuth.signOut() + + UserDefaults.resetDefaults() - } - catch { + + DispatchQueue.main.async { + self.loggedInBackendUser = nil + self.loggedInFirebaseUser = nil + } + + + print(self.loggedInBackendUser ?? "the backend user is set to nil ") + + logger.info("User signed out successfully") + + } catch { logger.error("Error Signing Out: \(error)") } } + + + private func firebaseUserAuthUpdate(with auth: Auth, user: User?) { + logger.info("Firebase User Auth State Updated") + DispatchQueue.main.async { + self.loggedInFirebaseUser = user + + + if user == nil { + self.loggedInBackendUser = nil + } + } + } } extension UserDefaults { diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index f6fa929..77acbfd 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -11,6 +11,7 @@ struct UserProfileSidebar: View { @State private var ghostMode: Bool = false @State private var isUpdatingGhostMode: Bool = false @Environment(\.modelContext) private var modelContext + @State private var isLoggingOut: Bool = false var body: some View { ZStack(alignment: .topTrailing) { @@ -92,16 +93,22 @@ struct UserProfileSidebar: View { Spacer() Button { - authViewModel.signOut() - do{ - try modelContext.delete(model:TimeTable.self) - try modelContext.delete(model:Remainder.self) - try modelContext.delete(model:CreateNoteModel.self) - try modelContext.delete(model:UploadedFile.self) - try modelContext.save() - }catch{ - print("Failed to load data") + Task{ + await performLogout() } +// authViewModel.signOut() + +// do{ +// try modelContext.delete(model:TimeTable.self) +// try modelContext.delete(model:Remainder.self) +// try modelContext.delete(model:CreateNoteModel.self) +// try modelContext.delete(model:UploadedFile.self) +// try modelContext.save() +// }catch{ +// print("Failed to load data") +// } + + } label: { HStack { Image(systemName: "rectangle.portrait.and.arrow.right") @@ -180,6 +187,42 @@ struct UserProfileSidebar: View { } }.resume() } + private func performLogout() async { + isLoggingOut = true + + + await MainActor.run { + authViewModel.signOut() + } + + + await clearLocalData() + + + await MainActor.run { + isLoggingOut = false + isPresented = false + } + } + + private func clearLocalData() async { + do { + + await Task.detached { [modelContext] in + do { + try modelContext.delete(model: TimeTable.self) + try modelContext.delete(model: Remainder.self) + try modelContext.delete(model: CreateNoteModel.self) + try modelContext.delete(model: UploadedFile.self) + try modelContext.save() + } catch { + print("Failed to delete local data: \(error)") + } + }.value + } catch { + print("Failed to clear local data: \(error)") + } + } } struct MenuOption: View { From d6898b27269074437dca4d9d5642bcb2ce637b04 Mon Sep 17 00:00:00 2001 From: rujin2003 Date: Sun, 6 Jul 2025 11:45:13 +0545 Subject: [PATCH 04/10] fix: deeplink and join with qr impl --- VITTY/VITTY.xcodeproj/project.pbxproj | 25 --- VITTY/VITTY/Connect/Models/CircleModel.swift | 5 + .../View/Circles/Components/JoinGroup.swift | 151 +++++++------ .../View/Circles/Components/QrCode.swift | 209 ++++++++++++++---- .../Connect/View/Circles/View/Circles.swift | 2 +- .../View/Circles/View/InsideCircle.swift | 51 +++-- .../ViewModel/CommunityPageViewModel.swift | 125 +++++------ VITTY/VITTY/Info.plist | 14 +- .../ViewModel/SettingsViewModel.swift | 182 ++++++--------- VITTY/VITTYApp.swift | 53 ++--- VITTY/VittyWidget/Views/LargeWidget.swift | 8 +- VITTY/VittyWidget/Views/SmallWidget.swift | 15 +- 12 files changed, 453 insertions(+), 387 deletions(-) diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 431c6b2..ae0e5f5 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -36,7 +36,6 @@ 4B37F1E62E03D7D300DCEE5F /* ExistingHotelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */; }; 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */; }; 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */; }; - 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */; }; 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */; }; 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; @@ -46,11 +45,6 @@ 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8762E0BF77400B390E9 /* Alerts.swift */; }; 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */; }; 4B74D87B2E0BFC7E00B390E9 /* FileUploadHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */; }; - 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; - 4B74D8742E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; - 4B74D8772E0BF77800B390E9 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8762E0BF77400B390E9 /* Alerts.swift */; }; - 4B74D8792E0BFC6000B390E9 /* FileUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */; }; - 4B74D87B2E0BFC7E00B390E9 /* FileUploadHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */; }; 4B7DA5DC2D708BD3007354A3 /* LectureItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5DB2D708BCD007354A3 /* LectureItemView.swift */; }; 4B7DA5DF2D7094E8007354A3 /* Academics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5DE2D7094E3007354A3 /* Academics.swift */; }; 4B7DA5E12D70A728007354A3 /* FriendRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5E02D70A71C007354A3 /* FriendRow.swift */; }; @@ -204,7 +198,6 @@ 4B37F1E52E03D7D300DCEE5F /* ExistingHotelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExistingHotelView.swift; sourceTree = ""; }; 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCode.swift; sourceTree = ""; }; - 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCode.swift; sourceTree = ""; }; 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = ""; }; 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = ""; }; @@ -212,10 +205,6 @@ 4B74D8762E0BF77400B390E9 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUpload.swift; sourceTree = ""; }; 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadHelper.swift; sourceTree = ""; }; - 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseFile.swift; sourceTree = ""; }; - 4B74D8762E0BF77400B390E9 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; - 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUpload.swift; sourceTree = ""; }; - 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadHelper.swift; sourceTree = ""; }; 4B7DA5DB2D708BCD007354A3 /* LectureItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureItemView.swift; sourceTree = ""; }; 4B7DA5DE2D7094E3007354A3 /* Academics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Academics.swift; sourceTree = ""; }; 4B7DA5E02D70A71C007354A3 /* FriendRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendRow.swift; sourceTree = ""; }; @@ -492,19 +481,9 @@ path = Components; sourceTree = ""; }; - 4B74D8752E0BF76B00B390E9 /* Components */ = { - isa = PBXGroup; - children = ( - 4B74D87A2E0BFC7900B390E9 /* FileUploadHelper.swift */, - 4B74D8762E0BF77400B390E9 /* Alerts.swift */, - ); - path = Components; - sourceTree = ""; - }; 4B7DA5DD2D7094CA007354A3 /* Academics */ = { isa = PBXGroup; children = ( - 4B74D8752E0BF76B00B390E9 /* Components */, 4B74D8752E0BF76B00B390E9 /* Components */, 4BBB002F2D95510B003B8FE2 /* Model */, 4BBB002E2D955104003B8FE2 /* VIewModel */, @@ -533,7 +512,6 @@ 4B7DA5EB2D71E0F4007354A3 /* Components */ = { isa = PBXGroup; children = ( - 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */, 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */, 4BF0C79E2D94694000016202 /* InsideCircleCards.swift */, 4B7DA5E62D71AC51007354A3 /* CirclesRow.swift */, @@ -547,7 +525,6 @@ 4B7DA5EC2D71E0FB007354A3 /* View */ = { isa = PBXGroup; children = ( - 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */, 4B7DA5E42D70B2C8007354A3 /* Circles.swift */, 4BF0C79C2D94680A00016202 /* InsideCircle.swift */, @@ -576,7 +553,6 @@ 4BBB002D2D9550F8003B8FE2 /* View */ = { isa = PBXGroup; children = ( - 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */, 4B74D8782E0BFC5A00B390E9 /* FileUpload.swift */, 4B183EE72D7C78B300C9D801 /* Courses.swift */, 4BF03C982D7819E00098C803 /* Notes.swift */, @@ -602,7 +578,6 @@ 4BBB002F2D95510B003B8FE2 /* Model */ = { isa = PBXGroup; children = ( - 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */, 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */, 4B5977462DF97D5A009CC224 /* RemainderModel.swift */, 4BBB00322D957A6A003B8FE2 /* NotesModel.swift */, diff --git a/VITTY/VITTY/Connect/Models/CircleModel.swift b/VITTY/VITTY/Connect/Models/CircleModel.swift index eaea8f2..44435b1 100644 --- a/VITTY/VITTY/Connect/Models/CircleModel.swift +++ b/VITTY/VITTY/Connect/Models/CircleModel.swift @@ -15,11 +15,16 @@ struct CircleModel: Decodable { let circleID: String let circleName: String let circleRole: String + let circleJoinCode: String enum CodingKeys: String, CodingKey { case circleID = "circle_id" case circleName = "circle_name" case circleRole = "circle_role" + case circleJoinCode = "circle_join_code" + + + } } diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index 00a4f94..30fead0 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -3,6 +3,7 @@ // // Created by Rujin Devkota on 2/28/25. // + import SwiftUI import AVFoundation import UIKit @@ -156,17 +157,15 @@ struct JoinGroup: View { } .transition(.move(edge: .bottom).combined(with: .opacity)) } - }.onReceive(NotificationCenter.default.publisher(for: Notification.Name("JoinCircleFromDeepLink"))) { notification in + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("JoinCircleFromDeepLink"))) { notification in if let userInfo = notification.userInfo, - let circleId = userInfo["circleId"] as? String, - let circleName = userInfo["circleName"] as? String { + let code = userInfo["code"] as? String { + + localGroupCode = code + groupCode = code - - localGroupCode = circleId - groupCode = circleId - self.circleName = circleName - joinCircle() } } @@ -179,9 +178,6 @@ struct JoinGroup: View { } message: { Text(alertMessage) } - .onOpenURL { url in - handleDeepLink(url) - } .onAppear { localGroupCode = groupCode } @@ -190,61 +186,49 @@ struct JoinGroup: View { } } - // MARK: - Handle Deep Link - private func handleDeepLink(_ url: URL) { - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value else { - return - } - - let circleName = components.queryItems?.first(where: { $0.name == "circleName" })?.value ?? "Unknown Circle" - - showJoinAlert(circleId: circleId, circleName: circleName) - } - - // MARK: - Show Join Alert - private func showJoinAlert(circleId: String, circleName: String) { - let alert = UIAlertController( - title: "Join Circle", - message: "Do you want to join '\(circleName)'?", - preferredStyle: .alert - ) - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - alert.addAction(UIAlertAction(title: "Join", style: .default) { _ in - self.localGroupCode = circleId - self.groupCode = circleId - self.circleName = circleName - self.joinCircle() - }) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.windows.first?.rootViewController { - rootViewController.present(alert, animated: true) - } - } - + // MARK: - Handle Scanned Code private func handleScannedCode(_ code: String) { - if code.contains("vitty.app/invite") || code.contains("circleId=") { - if let components = URLComponents(string: code), - let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value { - localGroupCode = circleId - groupCode = circleId - if let name = components.queryItems?.first(where: { $0.name == "circleName" })?.value { - circleName = name - } - joinCircle() + print("Scanned code: \(code)") + + + if code.contains("vitty.app/join") { + if let url = URL(string: code) { + handleDeepLink(url) } } else { + localGroupCode = code groupCode = code + + joinCircle() } + isScanning = false } + + // MARK: - Handle Deep Link + + private func handleDeepLink(_ url: URL) { + print("Deep link received in JoinGroup: \(url.absoluteString)") + + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + print("Failed to parse URL components") + return + } + + // Handle the URL format: https://vitty.app/join?code=ABC123 + + if let code = components.queryItems?.first(where: { $0.name == "code" })?.value { + localGroupCode = code + groupCode = code + + + joinCircle() + } + } // MARK: - Join Circle - private func joinCircle() { guard !localGroupCode.isEmpty, let username = authViewModel.loggedInBackendUser?.username, @@ -261,7 +245,7 @@ struct JoinGroup: View { isJoining = true UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - + let urlString = "\(APIConstants.base_url)circles/join?code=\(localGroupCode)" guard let url = URL(string: urlString) else { showToast(message: "Error: Invalid URL", isError: true) @@ -274,11 +258,15 @@ struct JoinGroup: View { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + print("Joining circle with code: \(localGroupCode)") + print("Request URL: \(urlString)") + URLSession.shared.dataTask(with: request) { data, response, error in DispatchQueue.main.async { isJoining = false if let error = error { + print("Network error: \(error.localizedDescription)") showToast(message: "Network error: \(error.localizedDescription)", isError: true) return } @@ -288,13 +276,15 @@ struct JoinGroup: View { return } + print("Response status code: \(httpResponse.statusCode)") + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { showToast(message: "Successfully joined the circle! 🎉", isError: false) let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.impactOccurred() - + communityPageViewModel.fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, @@ -308,28 +298,39 @@ struct JoinGroup: View { dismiss() } } else { - if let data = data, - let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let message = errorResponse["message"] as? String { - showToast(message: "Error: \(message)", isError: true) - } else { - switch httpResponse.statusCode { - case 400: - showToast(message: "Error: Invalid circle code", isError: true) - case 404: - showToast(message: "Error: Circle not found", isError: true) - case 409: - showToast(message: "Error: Already a member of this circle", isError: true) - case 403: - showToast(message: "Error: Not authorized to join this circle", isError: true) - default: - showToast(message: "Error: Failed to join circle (Code: \(httpResponse.statusCode))", isError: true) + + if let data = data { + print("Error response data: \(String(data: data, encoding: .utf8) ?? "No data")") + + if let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = errorResponse["message"] as? String { + showToast(message: "Error: \(message)", isError: true) + } else { + handleHTTPError(statusCode: httpResponse.statusCode) } + } else { + handleHTTPError(statusCode: httpResponse.statusCode) } } } }.resume() } + + // MARK: - Handle HTTP Errors + private func handleHTTPError(statusCode: Int) { + switch statusCode { + case 400: + showToast(message: "Error: Invalid circle code", isError: true) + case 404: + showToast(message: "Error: Circle not found", isError: true) + case 409: + showToast(message: "Error: Already a member of this circle", isError: true) + case 403: + showToast(message: "Error: Not authorized to join this circle", isError: true) + default: + showToast(message: "Error: Failed to join circle (Code: \(statusCode))", isError: true) + } + } // MARK: - Show Toast private func showToast(message: String, isError: Bool) { @@ -346,7 +347,6 @@ struct JoinGroup: View { } } - // MARK: - Toast View struct ToastView: View { let message: String @@ -377,7 +377,7 @@ struct ToastView: View { } } - +// MARK: - QR Scanner Components struct QRScannerView: UIViewControllerRepresentable { @Binding var scannedCode: String @Binding var isScanning: Bool @@ -412,7 +412,6 @@ struct QRScannerView: UIViewControllerRepresentable { } } - protocol QRScannerDelegate: AnyObject { func didScanCode(_ code: String) func didFailWithError(_ error: Error) diff --git a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift index 05bcd18..58b7a2c 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift @@ -1,26 +1,25 @@ -// -// QrCode.swift -// VITTY -// -// Created by Rujin Devkota on 6/24/25. -// - import CoreImage.CIFilterBuiltins import SwiftUI - struct QRCodeModalView: View { let groupCode: String let circleName: String + let existingJoinCode: String let onDismiss: () -> Void @State private var showingShareSheet = false + @State private var isGeneratingCode = false + @State private var joinCode: String = "" + @State private var showError = false + @State private var errorMessage = "" + + @Environment(AuthViewModel.self) private var authViewModel + @Environment(CommunityPageViewModel.self) private var communityPageViewModel var body: some View { VStack { Spacer() VStack(spacing: 20) { - HStack { Text("Circle QR Code") .font(.custom("Poppins-SemiBold", size: 20)) @@ -33,37 +32,101 @@ struct QRCodeModalView: View { } } - VStack(spacing: 8) { Text(circleName) .font(.custom("Poppins-SemiBold", size: 18)) .foregroundColor(.white) - - } - - if let qrImage = generateQRCode(from: createInvitationLink()) { - Image(uiImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() + // QR Code Display + if isGeneratingCode { + Rectangle() + .fill(Color.gray.opacity(0.3)) .frame(width: 200, height: 200) - .background(Color.white) .cornerRadius(12) + .overlay( + VStack(spacing: 8) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color("Accent"))) + Text("Generating QR Code...") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.white) + } + ) + } else if !joinCode.isEmpty { + if let qrImage = generateQRCode(from: createInvitationLink()) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .background(Color.white) + .cornerRadius(12) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 200, height: 200) + .cornerRadius(12) + .overlay( + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 30)) + .foregroundColor(.orange) + Text("QR Code\nGeneration Failed") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + Button("Try Again") { + generateJoinCode() + } + .font(.custom("Poppins-Regular", size: 10)) + .foregroundColor(Color("Accent")) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color("Secondary")) + .cornerRadius(4) + } + ) + } } else { Rectangle() .fill(Color.gray.opacity(0.3)) .frame(width: 200, height: 200) .cornerRadius(12) .overlay( - Text("QR Code\nGeneration Failed") - .font(.custom("Poppins-Regular", size: 12)) - .foregroundColor(.white) - .multilineTextAlignment(.center) + VStack(spacing: 8) { + Image(systemName: "qrcode") + .font(.system(size: 40)) + .foregroundColor(.white) + Text("Tap to Generate QR Code") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + } ) + .onTapGesture { + generateJoinCode() + } } + + if !joinCode.isEmpty { + VStack(spacing: 4) { + Text("Join Code:") + .font(.custom("Poppins-Regular", size: 12)) + .foregroundColor(.gray) + Text(joinCode) + .font(.custom("Poppins-SemiBold", size: 16)) + .foregroundColor(Color("Accent")) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color("Secondary")) + .cornerRadius(8) + .onTapGesture { + copyJoinCode() + } + } + } Text("Share this code for others to join your circle") .font(.custom("Poppins-Regular", size: 12)) @@ -71,20 +134,28 @@ struct QRCodeModalView: View { .multilineTextAlignment(.center) .padding(.horizontal) - - Button(action: { - showingShareSheet = true - }) { - HStack { - Image(systemName: "square.and.arrow.up") - Text("Share Invitation") + // Action Buttons + HStack(spacing: 12) { + + Button(action: { + if joinCode.isEmpty { + generateJoinCode() + } else { + showingShareSheet = true + } + }) { + HStack { + Image(systemName: joinCode.isEmpty ? "qrcode" : "square.and.arrow.up") + Text(joinCode.isEmpty ? "Generate QR Code" : "Share Invitation") + } + .font(.custom("Poppins-SemiBold", size: 14)) + .foregroundColor(Color("Background")) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color("Accent")) + .cornerRadius(8) } - .font(.custom("Poppins-SemiBold", size: 16)) - .foregroundColor(Color("Background")) - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background(Color("Accent")) - .cornerRadius(8) + .disabled(isGeneratingCode) } } .frame(maxWidth: 300) @@ -97,15 +168,71 @@ struct QRCodeModalView: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .sheet(isPresented: $showingShareSheet) { - ShareSheetQr(items: [createInvitationLink(), "Join my circle '\(circleName)' on VITTY!"]) + ShareSheetQr(items: [ + createInvitationLink(), + "Join my circle '\(circleName)' on VITTY! Use code: \(joinCode)" + ]) + } + .alert("Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage) + } + .onAppear { + + if !existingJoinCode.isEmpty { + joinCode = existingJoinCode + } else { + + generateJoinCode() + } + } + .onChange(of: existingJoinCode) { oldValue, newValue in + if !newValue.isEmpty && newValue != joinCode { + joinCode = newValue + } } } + private func generateJoinCode() { + guard let token = authViewModel.loggedInBackendUser?.token else { + errorMessage = "Authentication required" + showError = true + return + } + + isGeneratingCode = true + + communityPageViewModel.generateJoinCode(circleId: groupCode, token: token) { result in + DispatchQueue.main.async { + isGeneratingCode = false + + switch result { + case .success(let code): + joinCode = code + + case .failure(let error): + errorMessage = "Failed to generate join code: \(error.localizedDescription)" + showError = true + } + } + } + } + + private func copyJoinCode() { + UIPasteboard.general.string = joinCode + + print("Join code copied to clipboard") + } + private func createInvitationLink() -> String { - - let baseURL = "https://vitty.app/invite" - let encodedCircleName = circleName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? circleName - return "\(baseURL)/circles/sendRequest/\(groupCode)&circleName=\(encodedCircleName)" + let baseURL = "https://vitty.app/join" + + guard let encodedCircleName = circleName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return "\(baseURL)?code=\(joinCode)" + } + + return "\(baseURL)?code=\(joinCode)&circleName=\(encodedCircleName)" } private func generateQRCode(from string: String) -> UIImage? { diff --git a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift index ebe0ac1..118874c 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift @@ -52,7 +52,7 @@ struct CirclesView: View { VStack(spacing: 10) { ForEach(filteredCircles, id: \.circleID) { circle in - NavigationLink(destination: InsideCircle(circleName: circle.circleName, groupCode: circle.circleID)) { + NavigationLink(destination: InsideCircle(circleName: circle.circleName, circle_id:circle.circleID, circle_join_code: circle.circleJoinCode,circle_role: circle.circleRole)) { CirclesRow(circle: circle) } .buttonStyle(PlainButtonStyle()) diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index 3056419..482eadc 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -198,7 +198,9 @@ struct GenerateJoinCodeModal: View { struct CircleMenuView: View { let circleName: String + let role: String let onLeaveGroup: () -> Void + let onDeleteGroup: () -> Void let onGroupRequests: () -> Void let onGenerateJoinCode: () -> Void @@ -227,20 +229,23 @@ struct CircleMenuView: View { Divider() .background(Color.gray.opacity(0.3)) - Button(action: { - onCancel() - onDeleteGroup() - }) { - HStack { - Image(systemName: "trash") - .foregroundColor(.red) - Text("Delete Circle") - .font(.custom("Poppins-Regular", size: 16)) - .foregroundColor(.red) - Spacer() + if role == "admin"{ + + Button(action: { + onCancel() + onDeleteGroup() + }) { + HStack { + Image(systemName: "trash") + .foregroundColor(.red) + Text("Delete Circle") + .font(.custom("Poppins-Regular", size: 16)) + .foregroundColor(.red) + Spacer() + } + .padding() + .background(Color("Background")) } - .padding() - .background(Color("Background")) } Divider() @@ -300,7 +305,9 @@ struct DualIconMenu: View { struct InsideCircle: View { var circleName : String - var groupCode: String + var circle_id: String + var circle_join_code : String + var circle_role : String @State var searchText: String = "" @State var showLeaveAlert: Bool = false @State var showDeleteAlert: Bool = false @@ -336,6 +343,7 @@ struct InsideCircle: View { } // MARK: - Filtered members for search + private var filteredMembers: [CircleUserTemp] { if searchText.isEmpty { return communityPageViewModel.circleMembers @@ -353,7 +361,7 @@ struct InsideCircle: View { let token = authViewModel.loggedInBackendUser?.token ?? "" - communityPageViewModel.generateJoinCode(circleId: groupCode, token: token) { result in + communityPageViewModel.generateJoinCode(circleId: circle_id, token: token) { result in DispatchQueue.main.async { self.isGeneratingCode = false @@ -371,10 +379,11 @@ struct InsideCircle: View { // MARK: - Copy Join Code Function private func copyJoinCode() { UIPasteboard.general.string = generatedJoinCode - // You might want to show a toast or feedback that the code was copied + } var body: some View { + VStack(spacing: 0) { HStack { Button(action: { @@ -489,7 +498,7 @@ struct InsideCircle: View { }) .onAppear { communityPageViewModel.fetchCircleMemberData( - from: "\(APIConstants.base_url)circles/\(groupCode)", + from: "\(APIConstants.base_url)circles/\(circle_id)", token: authViewModel.loggedInBackendUser?.token ?? "", loading: true ) @@ -500,7 +509,7 @@ struct InsideCircle: View { LeaveCircleAlert(circleName: "\(circleName)", onCancel: { showLeaveAlert = false }, onLeave: { - let url = "\(APIConstants.base_url)circles/\(groupCode)/leave" + let url = "\(APIConstants.base_url)circles/\(circle_id)/leave" let token = authViewModel.loggedInBackendUser?.token ?? "" communityPageViewModel.leaveCircle(from: url, token: token) @@ -516,7 +525,7 @@ struct InsideCircle: View { DeleteCircleAlert(circleName: "\(circleName)", onCancel: { showDeleteAlert = false }, onDelete: { - let url = "\(APIConstants.base_url)circles/\(groupCode)" + let url = "\(APIConstants.base_url)circles/\(circle_id)" let token = authViewModel.loggedInBackendUser?.token ?? "" communityPageViewModel.deleteCircle(from: url, token: token) @@ -549,6 +558,7 @@ struct InsideCircle: View { if showCircleMenu { CircleMenuView( circleName: circleName, + role : circle_role, onLeaveGroup: { showLeaveAlert = true }, @@ -570,8 +580,9 @@ struct InsideCircle: View { if showQRCode { QRCodeModalView( - groupCode: groupCode, + groupCode: circle_id, circleName: circleName, + existingJoinCode: circle_join_code, onDismiss: { showQRCode = false } diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 8ed78a1..203c45c 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -43,15 +43,12 @@ class CommunityPageViewModel { self.loadingFreinds = true } - self.errorFreinds = false print("This is the token used in the app \(token)") print("this is the url used for the endpoint \(url)") AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) - .validate() - .responseDecodable(of: FriendRaw.self) { response in DispatchQueue.main.async { self.loadingFreinds = false @@ -63,7 +60,6 @@ class CommunityPageViewModel { case .failure(let error): self.logger.error("Error fetching friends: \(error)") - if self.friends.isEmpty { self.errorFreinds = true } @@ -75,12 +71,10 @@ class CommunityPageViewModel { //MARK: Circle DATA func fetchCircleData(from url: String, token: String, loading: Bool = false) { - if loading || circles.isEmpty { self.loadingCircle = true } - self.errorCircle = false AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) @@ -97,7 +91,6 @@ class CommunityPageViewModel { case .failure(let error): self.logger.error("Error fetching circles: \(error)") - if self.circles.isEmpty { self.errorCircle = true } @@ -137,7 +130,6 @@ class CommunityPageViewModel { self.logger.info("Raw response: \(jsonString)") } - response self.circleRequests = [] self.errorCircleRequests = false } @@ -155,17 +147,16 @@ class CommunityPageViewModel { func acceptCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { self.loadingRequestAction = true - let url = "\(APIConstants.base_url)circles/acceptRequest/\(circleId)" - // Debug logging to see the actual URL being called + logger.info("Attempting to accept circle request with URL: \(url)") logger.info("Circle ID: \(circleId)") logger.info("Token: \(token.prefix(10))...") AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() - .responseData { response in // Changed to responseData to get more details + .responseData { response in DispatchQueue.main.async { self.loadingRequestAction = false @@ -178,10 +169,10 @@ class CommunityPageViewModel { self.logger.info("Response: \(responseString)") } - // Remove the accepted request from the list + self.circleRequests.removeAll { $0.circle_id == circleId } - // Refresh circles data to show the newly joined circle + self.fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, @@ -193,7 +184,7 @@ class CommunityPageViewModel { case .failure(let error): self.logger.error("Error accepting circle request: \(error)") - // Log more details about the error + if let data = response.data, let errorString = String(data: data, encoding: .utf8) { self.logger.error("Error response: \(errorString)") } @@ -314,10 +305,8 @@ class CommunityPageViewModel { self.loadingCircleMembers = false switch response.result { - case .success(let value): - if let json = value as? [String: Any], let detail = json["detail"] as? String { - self.logger.info("Success: \(detail)") - } + case .success: + self.logger.info("Successfully left circle") case .failure(let error): self.logger.error("Error leaving circle: \(error)") @@ -339,21 +328,15 @@ class CommunityPageViewModel { self.loadingCircleMembers = false switch response.result { - case .success(let value): - if let json = value as? [String: Any], let detail = json["detail"] as? String { - self.logger.info("Successfully deleted circle: \(detail)") - } else { - self.logger.info("Successfully deleted circle") - } - + case .success: + self.logger.info("Successfully deleted circle") - self.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: token, - loading: false - ) - - + + self.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: token, + loading: false + ) case .failure(let error): self.logger.error("Error deleting circle: \(error)") @@ -379,7 +362,12 @@ class CommunityPageViewModel { } // MARK: - Group Creation - + + + struct CreateCircleResponse: Codable { + let detail: String + } + func createCircle(name: String, token: String, completion: @escaping (Result) -> Void) { guard let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { @@ -392,30 +380,20 @@ class CommunityPageViewModel { AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() - .responseJSON { response in + .responseDecodable(of: CreateCircleResponse.self) { response in DispatchQueue.main.async { switch response.result { case .success(let data): - if let json = data as? [String: Any], - let detail = json["detail"] as? String { - - - if detail.lowercased().contains("successfully") { - self.logger.info("Successfully created circle: \(name)") - - completion(.success(name)) - } else { - - let error = NSError(domain: "CreateCircleError", code: 1, userInfo: [NSLocalizedDescriptionKey: detail]) - self.logger.error("Error creating circle: \(detail)") - completion(.failure(error)) - } + if data.detail.lowercased().contains("successfully") { + self.logger.info("Successfully created circle: \(name)") + completion(.success(name)) } else { - let error = NSError(domain: "CreateCircleError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + let error = NSError(domain: "CreateCircleError", code: 1, userInfo: [NSLocalizedDescriptionKey: data.detail]) + self.logger.error("Error creating circle: \(data.detail)") completion(.failure(error)) } - + self.fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, @@ -434,6 +412,7 @@ class CommunityPageViewModel { let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" print("this is the endpoint \(url)") + AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() .response { response in @@ -472,52 +451,54 @@ class CommunityPageViewModel { // MARK: - Refresh Methods func refreshAllData(token: String, username: String) { - fetchFriendsData( from: "\(APIConstants.base_url)friends/\(username)/", token: token, loading: false ) - fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, loading: false ) - fetchCircleRequests(token: token, loading: false) } + + struct JoinCodeResponse: Codable { + + let joinCode: String? + let detail: String? + + enum CodingKeys: String, CodingKey { + case joinCode = "join_code" + case detail + } + } + func generateJoinCode(circleId: String, token: String, completion: @escaping (Result) -> Void) { let url = "\(APIConstants.base_url)circles/\(circleId)/generateJoinCode" - print("Generating join code for circle: \(circleId)") - print("Request URL: \(url)") + print("Generating join code for circle: \(circleId)") + print("Request URL: \(url)") AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() - .responseJSON { response in + .responseDecodable(of: JoinCodeResponse.self) { response in DispatchQueue.main.async { switch response.result { case .success(let data): - if let json = data as? [String: Any] { - if let joinCode = json["joinCode"] as? String { - print("Successfully generated join code: \(joinCode)") - completion(.success(joinCode)) - } else if let detail = json["detail"] as? String { - // Handle error case where detail contains error message - print("Error generating join code: \(detail)") - let error = NSError(domain: "GenerateJoinCodeError", code: 1, userInfo: [NSLocalizedDescriptionKey: detail]) - completion(.failure(error)) - } else { - // Handle unexpected response format - print("Unexpected response format") - let error = NSError(domain: "GenerateJoinCodeError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) - completion(.failure(error)) - } + if let joinCode = data.joinCode { + print("Successfully generated join code: \(joinCode)") + completion(.success(joinCode)) + } else if let detail = data.detail { + print("Error generating join code: \(detail)") + let error = NSError(domain: "GenerateJoinCodeError", code: 1, userInfo: [NSLocalizedDescriptionKey: detail]) + completion(.failure(error)) } else { + print("Unexpected response format") let error = NSError(domain: "GenerateJoinCodeError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) completion(.failure(error)) } @@ -530,5 +511,3 @@ class CommunityPageViewModel { } } } - - diff --git a/VITTY/VITTY/Info.plist b/VITTY/VITTY/Info.plist index 3193b8a..5299857 100644 --- a/VITTY/VITTY/Info.plist +++ b/VITTY/VITTY/Info.plist @@ -16,6 +16,16 @@ com.googleusercontent.apps.272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63 + + CFBundleTypeRole + Editor + CFBundleURLName + com.gdscvit.vittyios + CFBundleURLSchemes + + vitty + + FirebaseAppDelegateProxyEnabled @@ -26,6 +36,8 @@ comgooglemaps maps + NSUserNotificationUsageDescription + We use notifications to remind you about your academic events UIAppFonts Poppins-Medium.ttf @@ -47,7 +59,5 @@ UIViewControllerBasedStatusBarAppearance - NSUserNotificationUsageDescription - We use notifications to remind you about your academic events diff --git a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift index ae92f18..3873306 100644 --- a/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift +++ b/VITTY/VITTY/Settings/ViewModel/SettingsViewModel.swift @@ -2,16 +2,17 @@ import Foundation import SwiftUI import UserNotifications -class SettingsViewModel : ObservableObject{ - @Published var notificationsEnabled: Bool = false { +class SettingsViewModel: ObservableObject { + @Published var notificationsEnabled: Bool = UserDefaults.standard.bool(forKey: "notificationsEnabled") { didSet { UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") if notificationsEnabled { - if let timetable = self.timetable { - self.scheduleAllNotifications(from: timetable) - } + + requestPermissionAndSchedule() } else { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + print("All pending notifications have been cleared.") showNotificationDisabledAlert = true } } @@ -22,11 +23,11 @@ class SettingsViewModel : ObservableObject{ init(timetable: TimeTable? = nil) { self.timetable = timetable - - self.notificationsEnabled = UserDefaults.standard.bool(forKey: "notificationsEnabled") + checkNotificationAuthorization() } + func checkNotificationAuthorization() { UNUserNotificationCenter.current().getNotificationSettings { settings in DispatchQueue.main.async { @@ -37,147 +38,108 @@ class SettingsViewModel : ObservableObject{ } } - func requestNotificationPermission() { - UNUserNotificationCenter.current().getNotificationSettings { settings in + + func requestPermissionAndSchedule() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in DispatchQueue.main.async { - if settings.authorizationStatus == .authorized { + if granted { + print("Notification permission granted.") if let timetable = self.timetable { self.scheduleAllNotifications(from: timetable) } } else { + print("Notification permission denied.") + self.notificationsEnabled = false } } } } + func scheduleAllNotifications(from timetable: TimeTable) { - // Clear existing notifications first + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - + let weekdays: [(Int, [Lecture])] = [ - (2, timetable.monday), // Monday = 2 - (3, timetable.tuesday), // Tuesday = 3 - (4, timetable.wednesday), // Wednesday = 4 - (5, timetable.thursday), // Thursday = 5 - (6, timetable.friday), // Friday = 6 - (7, timetable.saturday), // Saturday = 7 - (1, timetable.sunday) // Sunday = 1 + (1, timetable.sunday), (2, timetable.monday), (3, timetable.tuesday), + (4, timetable.wednesday), (5, timetable.thursday), (6, timetable.friday), + (7, timetable.saturday) ] for (weekday, lectures) in weekdays { for lecture in lectures { - guard let startDate = parseLectureTime(lecture.startTime, weekday: weekday) else { - print("Failed to parse time for lecture: \(lecture.name) with time: \(lecture.startTime)") - continue - } - - scheduleNotification(for: lecture.name, at: startDate, title: "Class Starting", minutesBefore: 0) - - - scheduleNotification(for: lecture.name, at: startDate, title: "Upcoming Class", minutesBefore: 10) + scheduleNotificationForNextOccurence(lecture: lecture, weekday: weekday) } } - - print("Scheduled notifications for all lectures") + print("Scheduled all notifications for the next 7 days.") } - private func scheduleNotification(for lectureName: String, at date: Date, title: String, minutesBefore: Int) { - let triggerDate = Calendar.current.date(byAdding: .minute, value: -minutesBefore, to: date) ?? date + + private func scheduleNotificationForNextOccurence(lecture: Lecture, weekday: Int) { + guard let time = parseTime(from: lecture.startTime) else { return } + var dateComponents = DateComponents() + dateComponents.hour = Calendar.current.component(.hour, from: time) + dateComponents.minute = Calendar.current.component(.minute, from: time) + dateComponents.weekday = weekday + + + guard let nextTriggerDate = Calendar.current.nextDate(after: Date(), matching: dateComponents, matchingPolicy: .nextTime) else { return } + + + scheduleNotification( + lectureName: lecture.name, + date: nextTriggerDate, + title: "Upcoming Class", + body: "\(lecture.name) starts in 10 minutes.", + minutesBefore: 10 + ) + + + scheduleNotification( + lectureName: lecture.name, + date: nextTriggerDate, + title: "Class Starting!", + body: "\(lecture.name) is starting now.", + minutesBefore: 0 + ) + } + + + private func scheduleNotification(lectureName: String, date: Date, title: String, body: String, minutesBefore: Int) { let content = UNMutableNotificationContent() content.title = title - content.body = "\(lectureName) is starting soon." + content.body = body content.sound = .default - let triggerComponents = Calendar.current.dateComponents([.weekday, .hour, .minute], from: triggerDate) - let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: true) + + guard let triggerDate = Calendar.current.date(byAdding: .minute, value: -minutesBefore, to: date) else { return } + let triggerComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: triggerDate) + + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: false) - let identifier = "\(lectureName)-\(title)-\(minutesBefore)min-weekday\(triggerComponents.weekday ?? 0)" - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: trigger - ) + let identifier = "\(lectureName)-\(title)-\(triggerDate.timeIntervalSince1970)" + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in if let error = error { - print("Error scheduling notification: \(error)") + print("Error scheduling notification for \(lectureName): \(error.localizedDescription)") } else { - print("Successfully scheduled notification: \(identifier)") + print("Successfully scheduled notification: '\(title)' for \(lectureName)") } } } - - private func parseLectureTime(_ timeString: String, weekday: Int) -> Date? { - - let formattedTimeString = formatTime(time: timeString) - - - if formattedTimeString == "Failed to parse the time string." { - return nil - } - - let timeFormatter = DateFormatter() - timeFormatter.dateFormat = "h:mm a" - timeFormatter.locale = Locale(identifier: "en_US_POSIX") - - guard let timeDate = timeFormatter.date(from: formattedTimeString) else { - print("Failed to parse formatted time: \(formattedTimeString)") - return nil - } - - - let calendar = Calendar.current - let timeComponents = calendar.dateComponents([.hour, .minute], from: timeDate) - - - let today = Date() - let currentWeekday = calendar.component(.weekday, from: today) - - - let daysFromToday = weekday - currentWeekday - let targetDate = calendar.date(byAdding: .day, value: daysFromToday, to: today) ?? today - - - var finalDateComponents = calendar.dateComponents([.year, .month, .day], from: targetDate) - finalDateComponents.hour = timeComponents.hour - finalDateComponents.minute = timeComponents.minute - finalDateComponents.second = 0 - - guard let lectureDate = calendar.date(from: finalDateComponents) else { - print("Failed to create lecture date") - return nil - } - + + private func parseTime(from timeString: String) -> Date? { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" - if weekday == currentWeekday && lectureDate < today { - return calendar.date(byAdding: .weekOfYear, value: 1, to: lectureDate) - } - - - if lectureDate < today { - return calendar.date(byAdding: .weekOfYear, value: 1, to: lectureDate) - } - - return lectureDate - } - - // Your existing formatTime function - 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." + if let timePart = timeString.components(separatedBy: "T").last?.components(separatedBy: "+").first { + return formatter.date(from: timePart) } + return nil } } diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index cbfd107..b29b191 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -39,9 +39,8 @@ import TipKit - use // MARK: when u create a function, it helps to navigate. */ -/// Empty classrooms testing -/// empty sheet in reaminder view -/// + + @main struct VITTYApp: App { @@ -54,7 +53,7 @@ struct VITTYApp: App { @State private var deepLinkURL: URL? @State private var showJoinCircleAlert = false - @State private var pendingCircleInvite: CircleInvite? + @State private var pendingCircleInvite: (code: String, circleName: String?)? init() { setupFirebase() @@ -82,7 +81,7 @@ struct VITTYApp: App { } } message: { if let invite = pendingCircleInvite { - Text("Do you want to join '\(invite.circleName)'?") + Text("Do you want to join the circle with code '\(invite.code)'?") } } } @@ -98,68 +97,62 @@ struct VITTYApp: App { } } - +// MARK: - Deep Link Handling extension VITTYApp { - struct CircleInvite { - let circleId: String - let circleName: String - } - private func handleDeepLink(_ url: URL) { logger.info("Deep link received: \(url.absoluteString)") - if url.absoluteString.contains("vitty.app/invite") || - url.absoluteString.contains("circleId=") { - handleCircleInviteURL(url) + if url.absoluteString.contains("vitty.app/join") { + handleJoinCircleURL(url) } else { - + logger.info("Unhandled deep link type: \(url.absoluteString)") } } - private func handleCircleInviteURL(_ url: URL) { + private func handleJoinCircleURL(_ url: URL) { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { logger.error("Failed to parse URL components") return } - - guard let circleId = components.queryItems?.first(where: { $0.name == "circleId" })?.value else { - logger.error("No circleId found in URL") + + guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { + logger.error("No code found in URL") return } - - let circleName = components.queryItems?.first(where: { $0.name == "circleName" })?.value ?? "Unknown Circle" - - pendingCircleInvite = CircleInvite(circleId: circleId, circleName: circleName) + let circleName = components.queryItems?.first(where: { $0.name == "circleName" })?.value + + // Store the invite and show alert + pendingCircleInvite = (code: code, circleName: circleName) showJoinCircleAlert = true - logger.info("Circle invite prepared: \(circleId) - \(circleName)") + logger.info("Circle join code prepared: \(code)") } - private func handleCircleInvite(_ invite: CircleInvite) { + private func handleCircleInvite(_ invite: (code: String, circleName: String?)) { NotificationCenter.default.post( name: Notification.Name("JoinCircleFromDeepLink"), object: nil, userInfo: [ - "circleId": invite.circleId, - "circleName": invite.circleName + "code": invite.code, + "circleName": invite.circleName ?? "Unknown Circle" ] ) - + pendingCircleInvite = nil - logger.info("Circle invite notification posted for: \(invite.circleId)") + logger.info("Circle join notification posted for code: \(invite.code)") } } - +// MARK: - Firebase Setup extension VITTYApp { private func setupFirebase() { self.logger.info("Configuring Firebase Started") diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index 9b3755c..68fa3ed 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -75,7 +75,7 @@ struct ScheduleLargeWidgetView: View { HStack(alignment: .top) { Spacer().frame(width: 2) VStack(alignment: .leading, spacing: 15) { - Spacer().frame(height: 5) + WidgetTitle(title: "Today's Schedule", fontSize: 18) Spacer().frame(height: 5) @@ -92,7 +92,7 @@ struct ScheduleLargeWidgetView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if entry.completed == entry.total { - // Center the CircleProgressView + VStack { Spacer() CircleProgressView( @@ -123,7 +123,7 @@ struct ScheduleLargeWidgetView: View { } .frame(maxWidth: .infinity) } else { - // Center the CircleProgressView + VStack { Spacer() CircleProgressView( @@ -164,7 +164,7 @@ struct ScheduleLargeWidgetView: View { Spacer() } .padding(.horizontal, 4) - .padding(.vertical, 6) + .padding(.vertical, 6).ignoresSafeArea() } private func getUpcomingClasses() -> [Classes] { diff --git a/VITTY/VittyWidget/Views/SmallWidget.swift b/VITTY/VittyWidget/Views/SmallWidget.swift index 6a2957f..e322f8c 100644 --- a/VITTY/VittyWidget/Views/SmallWidget.swift +++ b/VITTY/VittyWidget/Views/SmallWidget.swift @@ -89,7 +89,7 @@ struct ScheduleSmallWidgetView: View { var body: some View { VStack(alignment: .leading) { - Spacer().frame(height: 10) + WidgetTitle(title: "Schedule", fontSize: 12.0) Spacer().frame(height: 15) @@ -121,13 +121,18 @@ struct ScheduleSmallWidgetView: View { .foregroundColor(Color(.accentBlue)) } } else { - Text("No more classes") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.white) + HStack{ + Spacer() + Text("No more classes") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white) + Spacer() + + } } Spacer() - } + }.ignoresSafeArea() } From ea743fd35e6c0984bd574c32d1716e70ff125010 Mon Sep 17 00:00:00 2001 From: rujin2003 <rujindevkota@gmail.com> Date: Sun, 6 Jul 2025 12:07:42 +0545 Subject: [PATCH 05/10] fix: delete user context remove running on main thread --- .../ViewModel/CommunityPageViewModel.swift | 3 -- VITTY/VITTY/Settings/View/SettingsView.swift | 35 ++++++++++++------- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 1 - 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index f76b7c8..203c45c 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -265,9 +265,6 @@ class CommunityPageViewModel { } } } - } - } - } //MARK : Circle Leave func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index 2392331..d6a3bfc 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -202,10 +202,13 @@ struct SettingsView: View { try await deleteUserFromServer(username: username) await MainActor.run { - cleanupLocalData() - authViewModel.signOut() - showDeleteUserAlert = false - isDeletingUser = false + + Task { + await cleanupLocalData() + authViewModel.signOut() + showDeleteUserAlert = false + isDeletingUser = false + } } } catch { await MainActor.run { @@ -237,18 +240,26 @@ struct SettingsView: View { } } - private func cleanupLocalData() { + private func cleanupLocalData() async { do { - try modelContext.delete(model: TimeTable.self) - try modelContext.delete(model: Remainder.self) - try modelContext.delete(model: CreateNoteModel.self) - try modelContext.delete(model: UploadedFile.self) - try modelContext.save() - print("Successfully cleaned up local data") + + await Task.detached { [modelContext] in + do { + try modelContext.delete(model: TimeTable.self) + try modelContext.delete(model: Remainder.self) + try modelContext.delete(model: CreateNoteModel.self) + try modelContext.delete(model: UploadedFile.self) + try modelContext.save() + print("Successfully cleaned up local data") + } catch { + print("Failed to clean up local data: \(error)") + } + }.value } catch { - print("Failed to clean up local data: \(error)") + print("Failed to clear local data: \(error)") } } + private func copyLecturesToSaturday(from day: String) { diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 2e974ad..77acbfd 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -89,7 +89,6 @@ struct UserProfileSidebar: View { } } } - } Spacer() From 7d2171c93bb5693eb32acc93bfbfc35f93855842 Mon Sep 17 00:00:00 2001 From: rujin2003 <rujindevkota@gmail.com> Date: Sun, 6 Jul 2025 12:18:10 +0545 Subject: [PATCH 06/10] feat: proxy scroll for sunday sat in timetable --- .../VITTY/TimeTable/Views/TimeTableView.swift | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index 5b8d181..760c0cb 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -89,31 +89,50 @@ struct TimeTableView: View { } case .data: VStack(spacing: 0) { - // Day selector - 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() + + 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)) + .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) } } } - .scrollIndicators(.hidden) .background(Color("Secondary")) .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) From d7ab1bd4e0776efa6c586c3fd1ae1e909af47cb3 Mon Sep 17 00:00:00 2001 From: rujin2003 <rujindevkota@gmail.com> Date: Sun, 6 Jul 2025 21:11:17 +0545 Subject: [PATCH 07/10] fix: deeplink fix --- .../View/Circles/Components/JoinGroup.swift | 2 +- .../View/Circles/Components/QrCode.swift | 71 ++-- .../Connect/View/Circles/View/Circles.swift | 27 +- .../View/Circles/View/InsideCircle.swift | 4 +- VITTY/VITTY/Connect/View/ConnectPage.swift | 14 + .../ViewModel/CommunityPageViewModel.swift | 4 + VITTY/VITTY/Home/View/HomeView.swift | 14 +- .../VITTY/TimeTable/Views/TimeTableView.swift | 3 +- VITTY/VITTYApp.swift | 370 ++++++++++++++---- 9 files changed, 393 insertions(+), 116 deletions(-) diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index 30fead0..5ce3fcb 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -191,7 +191,7 @@ struct JoinGroup: View { print("Scanned code: \(code)") - if code.contains("vitty.app/join") { + if code.contains("vitty://join") { if let url = URL(string: code) { handleDeepLink(url) } diff --git a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift index 58b7a2c..143ac82 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift @@ -38,7 +38,7 @@ struct QRCodeModalView: View { .foregroundColor(.white) } - // QR Code Display + if isGeneratingCode { Rectangle() .fill(Color.gray.opacity(0.3)) @@ -54,7 +54,7 @@ struct QRCodeModalView: View { } ) } else if !joinCode.isEmpty { - if let qrImage = generateQRCode(from: createInvitationLink()) { + if let qrImage = generateQRCode(from: createDeepLink()) { Image(uiImage: qrImage) .interpolation(.none) .resizable() @@ -134,28 +134,29 @@ struct QRCodeModalView: View { .multilineTextAlignment(.center) .padding(.horizontal) - // Action Buttons + HStack(spacing: 12) { - - Button(action: { - if joinCode.isEmpty { - generateJoinCode() - } else { - showingShareSheet = true - } - }) { - HStack { - Image(systemName: joinCode.isEmpty ? "qrcode" : "square.and.arrow.up") - Text(joinCode.isEmpty ? "Generate QR Code" : "Share Invitation") + if joinCode.isEmpty{ + Button(action: { + if joinCode.isEmpty { + generateJoinCode() + } + }) { + HStack { + Image(systemName:"qrcode" ) + Text( "Generate QR Code") + } + .font(.custom("Poppins-SemiBold", size: 14)) + .foregroundColor(Color("Background")) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color("Accent")) + .cornerRadius(8) } - .font(.custom("Poppins-SemiBold", size: 14)) - .foregroundColor(Color("Background")) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color("Accent")) - .cornerRadius(8) + .disabled(isGeneratingCode) } - .disabled(isGeneratingCode) + + } } .frame(maxWidth: 300) @@ -167,12 +168,7 @@ struct QRCodeModalView: View { Spacer() } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) - .sheet(isPresented: $showingShareSheet) { - ShareSheetQr(items: [ - createInvitationLink(), - "Join my circle '\(circleName)' on VITTY! Use code: \(joinCode)" - ]) - } + .alert("Error", isPresented: $showError) { Button("OK") { } } message: { @@ -225,14 +221,13 @@ struct QRCodeModalView: View { print("Join code copied to clipboard") } - private func createInvitationLink() -> String { - let baseURL = "https://vitty.app/join" - + + private func createDeepLink() -> String { guard let encodedCircleName = circleName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - return "\(baseURL)?code=\(joinCode)" + return "vitty://join?code=\(joinCode)" } - - return "\(baseURL)?code=\(joinCode)&circleName=\(encodedCircleName)" + //vitty://join?code=Ow2tWaHExs&circleName=newircircle + return "vitty://join?code=\(joinCode)&circleName=\(encodedCircleName)" } private func generateQRCode(from string: String) -> UIImage? { @@ -254,13 +249,3 @@ struct QRCodeModalView: View { } } -struct ShareSheetQr: UIViewControllerRepresentable { - let items: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) - return controller - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} diff --git a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift index 118874c..8215cb2 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift @@ -6,12 +6,17 @@ // import SwiftUI +import SwiftUI + struct CirclesView: View { @Binding var isCreatingGroup: Bool @State private var searchText = "" @Environment(CommunityPageViewModel.self) private var communityPageViewModel @Environment(AuthViewModel.self) private var authViewModel - + + + @EnvironmentObject private var navigationCoordinator: NavigationCoordinator + var body: some View { NavigationStack { VStack(spacing: 12) { @@ -52,7 +57,7 @@ struct CirclesView: View { VStack(spacing: 10) { ForEach(filteredCircles, id: \.circleID) { circle in - NavigationLink(destination: InsideCircle(circleName: circle.circleName, circle_id:circle.circleID, circle_join_code: circle.circleJoinCode,circle_role: circle.circleRole)) { + NavigationLink(destination: InsideCircle(circleName: circle.circleName, circle_id: circle.circleID, circle_join_code: circle.circleJoinCode, circle_role: circle.circleRole)) { CirclesRow(circle: circle) } .buttonStyle(PlainButtonStyle()) @@ -71,6 +76,24 @@ struct CirclesView: View { loading: true ) } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("CircleJoinedSuccessfully"))) { _ in + + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: true + ) + } + + .onAppear { + + if let pendingInvite = navigationCoordinator.pendingCircleInvite { + + print("CirclesView appeared with pending invite: \(pendingInvite.code)") + + + } + } } } } diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index 482eadc..7ec11e2 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -509,12 +509,13 @@ struct InsideCircle: View { LeaveCircleAlert(circleName: "\(circleName)", onCancel: { showLeaveAlert = false }, onLeave: { - let url = "\(APIConstants.base_url)circles/\(circle_id)/leave" + let url = "\(APIConstants.base_url)circles/leave/\(circle_id)" let token = authViewModel.loggedInBackendUser?.token ?? "" communityPageViewModel.leaveCircle(from: url, token: token) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + communityPageViewModel.fetchCircleData(from:"\(APIConstants.base_url)circles" , token: token) showLeaveAlert = false presentationMode.wrappedValue.dismiss() } @@ -532,6 +533,7 @@ struct InsideCircle: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showDeleteAlert = false + presentationMode.wrappedValue.dismiss() } }) diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index d04ac36..4e23323 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -35,6 +35,7 @@ struct ConnectPage: View { @State private var showCircleMenu = false @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var navigationCoordinator: NavigationCoordinator @Binding var isCreatingGroup : Bool @State private var isAddFriendsViewPresented = false @@ -143,8 +144,20 @@ struct ConnectPage: View { case .groupRequests: CircleRequestsView() } + }.onChange(of: navigationCoordinator.shouldNavigateToCircles) { _, shouldNavigate in + if shouldNavigate { + selectedTab = 0 + } + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_url)circles", + token: authViewModel.loggedInBackendUser?.token ?? "", + loading: true + ) } .onAppear { + if navigationCoordinator.shouldNavigateToCircles { + selectedTab = 0 + } let shouldShowLoading = !hasLoadedInitialData @@ -153,6 +166,7 @@ struct ConnectPage: View { loading: shouldShowLoading ) + if communityPageViewModel.friends.isEmpty || !hasLoadedInitialData { communityPageViewModel.fetchFriendsData( from: "\(APIConstants.base_url)friends/\(authViewModel.loggedInBackendUser?.username ?? "")/", diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 203c45c..045b66f 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -307,13 +307,17 @@ class CommunityPageViewModel { switch response.result { case .success: self.logger.info("Successfully left circle") + case .failure(let error): self.logger.error("Error leaving circle: \(error)") self.errorCircleMembers = true } } + + } + } //MARK: Delete Circle diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 8c48c1a..5a19d32 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -7,6 +7,9 @@ struct HomeView: View { @State private var showProfileSidebar: Bool = false @State private var isCreatingGroup = false @StateObject private var tipManager = CustomTipManager() + + + @EnvironmentObject private var navigationCoordinator: NavigationCoordinator var body: some View { NavigationStack { @@ -39,6 +42,16 @@ struct HomeView: View { .onChange(of: selectedPage) { _, newValue in handleTabChange(newValue) } + // NEW: Listen for deep link navigation + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("NavigateToCircles"))) { _ in + selectedPage = 2 // Navigate to Connects tab + } + // NEW: Handle navigation coordinator changes + .onChange(of: navigationCoordinator.shouldNavigateToCircles) { _, shouldNavigate in + if shouldNavigate { + selectedPage = 2 + } + } } } @@ -138,7 +151,6 @@ struct HomeView: View { } private func handleTabChange(_ newTab: Int) { - print("Switched to tab: \(newTab)") } } diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index 760c0cb..eb75e5f 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -142,7 +142,7 @@ struct TimeTableView: View { VStack(spacing: 16) { Image(systemName: "calendar.badge.exclamationmark") .font(.system(size: 50)) - .foregroundColor(.secondary) + .foregroundColor(.secondary) Text("No classes today!") .font(Font.custom("Poppins-Bold", size: 24)) @@ -154,6 +154,7 @@ struct TimeTableView: View { .padding(.horizontal) } Spacer() + } else { ScrollView { VStack(spacing: 12) { diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index b29b191..d5ac2b3 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -11,36 +11,6 @@ import SwiftUI import SwiftData import TipKit -/** - `NOTE FOR FUTURE/NEW DEVS:` - - - always use the latest and greatest apple tools, don't use something that's been replaced by apple (start watching WWDC to stay updated) - for eg: use SwiftData and not CoreData. use @Observable and not ObservableObject and u don't want to use UIKit instead of SwiftUI. trust me on this. - reason: it makes the code more future proof and incase there's no activity on the development for a year, the app wont be outdated. - downside: minimum deployment target has to be raised which hurts adoption but apple doesn't care about this either so we don't too. - - `personal experience, we have had issues when this app would just crash for iOS 16+ because the code were not updated.` - `we lost a lot of users and our ratings dropped to 3.` - - - continuation to the first point, pls replace parts of the app that uses these old tech as soon as you can. - - - focus on keeping the package dependencies on latest versions - - - use `swift-format` to format the code before pushing. it's already configured for the project. double click on VITTY on left panel and click on format code. - - - use `tabs` and `not` spaces pls. - - - try to focus on subtle animations and transitions. it makes the app feel more polished. `withAnimation{ }` is the greatest tool ever made by apple. - - - try to use haptics wherever possible. users love to feel those and apple makes it easier for us to implement - - - try to stick to Apple HIG as much as possible. ik it's difficult considering the UI we have now but it's worth it. - - - use // MARK: <title> when u create a function, it helps to navigate. - */ - - - @main struct VITTYApp: App { @@ -54,6 +24,15 @@ struct VITTYApp: App { @State private var deepLinkURL: URL? @State private var showJoinCircleAlert = false @State private var pendingCircleInvite: (code: String, circleName: String?)? + + + @State private var isProcessingDeepLink = false + + + @StateObject private var navigationCoordinator = NavigationCoordinator() + + + @StateObject private var toastManager = ToastManager() init() { setupFirebase() @@ -62,28 +41,44 @@ struct VITTYApp: App { var body: some Scene { WindowGroup { - ContentView() - .preferredColorScheme(.dark) - .task { - try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)]) - } - .onOpenURL { url in - handleDeepLink(url) - } - .alert("Join Circle", isPresented: $showJoinCircleAlert) { - Button("Cancel", role: .cancel) { - pendingCircleInvite = nil + ZStack { + ContentView() + .preferredColorScheme(.dark) + .environmentObject(navigationCoordinator) + .task { + try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)]) + } + .onOpenURL { url in + handleDeepLink(url) } - Button("Join") { + .alert("Join Circle", isPresented: $showJoinCircleAlert) { + Button("Cancel", role: .cancel) { + pendingCircleInvite = nil + isProcessingDeepLink = false + } + Button("Join") { + if let invite = pendingCircleInvite { + handleCircleInvite(invite) + } + } + } message: { if let invite = pendingCircleInvite { - handleCircleInvite(invite) + Text("Do you want to join the circle with code '\(invite.code)'?") } } - } message: { - if let invite = pendingCircleInvite { - Text("Do you want to join the circle with code '\(invite.code)'?") - } + + + if toastManager.isShowing { + CircleToastView( + message: toastManager.message, + isError: toastManager.isError, + isShowing: $toastManager.isShowing + ) + .animation(.easeInOut(duration: 0.3), value: toastManager.isShowing) + .zIndex(1000) } + } + .environmentObject(toastManager) } .modelContainer(sharedModelContainer) } @@ -97,6 +92,116 @@ struct VITTYApp: App { } } +// MARK: - Toast Manager +class ToastManager: ObservableObject { + @Published var isShowing = false + @Published var message = "" + @Published var isError = false + + private var hideTimer: Timer? + + init() { + + NotificationCenter.default.addObserver( + self, + selector: #selector(showToastNotification), + name: Notification.Name("ShowToast"), + object: nil + ) + } + + @objc private func showToastNotification(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let message = userInfo["message"] as? String, + let isError = userInfo["isError"] as? Bool else { + return + } + + showToast(message: message, isError: isError) + } + + func showToast(message: String, isError: Bool) { + DispatchQueue.main.async { + self.message = message + self.isError = isError + self.isShowing = true + + + self.hideTimer?.invalidate() + self.hideTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + self.hideToast() + } + } + } + + func hideToast() { + DispatchQueue.main.async { + self.isShowing = false + self.hideTimer?.invalidate() + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + hideTimer?.invalidate() + } +} + +// MARK: - Toast View +struct CircleToastView: View { + let message: String + let isError: Bool + @Binding var isShowing: Bool + + var body: some View { + VStack { + Spacer() + + HStack { + Image(systemName: isError ? "xmark.circle.fill" : "checkmark.circle.fill") + .foregroundColor(isError ? .red : .green) + .font(.system(size: 20)) + + Text(message) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.9)) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) + ) + .padding(.horizontal, 20) + .padding(.bottom, 100) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onTapGesture { + isShowing = false + } + } +} + +// MARK: - Navigation Coordinator +class NavigationCoordinator: ObservableObject { + @Published var shouldNavigateToCircles = false + @Published var pendingCircleInvite: (code: String, circleName: String?)? + + func navigateToCirclesForInvite(code: String, circleName: String?) { + pendingCircleInvite = (code: code, circleName: circleName) + shouldNavigateToCircles = true + } + + func resetNavigation() { + shouldNavigateToCircles = false + pendingCircleInvite = nil + } +} + // MARK: - Deep Link Handling extension VITTYApp { @@ -104,51 +209,182 @@ extension VITTYApp { logger.info("Deep link received: \(url.absoluteString)") - if url.absoluteString.contains("vitty.app/join") { + guard !isProcessingDeepLink else { + logger.info("Already processing a deep link, ignoring") + return + } + + isProcessingDeepLink = true + + if url.absoluteString.contains("vitty://join") { handleJoinCircleURL(url) } else { - logger.info("Unhandled deep link type: \(url.absoluteString)") + isProcessingDeepLink = false } } - + private func handleJoinCircleURL(_ url: URL) { + logger.info("Handling join circle URL") + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { logger.error("Failed to parse URL components") + isProcessingDeepLink = false return } - guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { logger.error("No code found in URL") + showToast(message: "Error: Invalid invitation link", isError: true) + isProcessingDeepLink = false return } - let circleName = components.queryItems?.first(where: { $0.name == "circleName" })?.value - // Store the invite and show alert - pendingCircleInvite = (code: code, circleName: circleName) - showJoinCircleAlert = true + logger.info("Parsed circle code: \(code)") + if let name = circleName { + logger.info("Parsed circle name: \(name)") + } - logger.info("Circle join code prepared: \(code)") + + 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 + } + + logger.info("Circle join alert prepared for code: \(code)") } private func handleCircleInvite(_ invite: (code: String, circleName: String?)) { - - NotificationCenter.default.post( - name: Notification.Name("JoinCircleFromDeepLink"), - object: nil, - userInfo: [ - "code": invite.code, - "circleName": invite.circleName ?? "Unknown Circle" - ] - ) - + guard let token = UserDefaults.standard.string(forKey: UserDefaultKeys.tokenKey), + !token.isEmpty else { + logger.error("No token found in UserDefaults") + showToast(message: "Error: Unable to get user information", isError: true) + cleanup() + return + } + + if invite.code.count < 3 { + showToast(message: "Error: Circle code must be at least 3 characters", isError: true) + cleanup() + return + } + + let urlString = "\(APIConstants.base_url)circles/join?code=\(invite.code)" + guard let url = URL(string: urlString) else { + logger.error("Invalid URL: \(urlString)") + showToast(message: "Error: Invalid URL", isError: true) + cleanup() + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + + logger.info("Joining circle with code: \(invite.code)") + logger.info("Request URL: \(urlString)") + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + + if let error = error { + self.logger.error("Network error: \(error.localizedDescription)") + self.showToast(message: "Network error: \(error.localizedDescription)", isError: true) + self.cleanup() + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + self.showToast(message: "Error: Invalid response", isError: true) + self.cleanup() + return + } + + self.logger.info("Response status code: \(httpResponse.statusCode)") + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + self.showToast(message: "Successfully joined the circle! 🎉", isError: false) + + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + NotificationCenter.default.post( + name: Notification.Name("CircleJoinedSuccessfully"), + object: nil, + userInfo: ["code": invite.code] + ) + } + + self.logger.info("Successfully joined circle with code: \(invite.code)") + } else { + + if let data = data { + self.logger.error("Error response data: \(String(data: data, encoding: .utf8) ?? "No data")") + + if let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = errorResponse["message"] as? String { + self.showToast(message: "Error: \(message)", isError: true) + } else { + self.handleHTTPError(statusCode: httpResponse.statusCode) + } + } else { + self.handleHTTPError(statusCode: httpResponse.statusCode) + } + } + + self.cleanup() + } + }.resume() + } + + // MARK: - Helper Methods + + + private func cleanup() { pendingCircleInvite = nil + isProcessingDeepLink = false + navigationCoordinator.resetNavigation() + } + + private func handleHTTPError(statusCode: Int) { + switch statusCode { + case 400: + showToast(message: "Error: Bad request", isError: true) + case 401: + showToast(message: "Error: Unauthorized", isError: true) + case 403: + showToast(message: "Error: Forbidden", isError: true) + case 404: + showToast(message: "Error: Circle not found", isError: true) + case 409: + showToast(message: "Error: Already a member of this circle", isError: true) + case 500: + showToast(message: "Error: Server error", isError: true) + default: + showToast(message: "Error: Something went wrong", isError: true) + } + } + + private func showToast(message: String, isError: Bool) { + // NEW: Use ToastManager directly + toastManager.showToast(message: message, isError: isError) - logger.info("Circle join notification posted for code: \(invite.code)") + if isError { + logger.error("Toast Error: \(message)") + } else { + logger.info("Toast Success: \(message)") + } } } From 976917ed90cf19bba130a382d8dcbd35d9599b87 Mon Sep 17 00:00:00 2001 From: rujin2003 <rujindevkota@gmail.com> Date: Sun, 6 Jul 2025 21:29:15 +0545 Subject: [PATCH 08/10] fix: merge stable build --- .../View/Circles/Components/JoinGroup.swift | 58 -------- .../View/Circles/Components/QrCode.swift | 8 -- .../View/Circles/View/InsideCircle.swift | 32 ----- .../ViewModel/CommunityPageViewModel.swift | 58 -------- VITTY/VITTY/Settings/View/SettingsView.swift | 127 ------------------ .../VITTY/TimeTable/Views/TimeTableView.swift | 99 -------------- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 1 - 7 files changed, 383 deletions(-) diff --git a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift index f8c9a6f..5ce3fcb 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/JoinGroup.swift @@ -4,7 +4,6 @@ // Created by Rujin Devkota on 2/28/25. // - import SwiftUI import AVFoundation import UIKit @@ -159,18 +158,12 @@ struct JoinGroup: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } - .onReceive(NotificationCenter.default.publisher(for: Notification.Name("JoinCircleFromDeepLink"))) { notification in - } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("JoinCircleFromDeepLink"))) { notification in if let userInfo = notification.userInfo, - let code = userInfo["code"] as? String { let code = userInfo["code"] as? String { localGroupCode = code groupCode = code - localGroupCode = code - groupCode = code - joinCircle() @@ -216,14 +209,9 @@ struct JoinGroup: View { // MARK: - Handle Deep Link - private func handleDeepLink(_ url: URL) { print("Deep link received in JoinGroup: \(url.absoluteString)") - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - print("Failed to parse URL components") - print("Deep link received in JoinGroup: \(url.absoluteString)") - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { print("Failed to parse URL components") return @@ -231,17 +219,11 @@ struct JoinGroup: View { // Handle the URL format: https://vitty.app/join?code=ABC123 - if let code = components.queryItems?.first(where: { $0.name == "code" })?.value { - - // Handle the URL format: https://vitty.app/join?code=ABC123 - if let code = components.queryItems?.first(where: { $0.name == "code" })?.value { localGroupCode = code groupCode = code - - joinCircle() } } @@ -264,7 +246,6 @@ struct JoinGroup: View { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - let urlString = "\(APIConstants.base_url)circles/join?code=\(localGroupCode)" guard let url = URL(string: urlString) else { showToast(message: "Error: Invalid URL", isError: true) @@ -280,15 +261,11 @@ struct JoinGroup: View { print("Joining circle with code: \(localGroupCode)") print("Request URL: \(urlString)") - print("Joining circle with code: \(localGroupCode)") - print("Request URL: \(urlString)") - URLSession.shared.dataTask(with: request) { data, response, error in DispatchQueue.main.async { isJoining = false if let error = error { - print("Network error: \(error.localizedDescription)") print("Network error: \(error.localizedDescription)") showToast(message: "Network error: \(error.localizedDescription)", isError: true) return @@ -301,8 +278,6 @@ struct JoinGroup: View { print("Response status code: \(httpResponse.statusCode)") - print("Response status code: \(httpResponse.statusCode)") - if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { showToast(message: "Successfully joined the circle! 🎉", isError: false) @@ -310,7 +285,6 @@ struct JoinGroup: View { impactFeedback.impactOccurred() - communityPageViewModel.fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, @@ -325,16 +299,6 @@ struct JoinGroup: View { } } else { - if let data = data { - print("Error response data: \(String(data: data, encoding: .utf8) ?? "No data")") - - if let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let message = errorResponse["message"] as? String { - showToast(message: "Error: \(message)", isError: true) - } else { - handleHTTPError(statusCode: httpResponse.statusCode) - } - if let data = data { print("Error response data: \(String(data: data, encoding: .utf8) ?? "No data")") @@ -352,27 +316,6 @@ struct JoinGroup: View { }.resume() } - // MARK: - Handle HTTP Errors - private func handleHTTPError(statusCode: Int) { - switch statusCode { - case 400: - showToast(message: "Error: Invalid circle code", isError: true) - case 404: - showToast(message: "Error: Circle not found", isError: true) - case 409: - showToast(message: "Error: Already a member of this circle", isError: true) - case 403: - showToast(message: "Error: Not authorized to join this circle", isError: true) - default: - showToast(message: "Error: Failed to join circle (Code: \(statusCode))", isError: true) - } - handleHTTPError(statusCode: httpResponse.statusCode) - } - } - } - }.resume() - } - // MARK: - Handle HTTP Errors private func handleHTTPError(statusCode: Int) { switch statusCode { @@ -434,7 +377,6 @@ struct ToastView: View { } } -// MARK: - QR Scanner Components // MARK: - QR Scanner Components struct QRScannerView: UIViewControllerRepresentable { @Binding var scannedCode: String diff --git a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift index edd888a..143ac82 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/QrCode.swift @@ -5,7 +5,6 @@ struct QRCodeModalView: View { let groupCode: String let circleName: String let existingJoinCode: String - let existingJoinCode: String let onDismiss: () -> Void @State private var showingShareSheet = false @@ -14,13 +13,6 @@ struct QRCodeModalView: View { @State private var showError = false @State private var errorMessage = "" - @Environment(AuthViewModel.self) private var authViewModel - @Environment(CommunityPageViewModel.self) private var communityPageViewModel - @State private var isGeneratingCode = false - @State private var joinCode: String = "" - @State private var showError = false - @State private var errorMessage = "" - @Environment(AuthViewModel.self) private var authViewModel @Environment(CommunityPageViewModel.self) private var communityPageViewModel diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index b484356..7ec11e2 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -199,10 +199,8 @@ struct GenerateJoinCodeModal: View { struct CircleMenuView: View { let circleName: String let role: String - let role: String let onLeaveGroup: () -> Void - let onDeleteGroup: () -> Void let onGroupRequests: () -> Void let onGenerateJoinCode: () -> Void @@ -231,24 +229,6 @@ struct CircleMenuView: View { Divider() .background(Color.gray.opacity(0.3)) - if role == "admin"{ - - Button(action: { - onCancel() - onDeleteGroup() - }) { - HStack { - Image(systemName: "trash") - .foregroundColor(.red) - Text("Delete Circle") - .font(.custom("Poppins-Regular", size: 16)) - .foregroundColor(.red) - Spacer() - } - .padding() - .background(Color("Background")) - } - if role == "admin"{ Button(action: { @@ -328,9 +308,6 @@ struct InsideCircle: View { var circle_id: String var circle_join_code : String var circle_role : String - var circle_id: String - var circle_join_code : String - var circle_role : String @State var searchText: String = "" @State var showLeaveAlert: Bool = false @State var showDeleteAlert: Bool = false @@ -367,7 +344,6 @@ struct InsideCircle: View { // MARK: - Filtered members for search - private var filteredMembers: [CircleUserTemp] { if searchText.isEmpty { return communityPageViewModel.circleMembers @@ -385,7 +361,6 @@ struct InsideCircle: View { let token = authViewModel.loggedInBackendUser?.token ?? "" - communityPageViewModel.generateJoinCode(circleId: circle_id, token: token) { result in communityPageViewModel.generateJoinCode(circleId: circle_id, token: token) { result in DispatchQueue.main.async { self.isGeneratingCode = false @@ -405,12 +380,10 @@ struct InsideCircle: View { private func copyJoinCode() { UIPasteboard.general.string = generatedJoinCode - } var body: some View { - VStack(spacing: 0) { HStack { Button(action: { @@ -525,7 +498,6 @@ struct InsideCircle: View { }) .onAppear { communityPageViewModel.fetchCircleMemberData( - from: "\(APIConstants.base_url)circles/\(circle_id)", from: "\(APIConstants.base_url)circles/\(circle_id)", token: authViewModel.loggedInBackendUser?.token ?? "", loading: true @@ -554,7 +526,6 @@ struct InsideCircle: View { DeleteCircleAlert(circleName: "\(circleName)", onCancel: { showDeleteAlert = false }, onDelete: { - let url = "\(APIConstants.base_url)circles/\(circle_id)" let url = "\(APIConstants.base_url)circles/\(circle_id)" let token = authViewModel.loggedInBackendUser?.token ?? "" @@ -590,7 +561,6 @@ struct InsideCircle: View { CircleMenuView( circleName: circleName, role : circle_role, - role : circle_role, onLeaveGroup: { showLeaveAlert = true }, @@ -612,11 +582,9 @@ struct InsideCircle: View { if showQRCode { QRCodeModalView( - groupCode: circle_id, groupCode: circle_id, circleName: circleName, existingJoinCode: circle_join_code, - existingJoinCode: circle_join_code, onDismiss: { showQRCode = false } diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index b2270ee..045b66f 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -75,7 +75,6 @@ class CommunityPageViewModel { self.loadingCircle = true } - self.errorCircle = false AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) @@ -148,18 +147,15 @@ class CommunityPageViewModel { func acceptCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { self.loadingRequestAction = true - let url = "\(APIConstants.base_url)circles/acceptRequest/\(circleId)" - logger.info("Attempting to accept circle request with URL: \(url)") logger.info("Circle ID: \(circleId)") logger.info("Token: \(token.prefix(10))...") AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() - .responseData { response in .responseData { response in DispatchQueue.main.async { self.loadingRequestAction = false @@ -174,11 +170,9 @@ class CommunityPageViewModel { } - self.circleRequests.removeAll { $0.circle_id == circleId } - self.fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, @@ -191,7 +185,6 @@ class CommunityPageViewModel { self.logger.error("Error accepting circle request: \(error)") - if let data = response.data, let errorString = String(data: data, encoding: .utf8) { self.logger.error("Error response: \(errorString)") } @@ -272,9 +265,6 @@ class CommunityPageViewModel { } } } - } - } - } //MARK : Circle Leave func fetchCircleLeave(from url: String, token: String, loading: Bool = false) { @@ -342,8 +332,6 @@ class CommunityPageViewModel { self.loadingCircleMembers = false switch response.result { - case .success: - self.logger.info("Successfully deleted circle") case .success: self.logger.info("Successfully deleted circle") @@ -353,12 +341,6 @@ class CommunityPageViewModel { token: token, loading: false ) - - self.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: token, - loading: false - ) case .failure(let error): self.logger.error("Error deleting circle: \(error)") @@ -386,12 +368,6 @@ class CommunityPageViewModel { // MARK: - Group Creation - struct CreateCircleResponse: Codable { - let detail: String - } - - - struct CreateCircleResponse: Codable { let detail: String } @@ -408,27 +384,20 @@ class CommunityPageViewModel { AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() - .responseDecodable(of: CreateCircleResponse.self) { response in .responseDecodable(of: CreateCircleResponse.self) { response in DispatchQueue.main.async { switch response.result { case .success(let data): - if data.detail.lowercased().contains("successfully") { - self.logger.info("Successfully created circle: \(name)") - completion(.success(name)) if data.detail.lowercased().contains("successfully") { self.logger.info("Successfully created circle: \(name)") completion(.success(name)) } else { - let error = NSError(domain: "CreateCircleError", code: 1, userInfo: [NSLocalizedDescriptionKey: data.detail]) - self.logger.error("Error creating circle: \(data.detail)") let error = NSError(domain: "CreateCircleError", code: 1, userInfo: [NSLocalizedDescriptionKey: data.detail]) self.logger.error("Error creating circle: \(data.detail)") completion(.failure(error)) } - self.fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, @@ -448,7 +417,6 @@ class CommunityPageViewModel { let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" print("this is the endpoint \(url)") - AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() .response { response in @@ -493,30 +461,16 @@ class CommunityPageViewModel { loading: false ) - fetchCircleData( from: "\(APIConstants.base_url)circles", token: token, loading: false ) - fetchCircleRequests(token: token, loading: false) } - struct JoinCodeResponse: Codable { - - let joinCode: String? - let detail: String? - - enum CodingKeys: String, CodingKey { - case joinCode = "join_code" - case detail - } - } - - struct JoinCodeResponse: Codable { let joinCode: String? @@ -531,27 +485,15 @@ class CommunityPageViewModel { func generateJoinCode(circleId: String, token: String, completion: @escaping (Result<String, Error>) -> Void) { let url = "\(APIConstants.base_url)circles/\(circleId)/generateJoinCode" - print("Generating join code for circle: \(circleId)") - print("Request URL: \(url)") print("Generating join code for circle: \(circleId)") print("Request URL: \(url)") AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() - .responseDecodable(of: JoinCodeResponse.self) { response in .responseDecodable(of: JoinCodeResponse.self) { response in DispatchQueue.main.async { switch response.result { case .success(let data): - if let joinCode = data.joinCode { - print("Successfully generated join code: \(joinCode)") - completion(.success(joinCode)) - } else if let detail = data.detail { - print("Error generating join code: \(detail)") - let error = NSError(domain: "GenerateJoinCodeError", code: 1, userInfo: [NSLocalizedDescriptionKey: detail]) - completion(.failure(error)) - } else { - print("Unexpected response format") if let joinCode = data.joinCode { print("Successfully generated join code: \(joinCode)") completion(.success(joinCode)) diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index f78d56b..d6a3bfc 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -9,16 +9,12 @@ struct SettingsView: View { @StateObject private var viewModel = SettingsViewModel() - @State private var showDaySelection = false @State private var selectedDay: String? = nil @State private var showResetAlert = false @State private var showDeleteUserAlert = false @State private var isDeletingUser = false - @State private var showDeleteUserAlert = false - @State private var isDeletingUser = false - private let selectedDayKey = "SelectedSaturdayDay" var body: some View { @@ -55,15 +51,11 @@ struct SettingsView: View { withAnimation { showDaySelection.toggle() } - withAnimation { - showDaySelection.toggle() - } } label: { SettingsRowView( icon: "calendar.badge.plus", title: "Saturday Class", subtitle: selectedDay == nil ? "Select a day to copy to Saturday" : "Saturday classes are a copy of \(selectedDay!)" - subtitle: selectedDay == nil ? "Select a day to copy to Saturday" : "Saturday classes are a copy of \(selectedDay!)" ) } .buttonStyle(PlainButtonStyle()) @@ -79,22 +71,17 @@ struct SettingsView: View { Spacer() } .padding([.leading, .vertical], 4) - .padding([.leading, .vertical], 4) .contentShape(Rectangle()) .onTapGesture { copyLecturesToSaturday(from: day) withAnimation { showDaySelection = false } - withAnimation { - showDaySelection = false - } } } } .padding(.top, 8) .transition(.opacity.combined(with: .scale(scale: 0.95, anchor: .top))) - .transition(.opacity.combined(with: .scale(scale: 0.95, anchor: .top))) } Button { @@ -108,7 +95,6 @@ struct SettingsView: View { } .buttonStyle(PlainButtonStyle()) - Button { if let url = URL(string: "https://vitty.dscvit.com") { UIApplication.shared.open(url) @@ -151,20 +137,6 @@ struct SettingsView: View { .disabled(isDeletingUser) } - SettingsSectionView(title: "Account Management") { - Button { - showDeleteUserAlert = true - } label: { - SettingsRowView( - icon: "person.badge.minus", - title: "Delete Account", - subtitle: "Permanently delete your account and all data" - ) - } - .buttonStyle(PlainButtonStyle()) - .disabled(isDeletingUser) - } - 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/")) @@ -175,7 +147,6 @@ struct SettingsView: View { if showResetAlert { ResetSaturdayAlert( - onCancel: { showResetAlert = false }, onCancel: { showResetAlert = false }, onReset: { resetSaturdayClasses() @@ -197,19 +168,6 @@ struct SettingsView: View { ) .zIndex(1) } - - if showDeleteUserAlert { - DeleteUserAlert( - isDeleting: isDeletingUser, - onCancel: { - showDeleteUserAlert = false - }, - onDelete: { - deleteUser() - } - ) - .zIndex(1) - } } .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) @@ -218,7 +176,6 @@ struct SettingsView: View { viewModel.checkNotificationAuthorization() loadSelectedDay() print("Saturday before save:", timeTables.first?.saturday.map { $0.name } ?? []) - print("Saturday before save:", timeTables.first?.saturday.map { $0.name } ?? []) } .alert("Notifications Disabled", isPresented: $viewModel.showNotificationDisabledAlert) { Button("OK", role: .cancel) {} @@ -422,26 +379,9 @@ struct SettingsView: View { ) } - - - self.selectedDay = nil - - print("Successfully reset Saturday classes using orthodox method") - - - Task { @MainActor in - NotificationCenter.default.post( - name: NSNotification.Name("TimetableDidChange"), - object: nil - ) - } - } catch { print("Error during orthodox reset: \(error)") - modelContext.rollback() - print("Error during orthodox reset: \(error)") - modelContext.rollback() } } @@ -588,73 +528,6 @@ struct ResetSaturdayAlert: View { } } -// Custom Delete User Alert Component -struct DeleteUserAlert: View { - let isDeleting: Bool - let onCancel: () -> Void - let onDelete: () -> Void - - var body: some View { - VStack { - Spacer() - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 40)) - .foregroundColor(.red) - - Text("Delete Account?") - .font(.custom("Poppins-SemiBold", size: 18)) - .foregroundColor(.white) - - Text("This action will permanently delete your account and all associated data. This cannot be undone.") - .font(.custom("Poppins-Regular", size: 14)) - .foregroundColor(.white) - .multilineTextAlignment(.center) - - if isDeleting { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .frame(height: 40) - } else { - HStack(spacing: 10) { - Button(action: onCancel) { - Text("Cancel") - .font(.custom("Poppins-Regular", size: 14)) - .padding(.vertical, 10) - .frame(maxWidth: .infinity) - .background(Color.gray.opacity(0.3)) - .foregroundColor(.white) - .cornerRadius(8) - } - - Button(action: onDelete) { - Text("Delete Account") - .font(.custom("Poppins-Regular", size: 14)) - .padding(.vertical, 10) - .frame(maxWidth: .infinity) - .background(Color.red) - .foregroundColor(.white) - .cornerRadius(8) - } - } - } - } - .frame(minHeight: 200) - .padding(20) - .background(Color("Background")) - .cornerRadius(16) - .padding(.horizontal, 30) - .transition(.scale.combined(with: .opacity)) - Spacer() - } - .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) - .onTapGesture { - // Empty tap gesture to prevent dismissal - // Empty tap gesture to prevent dismissal - } - } -} - // Custom Delete User Alert Component struct DeleteUserAlert: View { let isDeleting: Bool diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index e6d03c2..eb75e5f 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -7,26 +7,19 @@ struct TimeTableView: View { @Environment(\.modelContext) private var context @Environment(\.scenePhase) private var scenePhase - private let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - @State private var viewModel = TimeTableViewModel() @State private var selectedLecture: Lecture? = nil @State private var isRefreshing = false @State private var showingRefreshAlert = false - @State private var isRefreshing = false - @State private var showingRefreshAlert = false - @Query private var timetableItem: [TimeTable] @Environment(\.dismiss) private var dismiss let friend: Friend? - var isFriendsTimeTable: Bool - private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String( @@ -34,7 +27,6 @@ struct TimeTableView: View { ) ) - var body: some View { NavigationStack { ZStack { @@ -50,18 +42,12 @@ struct TimeTableView: View { }.padding(8) } - switch viewModel.stage { case .loading: VStack { Spacer() ProgressView() .scaleEffect(1.2) - Text("Loading timetable...") - .font(.caption) - .foregroundColor(.secondary) - .padding(.top, 8) - .scaleEffect(1.2) Text("Loading timetable...") .font(.caption) .foregroundColor(.secondary) @@ -76,41 +62,15 @@ struct TimeTableView: View { .foregroundColor(.orange) .padding(.bottom, 16) - Text("Something went wrong!") - 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) - .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) - - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.bottom, 20) - Button(action: { showingRefreshAlert = true }) { @@ -177,7 +137,6 @@ struct TimeTableView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) .padding(.horizontal) - if viewModel.lectures.isEmpty { Spacer() VStack(spacing: 16) { @@ -232,16 +191,6 @@ struct TimeTableView: View { } message: { Text("This will clear your local timetable and fetch fresh data from the server. Continue?") } - .alert("Refresh Timetable", isPresented: $showingRefreshAlert) { - Button("Cancel", role: .cancel) { } - Button("Refresh", role: .destructive) { - Task { - await refreshTimetable() - } - } - } message: { - Text("This will clear your local timetable and fetch fresh data from the server. Continue?") - } .navigationBarBackButtonHidden(true) .onAppear { logger.debug("onAppear triggered") @@ -267,26 +216,6 @@ struct TimeTableView: View { } } } - .onChange(of: timetableItem) { oldValue, newValue in - logger.debug("Timetable data changed, reloading view.") - - // NEW: Check if this is a meaningful change - let oldCount = oldValue.count - let newCount = newValue.count - - // Handle different change scenarios - if oldCount != newCount { - // Data was added or removed - loadTimetable() - } else if let oldTable = oldValue.first, let newTable = newValue.first { - // Check if the actual content changed (especially Saturday) - if oldTable.isDifferentFrom(newTable) { - logger.debug("Timetable content changed, refreshing ViewModel") - // Directly refresh the ViewModel with the new data - viewModel.refreshFromDatabase(newTable) - } - } - } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { viewModel.resetSyncStatus() @@ -295,24 +224,14 @@ struct TimeTableView: View { if viewModel.stage == .error || viewModel.timeTable == nil { loadTimetable() } - - // Check if we need to reload due to potential data corruption - if viewModel.stage == .error || viewModel.timeTable == nil { - loadTimetable() - } } } } - private func loadTimetable() { guard !isRefreshing else { return } - Task { @MainActor in - guard !isRefreshing else { return } - - Task { @MainActor in await viewModel.loadTimeTable( existingTimeTable: timetableItem.first, @@ -325,24 +244,6 @@ struct TimeTableView: View { logger.debug("User token: \(authViewModel.loggedInBackendUser?.token ?? "empty")") } - private func refreshTimetable() async { - await MainActor.run { - isRefreshing = true - } - - await viewModel.forceRefresh( - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), - authToken: authViewModel.loggedInBackendUser?.token ?? "", - context: context - ) - - await MainActor.run { - isRefreshing = false - } - - logger.debug("User token: \(authViewModel.loggedInBackendUser?.token ?? "empty")") - } - private func refreshTimetable() async { await MainActor.run { isRefreshing = true diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 2e974ad..77acbfd 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -89,7 +89,6 @@ struct UserProfileSidebar: View { } } } - } Spacer() From 92d0d9e36e39cf70715ab6afb20aa76cfbef171d Mon Sep 17 00:00:00 2001 From: rujin2003 <rujindevkota@gmail.com> Date: Mon, 14 Jul 2025 12:00:56 +0530 Subject: [PATCH 09/10] Removed sensitive data --- VITTY/GoogleService-Info.plist | 36 -- VITTY/VITTY.xcodeproj/project.pbxproj | 16 +- .../VITTY/Auth/ViewModels/AuthViewModel.swift | 7 +- .../Connect/View/Circles/View/Circles.swift | 2 +- .../ViewModel/CommunityPageViewModel.swift | 49 +- VITTY/VITTY/Info.plist | 4 +- VITTY/VITTY/Settings/View/SettingsView.swift | 450 ++++++++++++--- VITTY/VITTY/Shared/Constants.swift | 2 +- .../TimeTable/Models/NetworkMonitor.swift | 31 + VITTY/VITTY/TimeTable/Models/TimeTable.swift | 14 +- .../ViewModel/TimeTableViewModel.swift | 538 ++++++++++-------- .../VITTY/TimeTable/Views/TimeTableView.swift | 48 +- VITTY/VITTY/UserProfileSideBar/SideBar.swift | 4 +- .../Utilities/Constants/APIConstants.swift | 2 +- .../Providers/ScheduleProvider.swift | 181 +++++- VITTY/VittyWidget/Views/LargeWidget.swift | 2 +- 16 files changed, 968 insertions(+), 418 deletions(-) delete mode 100644 VITTY/GoogleService-Info.plist create mode 100644 VITTY/VITTY/TimeTable/Models/NetworkMonitor.swift diff --git a/VITTY/GoogleService-Info.plist b/VITTY/GoogleService-Info.plist deleted file mode 100644 index 57973b4..0000000 --- a/VITTY/GoogleService-Info.plist +++ /dev/null @@ -1,36 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>CLIENT_ID</key> - <string>272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63.apps.googleusercontent.com</string> - <key>REVERSED_CLIENT_ID</key> - <string>com.googleusercontent.apps.272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63</string> - <key>ANDROID_CLIENT_ID</key> - <string>272763363329-143lqjkb0i5a75lc0iglc26jlb61po0c.apps.googleusercontent.com</string> - <key>API_KEY</key> - <string>AIzaSyCJYYDMdzQiNiY0pxqbrglEw85BSlGgHBc</string> - <key>GCM_SENDER_ID</key> - <string>272763363329</string> - <key>PLIST_VERSION</key> - <string>1</string> - <key>BUNDLE_ID</key> - <string>com.gdscvit.vittyios</string> - <key>PROJECT_ID</key> - <string>vitty-dscvit</string> - <key>STORAGE_BUCKET</key> - <string>vitty-dscvit.appspot.com</string> - <key>IS_ADS_ENABLED</key> - <false></false> - <key>IS_ANALYTICS_ENABLED</key> - <false></false> - <key>IS_APPINVITE_ENABLED</key> - <true></true> - <key>IS_GCM_ENABLED</key> - <true></true> - <key>IS_SIGNIN_ENABLED</key> - <true></true> - <key>GOOGLE_APP_ID</key> - <string>1:272763363329:ios:3b020b67f7527e83e2e000</string> -</dict> -</plist> \ No newline at end of file diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index ae0e5f5..4fdd5ae 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -28,6 +28,9 @@ 4B183EEA2D7C793800C9D801 /* RemindersData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EE92D7C791400C9D801 /* RemindersData.swift */; }; 4B183EEC2D7CB15800C9D801 /* CourseRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */; }; 4B1BDBCC2E1396B1008C2DE9 /* ToolTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */; }; + 4B2D648F2E20BA6300412CB7 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */; }; + 4B2D64902E20BA6300412CB7 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */; }; + 4B2D64922E20C1AC00412CB7 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */; }; 4B2DD6952E0A703300BC3B67 /* CircleRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */; }; 4B341C0E2E1802910073906B /* FreindRequestModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */; }; 4B341C102E1803070073906B /* FreindRequestViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */; }; @@ -37,7 +40,6 @@ 4B37F1E92E04173A00DCEE5F /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */; }; 4B40FE5D2E0A917F000BDD07 /* QrCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */; }; 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */; }; - 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */; }; 4B5977472DF97D5C009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5977462DF97D5A009CC224 /* RemainderModel.swift */; }; 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */; }; @@ -190,6 +192,8 @@ 4B183EE92D7C791400C9D801 /* RemindersData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersData.swift; sourceTree = "<group>"; }; 4B183EEB2D7CB11500C9D801 /* CourseRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRefs.swift; sourceTree = "<group>"; }; 4B1BDBCB2E1396A9008C2DE9 /* ToolTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolTip.swift; sourceTree = "<group>"; }; + 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; }; + 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 4B2DD6942E0A702D00BC3B67 /* CircleRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleRequests.swift; sourceTree = "<group>"; }; 4B341C0D2E18028A0073906B /* FreindRequestModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestModel.swift; sourceTree = "<group>"; }; 4B341C0F2E1802FC0073906B /* FreindRequestViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreindRequestViewModel.swift; sourceTree = "<group>"; }; @@ -199,7 +203,6 @@ 4B37F1E82E04173500DCEE5F /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; }; 4B40FE5C2E0A9179000BDD07 /* QrCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrCode.swift; sourceTree = "<group>"; }; 4B47CD7A2D7DCB8400A46FEF /* CreateReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateReminder.swift; sourceTree = "<group>"; }; - 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 4B5977462DF97D5A009CC224 /* RemainderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemainderModel.swift; sourceTree = "<group>"; }; 4B74D8722E0BDF1E00B390E9 /* CourseFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseFile.swift; sourceTree = "<group>"; }; 4B74D8762E0BF77400B390E9 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; }; @@ -401,7 +404,7 @@ isa = PBXGroup; children = ( 4BC853C52DF6F71B0092B2E2 /* VittyWidgetExtension.entitlements */, - 4B4FCF622D317AFD002B392C /* GoogleService-Info.plist */, + 4B2D64912E20C1AC00412CB7 /* GoogleService-Info.plist */, 52EE849D2CB9CD1F00CD864C /* GoogleService-Info.plist */, 5251A7FF2B46E3C000D44CFE /* .swift-format */, 314A408E27383BEC0058082F /* VITTYApp.swift */, @@ -865,6 +868,7 @@ 528CF1712B769AF2007298A0 /* Models */ = { isa = PBXGroup; children = ( + 4B2D648E2E20BA5A00412CB7 /* NetworkMonitor.swift */, 528CF1722B769B18007298A0 /* TimeTable.swift */, ); path = Models; @@ -1096,7 +1100,7 @@ 31128CFA2772F57E0084C9EA /* Poppins-SemiBoldItalic.ttf in Resources */, 31128CFC2772F57E0084C9EA /* Poppins-Regular.ttf in Resources */, 52EE849E2CB9CD1F00CD864C /* GoogleService-Info.plist in Resources */, - 4B4FCF632D317AFD002B392C /* GoogleService-Info.plist in Resources */, + 4B2D64922E20C1AC00412CB7 /* GoogleService-Info.plist in Resources */, 314A409627383BEE0058082F /* Preview Assets.xcassets in Resources */, 314A409327383BEE0058082F /* Assets.xcassets in Resources */, ); @@ -1123,6 +1127,7 @@ 4B7DA5DF2D7094E8007354A3 /* Academics.swift in Sources */, 4B341C0E2E1802910073906B /* FreindRequestModel.swift in Sources */, 4B7DA5F22D7228F9007354A3 /* JoinGroup.swift in Sources */, + 4B2D648F2E20BA6300412CB7 /* NetworkMonitor.swift in Sources */, 524B842F2B46EBBD006D18BD /* HomeView.swift in Sources */, 527E3E082B7662920086F23D /* TimeTableView.swift in Sources */, 524B843A2B46F5C6006D18BD /* AddFriendsView.swift in Sources */, @@ -1213,6 +1218,7 @@ files = ( 4B5977482DFAC034009CC224 /* RemainderModel.swift in Sources */, 4BC853C42DF6DA7A0092B2E2 /* TimeTable.swift in Sources */, + 4B2D64902E20BA6300412CB7 /* NetworkMonitor.swift in Sources */, 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */, 4B74D8732E0BDF2100B390E9 /* CourseFile.swift in Sources */, ); @@ -1264,7 +1270,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; diff --git a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift index 05a21c3..2fdcd71 100644 --- a/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift +++ b/VITTY/VITTY/Auth/ViewModels/AuthViewModel.swift @@ -136,7 +136,7 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { token: backendUser.token, username: backendUser.username ) - + print("this is the log need to check \(backendUser)") UserDefaults.standard.set(backendUser.token, forKey: UserDefaultKeys.tokenKey) UserDefaults.standard.set(backendUser.username, forKey: UserDefaultKeys.usernameKey) @@ -271,8 +271,9 @@ class AuthViewModel: NSObject, ASAuthorizationControllerDelegate { if let firebaseUser = self.loggedInFirebaseUser { await checkBackendUserExists(uuid: firebaseUser.uid,url: APIConstants.base_url) } - - + + + } private func signInWithApple() { diff --git a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift index 8215cb2..86231d6 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift @@ -4,10 +4,10 @@ // // Created by Rujin Devkota on 2/27/25. // -import SwiftUI import SwiftUI + struct CirclesView: View { @Binding var isCreatingGroup: Bool @State private var searchText = "" diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 045b66f..eb744a2 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -372,6 +372,7 @@ class CommunityPageViewModel { let detail: String } + func createCircle(name: String, token: String, completion: @escaping (Result<String, Error>) -> Void) { guard let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { @@ -390,20 +391,21 @@ class CommunityPageViewModel { case .success(let data): if data.detail.lowercased().contains("successfully") { self.logger.info("Successfully created circle: \(name)") - completion(.success(name)) + + // Now fetch the updated circles data and wait for completion + self.fetchCircleDataWithCompletion( + from: "\(APIConstants.base_url)circles", + token: token, + circleName: name, + completion: completion + ) + } else { let error = NSError(domain: "CreateCircleError", code: 1, userInfo: [NSLocalizedDescriptionKey: data.detail]) self.logger.error("Error creating circle: \(data.detail)") completion(.failure(error)) } - - self.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: token, - loading: false - ) - case .failure(let error): self.logger.error("Error creating circle: \(error)") completion(.failure(error)) @@ -411,6 +413,37 @@ class CommunityPageViewModel { } } } + + + private func fetchCircleDataWithCompletion(from url: String, token: String, circleName: String, completion: @escaping (Result<String, Error>) -> Void) { + + AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) + .validate() + .responseDecodable(of: CircleResponse.self) { response in + DispatchQueue.main.async { + switch response.result { + case .success(let data): + self.circles = data.data + self.errorCircle = false + print("Successfully fetched circles after creation: \(data.data)") + + + if let createdCircle = self.circles.first(where: { $0.circleName == circleName }) { + print("Found created circle with ID: \(createdCircle.circleID)") + completion(.success(createdCircle.circleID)) + } else { + let error = NSError(domain: "CreateCircleError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not find created circle in updated data"]) + self.logger.error("Could not find created circle: \(circleName)") + completion(.failure(error)) + } + + case .failure(let error): + self.logger.error("Error fetching circles after creation: \(error)") + completion(.failure(error)) + } + } + } + } func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { diff --git a/VITTY/VITTY/Info.plist b/VITTY/VITTY/Info.plist index 5299857..d1aca8e 100644 --- a/VITTY/VITTY/Info.plist +++ b/VITTY/VITTY/Info.plist @@ -13,7 +13,7 @@ <string>Google SignIn</string> <key>CFBundleURLSchemes</key> <array> - <string>com.googleusercontent.apps.272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63</string> + <string>com.googleusercontent.apps.266303676876-77duk9tr18717lspccrvjuqcnuv0dp2s</string> </array> </dict> <dict> @@ -30,7 +30,7 @@ <key>FirebaseAppDelegateProxyEnabled</key> <false/> <key>GIDClientID</key> - <string>272763363329-i8n51oo9m30h9it7qq9ufmd0lahnmm63.apps.googleusercontent.com</string> + <string>266303676876-77duk9tr18717lspccrvjuqcnuv0dp2s.apps.googleusercontent.com</string> <key>LSApplicationQueriesSchemes</key> <array> <string>comgooglemaps</string> diff --git a/VITTY/VITTY/Settings/View/SettingsView.swift b/VITTY/VITTY/Settings/View/SettingsView.swift index d6a3bfc..b66f4db 100644 --- a/VITTY/VITTY/Settings/View/SettingsView.swift +++ b/VITTY/VITTY/Settings/View/SettingsView.swift @@ -14,6 +14,10 @@ struct SettingsView: View { @State private var showResetAlert = false @State private var showDeleteUserAlert = false @State private var isDeletingUser = false + @State private var isSyncing = false + @State private var showSyncAlert = false + @State private var syncMessage = "" + @State private var syncSuccess = false private let selectedDayKey = "SelectedSaturdayDay" @@ -45,6 +49,36 @@ struct SettingsView: View { } } + SettingsSectionView(title: "Timetable Management") { + VStack(alignment: .leading, spacing: 12) { + Button { + syncTimetable() + } label: { + SettingsRowView( + icon: "arrow.clockwise.circle.fill", + title: "Sync Timetable", + subtitle: isSyncing ? "Syncing..." : "Update timetable from server", + isLoading: isSyncing + ) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isSyncing) + + Button { + if let url = URL(string: "https://vitty.dscvit.com") { + UIApplication.shared.open(url) + } + } label: { + SettingsRowView( + icon: "pencil.and.ellipsis.rectangle", + title: "Update Timetable Online", + subtitle: "Modify your timetable on the web portal" + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + SettingsSectionView(title: "Class Settings") { VStack(alignment: .leading, spacing: 12) { Button { @@ -94,19 +128,6 @@ struct SettingsView: View { ) } .buttonStyle(PlainButtonStyle()) - - Button { - if let url = URL(string: "https://vitty.dscvit.com") { - UIApplication.shared.open(url) - } - } label: { - SettingsRowView( - icon: "pencil.and.ellipsis.rectangle", - title: "Update Timetable", - subtitle: "Keep your timetable up-to-date. Don't miss a class." - ) - } - .buttonStyle(PlainButtonStyle()) } } @@ -168,6 +189,17 @@ struct SettingsView: View { ) .zIndex(1) } + + if showSyncAlert { + SyncAlert( + message: syncMessage, + isSuccess: syncSuccess, + onDismiss: { + showSyncAlert = false + } + ) + .zIndex(1) + } } .navigationBarBackButtonHidden(true) .interactiveDismissDisabled(true) @@ -175,7 +207,6 @@ struct SettingsView: View { viewModel.timetable = timeTables.first viewModel.checkNotificationAuthorization() loadSelectedDay() - print("Saturday before save:", timeTables.first?.saturday.map { $0.name } ?? []) } .alert("Notifications Disabled", isPresented: $viewModel.showNotificationDisabledAlert) { Button("OK", role: .cancel) {} @@ -185,6 +216,163 @@ struct SettingsView: View { } } + // MARK: - Sync Timetable Functions + + // MARK: - Updated Sync Timetable Functions for SettingsView + + private func syncTimetable() { + guard let username = authViewModel.loggedInBackendUser?.username, + let authToken = authViewModel.loggedInBackendUser?.token else { + showSyncMessage("Unable to sync: No authentication credentials", success: false) + return + } + + isSyncing = true + + Task { + + let syncViewModel = TimeTableView.TimeTableViewModel() + + await syncViewModel.forceSync( + username: username, + authToken: authToken, + context: modelContext + ) + + await MainActor.run { + isSyncing = false + + // Check if sync was successful + if syncViewModel.stage == .data { + showSyncMessage("Timetable synced successfully!", success: true) + } else { + showSyncMessage("Sync failed. Please try again.", success: false) + } + } + } + } + + + private func syncTimetableAlternative() { + guard let username = authViewModel.loggedInBackendUser?.username, + let authToken = authViewModel.loggedInBackendUser?.token else { + showSyncMessage("Unable to sync: No authentication credentials", success: false) + return + } + + isSyncing = true + + Task { + do { + // Fetch latest timetable from API + let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( + with: username, + authToken: authToken + ) + + await MainActor.run { + updateLocalTimetable(with: remoteTimeTable) + } + + } catch { + await MainActor.run { + isSyncing = false + showSyncMessage("Sync failed: \(error.localizedDescription)", success: false) + } + } + } + } + + private func updateLocalTimetable(with remoteTimeTable: TimeTable) { + guard let currentTimeTable = timeTables.first else { + + insertNewTimetable(remoteTimeTable) + return + } + + + let finalTimeTable = preserveSaturdayCustomization( + remote: remoteTimeTable, + local: currentTimeTable + ) + + do { + + modelContext.delete(currentTimeTable) + + + modelContext.insert(finalTimeTable) + + + try modelContext.save() + + isSyncing = false + showSyncMessage("Timetable synced successfully!", success: true) + + + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) + + } catch { + isSyncing = false + showSyncMessage("Failed to save synced timetable: \(error.localizedDescription)", success: false) + modelContext.rollback() + } + } + + private func insertNewTimetable(_ timeTable: TimeTable) { + do { + modelContext.insert(timeTable) + try modelContext.save() + + isSyncing = false + showSyncMessage("Timetable synced successfully!", success: true) + + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) + + } catch { + isSyncing = false + showSyncMessage("Failed to save new timetable: \(error.localizedDescription)", success: false) + } + } + + private func preserveSaturdayCustomization(remote: TimeTable, local: TimeTable) -> TimeTable { + // Create new timetable with remote data + let newTimeTable = TimeTable( + monday: remote.monday.map { $0.deepCopy() }, + tuesday: remote.tuesday.map { $0.deepCopy() }, + wednesday: remote.wednesday.map { $0.deepCopy() }, + thursday: remote.thursday.map { $0.deepCopy() }, + friday: remote.friday.map { $0.deepCopy() }, + saturday: remote.saturday.map { $0.deepCopy() }, + sunday: remote.sunday.map { $0.deepCopy() } + ) + + // Preserve Saturday customization from local if it exists + if let saturdaySourceDay = local.saturdaySourceDay { + print("Preserving Saturday customization from: \(saturdaySourceDay)") + + let lecturesToCopy = newTimeTable.lectures(forDay: saturdaySourceDay) + newTimeTable.saturday = lecturesToCopy.map { $0.deepCopy() } + newTimeTable.saturdaySourceDay = saturdaySourceDay + } + + return newTimeTable + } + + private func showSyncMessage(_ message: String, success: Bool) { + syncMessage = message + syncSuccess = success + showSyncAlert = true + } + + // MARK: - Existing Functions + private func loadSelectedDay() { selectedDay = timeTables.first?.saturdaySourceDay } @@ -202,7 +390,6 @@ struct SettingsView: View { try await deleteUserFromServer(username: username) await MainActor.run { - Task { await cleanupLocalData() authViewModel.signOut() @@ -242,7 +429,6 @@ struct SettingsView: View { private func cleanupLocalData() async { do { - await Task.detached { [modelContext] in do { try modelContext.delete(model: TimeTable.self) @@ -261,65 +447,75 @@ struct SettingsView: View { } - private func copyLecturesToSaturday(from day: String) { guard let currentTimeTable = timeTables.first else { print("No timetable found") return } - + print("Starting SAFE copy from \(day) to Saturday - DELETE & RECREATE approach") - let lecturesToCopy = currentTimeTable.lectures(forDay: day) - print("Found \(lecturesToCopy.count) lectures to copy") - - - let newSaturdayLectures = lecturesToCopy.map { originalLecture in - let newLecture = Lecture( - name: originalLecture.name, - code: originalLecture.code, - venue: originalLecture.venue, - slot: originalLecture.slot, - type: originalLecture.type, - startTime: originalLecture.startTime, - endTime: originalLecture.endTime - ) - return newLecture - } - - - let newTimeTable = TimeTable( + print("Found \(lecturesToCopy.count) lectures to copy from \(day)") + + let backupData = ( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, wednesday: currentTimeTable.wednesday.map { $0.deepCopy() }, thursday: currentTimeTable.thursday.map { $0.deepCopy() }, friday: currentTimeTable.friday.map { $0.deepCopy() }, - saturday: newSaturdayLectures, + saturday: currentTimeTable.saturday.map { $0.deepCopy() }, sunday: currentTimeTable.sunday.map { $0.deepCopy() }, - saturdaySourceDay: day + saturdaySourceDay: currentTimeTable.saturdaySourceDay ) - do { - print("Deleting old timetable") + modelContext.delete(currentTimeTable) + print("Deleted existing timetable") + + + let newSaturdayLectures = lecturesToCopy.map { originalLecture in + Lecture( + name: originalLecture.name, + code: originalLecture.code, + venue: originalLecture.venue, + slot: originalLecture.slot, + type: originalLecture.type, + startTime: originalLecture.startTime, + endTime: originalLecture.endTime + ) + } + + print("Created \(newSaturdayLectures.count) new lectures for Saturday") + + + let newTimeTable = TimeTable( + monday: backupData.monday, + tuesday: backupData.tuesday, + wednesday: backupData.wednesday, + thursday: backupData.thursday, + friday: backupData.friday, + saturday: newSaturdayLectures, + sunday: backupData.sunday, + saturdaySourceDay: day + ) - print("Inserting new timetable with Saturday lectures") modelContext.insert(newTimeTable) + print("Inserted new timetable with Saturday lectures") - + try modelContext.save() self.selectedDay = day - print("Successfully copied \(day) to Saturday using orthodox method") + print("Successfully recreated timetable with \(day) copied to Saturday") print("New Saturday has \(newTimeTable.saturday.count) lectures") - Task { @MainActor in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil @@ -327,41 +523,72 @@ struct SettingsView: View { } } catch { - print("Error during orthodox copy: \(error)") - + print("Error during timetable recreation: \(error)") modelContext.rollback() + + + print("Attempting to restore backup data...") + do { + let restoredTimeTable = TimeTable( + monday: backupData.monday, + tuesday: backupData.tuesday, + wednesday: backupData.wednesday, + thursday: backupData.thursday, + friday: backupData.friday, + saturday: backupData.saturday, + sunday: backupData.sunday, + saturdaySourceDay: backupData.saturdaySourceDay + ) + + modelContext.insert(restoredTimeTable) + try modelContext.save() + print("Successfully restored backup data") + } catch { + print("Failed to restore backup data: \(error)") + } } } - - + private func resetSaturdayClasses() { guard let currentTimeTable = timeTables.first else { print("No timetable found") return } - print("Starting orthodox reset of Saturday classes") + print("Starting SAFE reset of Saturday classes - DELETE & RECREATE approach") - - let newTimeTable = TimeTable( + + let backupData = ( monday: currentTimeTable.monday.map { $0.deepCopy() }, tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, wednesday: currentTimeTable.wednesday.map { $0.deepCopy() }, thursday: currentTimeTable.thursday.map { $0.deepCopy() }, friday: currentTimeTable.friday.map { $0.deepCopy() }, - saturday: [], + saturday: currentTimeTable.saturday.map { $0.deepCopy() }, sunday: currentTimeTable.sunday.map { $0.deepCopy() }, - saturdaySourceDay: nil + saturdaySourceDay: currentTimeTable.saturdaySourceDay ) - do { - print("Deleting old timetable") + modelContext.delete(currentTimeTable) + print("Deleted existing timetable") + + + let newTimeTable = TimeTable( + monday: backupData.monday, + tuesday: backupData.tuesday, + wednesday: backupData.wednesday, + thursday: backupData.thursday, + friday: backupData.friday, + saturday: [], + sunday: backupData.sunday, + saturdaySourceDay: nil + ) - - print("Inserting new timetable with empty Saturday") + modelContext.insert(newTimeTable) + print("Inserted new timetable with empty Saturday") try modelContext.save() @@ -369,10 +596,10 @@ struct SettingsView: View { self.selectedDay = nil - print("Successfully reset Saturday classes using orthodox method") + print("Successfully recreated timetable with empty Saturday") - - Task { @MainActor in + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { NotificationCenter.default.post( name: NSNotification.Name("TimetableDidChange"), object: nil @@ -380,11 +607,32 @@ struct SettingsView: View { } } catch { - print("Error during orthodox reset: \(error)") - + print("Error during timetable reset: \(error)") modelContext.rollback() + + + print("Attempting to restore backup data...") + do { + let restoredTimeTable = TimeTable( + monday: backupData.monday, + tuesday: backupData.tuesday, + wednesday: backupData.wednesday, + thursday: backupData.thursday, + friday: backupData.friday, + saturday: backupData.saturday, + sunday: backupData.sunday, + saturdaySourceDay: backupData.saturdaySourceDay + ) + + modelContext.insert(restoredTimeTable) + try modelContext.save() + print("Successfully restored backup data") + } catch { + print("Failed to restore backup data: \(error)") + } } } + private var headerView: some View { HStack { @@ -423,12 +671,27 @@ struct SettingsView: View { let icon: String let title: String let subtitle: String + let isLoading: Bool + + init(icon: String, title: String, subtitle: String, isLoading: Bool = false) { + self.icon = icon + self.title = title + self.subtitle = subtitle + self.isLoading = isLoading + } var body: some View { HStack(alignment: .top, spacing: 12) { - Image(systemName: icon) - .foregroundColor(.white) - .frame(width: 30, height: 30) + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(0.8) + .frame(width: 30, height: 30) + } else { + Image(systemName: icon) + .foregroundColor(.white) + .frame(width: 30, height: 30) + } VStack(alignment: .leading, spacing: 4) { Text(title) @@ -473,7 +736,8 @@ struct SettingsView: View { } } -// Custom Reset Alert Component +// MARK: - Alert Components + struct ResetSaturdayAlert: View { let onCancel: () -> Void let onReset: () -> Void @@ -523,12 +787,11 @@ struct ResetSaturdayAlert: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .onTapGesture { - // Empty tap gesture to prevent dismissal + } } } -// Custom Delete User Alert Component struct DeleteUserAlert: View { let isDeleting: Bool let onCancel: () -> Void @@ -589,7 +852,54 @@ struct DeleteUserAlert: View { } .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) .onTapGesture { - // Empty tap gesture to prevent dismissal + + } + } +} + +struct SyncAlert: View { + let message: String + let isSuccess: Bool + let onDismiss: () -> Void + + var body: some View { + VStack { + Spacer() + VStack(spacing: 16) { + Image(systemName: isSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 40)) + .foregroundColor(isSuccess ? .green : .red) + + Text(isSuccess ? "Sync Successful" : "Sync Failed") + .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, 10) + .frame(maxWidth: .infinity) + .background(isSuccess ? Color.green : Color.red) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .frame(minHeight: 180) + .padding(20) + .background(Color("Background")) + .cornerRadius(16) + .padding(.horizontal, 30) + .transition(.scale.combined(with: .opacity)) + Spacer() + } + .background(Color.black.opacity(0.5).edgesIgnoringSafeArea(.all)) + .onTapGesture { + } } } diff --git a/VITTY/VITTY/Shared/Constants.swift b/VITTY/VITTY/Shared/Constants.swift index 2f8ca1f..5a8ed9d 100644 --- a/VITTY/VITTY/Shared/Constants.swift +++ b/VITTY/VITTY/Shared/Constants.swift @@ -12,7 +12,7 @@ class Constants { // "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" - "http://localhost:80/api/v2/" + "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" // "https://f4df-2409-40e3-30a4-8539-6d49-631b-ddd8-60a3.ngrok-free.app/api/v2/" diff --git a/VITTY/VITTY/TimeTable/Models/NetworkMonitor.swift b/VITTY/VITTY/TimeTable/Models/NetworkMonitor.swift new file mode 100644 index 0000000..02a7cc4 --- /dev/null +++ b/VITTY/VITTY/TimeTable/Models/NetworkMonitor.swift @@ -0,0 +1,31 @@ +// +// NetworkMonitor.swift +// VITTY +// +// Created by Rujin Devkota on 7/11/25. +// + + + +import Foundation +import Network + + + +class NetworkMonitor: ObservableObject { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "NetworkMonitor") + + + @Published var isConnected = true + + init() { + monitor.pathUpdateHandler = { path in + + DispatchQueue.main.async { + self.isConnected = path.status == .satisfied + } + } + monitor.start(queue: queue) + } +} diff --git a/VITTY/VITTY/TimeTable/Models/TimeTable.swift b/VITTY/VITTY/TimeTable/Models/TimeTable.swift index ca90c05..d6a8fc9 100644 --- a/VITTY/VITTY/TimeTable/Models/TimeTable.swift +++ b/VITTY/VITTY/TimeTable/Models/TimeTable.swift @@ -4,19 +4,12 @@ // // Created by Chandram Dutta on 09/02/24. // -// -// TimeTable.swift -// VITTY -// -// Created by Chandram Dutta on 09/02/24. -// + import Foundation import OSLog import SwiftData - - class TimeTableRaw: Codable { let data: TimeTable @@ -231,7 +224,7 @@ extension TimeTable { Classes( title: $0.name, time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", - slot: $0.slot + slot: $0.venue // NOTE: Passing venue instead of slot for display purposes ) } @@ -246,7 +239,7 @@ extension TimeTable { Classes( title: $0.name, time: "\(formatTime(time: $0.startTime)) - \(formatTime(time: $0.endTime))", - slot: $0.slot + slot: $0.venue // NOTE: Passing venue instead of slot for display purposes ) } } @@ -278,4 +271,3 @@ extension TimeTable { sunday != other.sunday } } - diff --git a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift index ec4cf2b..4775855 100644 --- a/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift +++ b/VITTY/VITTY/TimeTable/ViewModel/TimeTableViewModel.swift @@ -8,6 +8,8 @@ import Foundation import OSLog import SwiftData +import Network +import SwiftUI public enum Stage { case loading @@ -24,61 +26,72 @@ extension TimeTableView { var lectures = [Lecture]() var dayNo = Date.convertToMondayWeek() - private var hasSyncedThisSession = false - private var isSyncing = false - - // Serial queue for database operations to prevent race conditions - private let databaseQueue = DispatchQueue(label: "com.vitty.database", qos: .userInitiated) + private var networkMonitor = NetworkMonitor() private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, - category: String( - describing: TimeTableViewModel.self - ) + category: String(describing: TimeTableViewModel.self) ) private var notificationObserver: NSObjectProtocol? - - init() { - setupNotificationObserver() - } - - deinit { - if let observer = notificationObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - private func setupNotificationObserver() { - notificationObserver = NotificationCenter.default.addObserver( - forName: NSNotification.Name("TimetableDidChange"), - object: nil, - queue: .main - ) { [weak self] _ in - self?.forceRefreshCurrentDay() - } - } - private func forceRefreshCurrentDay() { - logger.info("Forcing refresh of current day due to timetable change") - changeDay() - } - // NEW: Method to refresh the timetable data from the database - @MainActor - func refreshFromDatabase(_ updatedTimeTable: TimeTable?) { - guard let updatedTimeTable = updatedTimeTable else { - self.timeTable = nil - self.lectures = [] - self.stage = .error - return + + init() { + setupNotificationObserver() + } + + deinit { + if let observer = notificationObserver { + NotificationCenter.default.removeObserver(observer) } - - // Update our cached copy with the fresh data - self.timeTable = updatedTimeTable - changeDay() // Refresh the current day's lectures - - logger.info("Timetable refreshed from database") } + private func setupNotificationObserver() { + notificationObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("TimetableDidChange"), + object: nil, + queue: .main + ) { [weak self] _ in + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self?.forceRefreshCurrentDay() + } + } + } + + private func forceRefreshCurrentDay() { + logger.info("Forcing refresh of current day due to timetable change") + + + changeDay() + + + let currentStage = stage + stage = .loading + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.stage = currentStage + } + } + + @MainActor + func refreshFromDatabase(_ updatedTimeTable: TimeTable?) { + logger.info("Refreshing from database") + + guard let updatedTimeTable = updatedTimeTable else { + self.timeTable = nil + self.lectures = [] + self.stage = .error + logger.error("No updated timetable provided") + return + } + + self.timeTable = updatedTimeTable + changeDay() + self.stage = .data + + logger.info("Timetable refreshed from database successfully") + } + func changeDay() { guard let timeTable = timeTable else { self.lectures = [] @@ -86,277 +99,324 @@ extension TimeTableView { } switch dayNo { - case 0: - self.lectures = timeTable.monday - case 1: - self.lectures = timeTable.tuesday - case 2: - self.lectures = timeTable.wednesday - case 3: - self.lectures = timeTable.thursday - case 4: - self.lectures = timeTable.friday - case 5: - self.lectures = timeTable.saturday - case 6: - self.lectures = timeTable.sunday - default: - self.lectures = [] + case 0: self.lectures = timeTable.monday + case 1: self.lectures = timeTable.tuesday + case 2: self.lectures = timeTable.wednesday + case 3: self.lectures = timeTable.thursday + case 4: self.lectures = timeTable.friday + case 5: self.lectures = timeTable.saturday + case 6: self.lectures = timeTable.sunday + default: self.lectures = [] } } - + // MARK: - Local First Load Implementation @MainActor - func loadTimeTable( - existingTimeTable: TimeTable?, - username: String, - authToken: String, - context: ModelContext - ) async { - logger.info("Starting timetable loading process") - - if let existing = existingTimeTable { - logger.debug("Using existing local timetable.") - self.timeTable = existing - changeDay() - self.stage = .data - - - if !hasSyncedThisSession && !isSyncing && !username.isEmpty && !authToken.isEmpty { - - Task { [weak self] in - await self?.backgroundSync( - localTimeTable: existing, - username: username, - authToken: authToken, - context: context - ) - } - } - } else { - logger.debug("No local timetable, fetching from API.") - await fetchTimeTableFromAPI( - username: username, - authToken: authToken, - context: context - ) - } - } - - private func backgroundSync( - localTimeTable: TimeTable, + func loadTimeTable( + existingTimeTable: TimeTable?, username: String, authToken: String, context: ModelContext ) async { - guard !isSyncing else { - logger.info("Sync already in progress. Skipping.") - return - } + logger.info("Starting local-first timetable loading process") - - await MainActor.run { - isSyncing = true + // Step 1: Check if we have local data + if let existingTimeTable = existingTimeTable { + logger.info("Found local timetable data - using it") + useLocalData(existingTimeTable: existingTimeTable) + return } - logger.info("Starting background sync.") + // Step 2: No local data exists - fetch from API and save + logger.info("No local timetable found - fetching from API") + stage = .loading - defer { - Task { @MainActor in - isSyncing = false - } + // Only fetch if we have credentials + if !username.isEmpty && !authToken.isEmpty { + await fetchAndSaveFromAPI( + username: username, + authToken: authToken, + context: context + ) + } else { + logger.error("No credentials available for API fetch") + stage = .error } - + } + + // MARK: - Use Local Data (Always prioritized) + @MainActor + private func useLocalData(existingTimeTable: TimeTable) { + logger.info("Using local timetable data") + self.timeTable = existingTimeTable + changeDay() + stage = .data + } + + // MARK: - Fetch from API and Save to Local Storage + @MainActor + private func fetchAndSaveFromAPI( + username: String, + authToken: String, + context: ModelContext + ) async { do { + logger.info("Fetching timetable from API") let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( with: username, authToken: authToken ) - logger.info("Background sync: Fetched remote timetable.") - - let mergedTimeTable = await createMergedTimeTable( - remote: remoteTimeTable, - local: localTimeTable - ) - - - await updateLocalDatabaseSafely( - with: mergedTimeTable, - oldTimeTable: localTimeTable, + // Save to local storage + await saveToLocalStorage( + newTimeTable: remoteTimeTable, context: context ) - await MainActor.run { - hasSyncedThisSession = true - } + // Update UI + self.timeTable = remoteTimeTable + changeDay() + stage = .data + + logger.info("Successfully fetched and saved timetable from API") } catch { - logger.error("Background sync failed: \(error.localizedDescription)") - - } - } - - private func createMergedTimeTable( - remote: TimeTable, - local: TimeTable - ) async -> TimeTable { - let saturdaySourceDay = local.saturdaySourceDay - - let finalTimeTable = TimeTable( - monday: remote.monday.map { $0.deepCopy() }, - tuesday: remote.tuesday.map { $0.deepCopy() }, - wednesday: remote.wednesday.map { $0.deepCopy() }, - thursday: remote.thursday.map { $0.deepCopy() }, - friday: remote.friday.map { $0.deepCopy() }, - saturday: [], - sunday: remote.sunday.map { $0.deepCopy() } - ) - - - if let sourceDay = saturdaySourceDay { - logger.info("Re-applying Saturday rule from source day: \(sourceDay).") - let lecturesToCopy = finalTimeTable.lectures(forDay: sourceDay) - finalTimeTable.saturday = lecturesToCopy.map { $0.deepCopy() } - finalTimeTable.saturdaySourceDay = sourceDay + logger.error("Failed to fetch from API: \(error.localizedDescription)") + stage = .error } - - return finalTimeTable } + // MARK: - Save to Local Storage @MainActor - private func updateLocalDatabaseSafely( - with newTimeTable: TimeTable, - oldTimeTable: TimeTable, + private func saveToLocalStorage( + newTimeTable: TimeTable, context: ModelContext ) async { - logger.info("Updating local database with merged timetable.") - - do { - - context.delete(oldTimeTable) + // Insert new timetable context.insert(newTimeTable) try context.save() - - self.timeTable = newTimeTable - changeDay() - logger.info("Local database successfully updated and persisted.") + logger.info("Successfully saved timetable to local storage") - } catch { - logger.error("Failed to save merged timetable: \(error.localizedDescription)") - - do { - - context.rollback() - + // Notify other components + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) - context.insert(oldTimeTable) - try context.save() - - - self.timeTable = oldTimeTable - changeDay() - logger.info("Rollback successful.") - - } catch { - logger.error("Rollback also failed: \(error.localizedDescription)") - // In this case, trigger a fresh fetch - await handleDatabaseCorruption(context: context) - } + } catch { + logger.error("Failed to save timetable to local storage: \(error.localizedDescription)") + context.rollback() } } + // MARK: - Force Sync (Explicit user action) @MainActor - private func handleDatabaseCorruption(context: ModelContext) async { - logger.warning("Handling potential database corruption.") + func forceSync( + username: String, + authToken: String, + context: ModelContext + ) async { + logger.info("Force syncing timetable from server") - - self.timeTable = nil - self.lectures = [] - self.stage = .error + // Set loading state + stage = .loading - - hasSyncedThisSession = false - isSyncing = false + // Get existing timetable for Saturday preservation + let existingTimeTable = timeTable - logger.info("Database corruption handled. User will need to reload.") + // Force fetch from API + if !username.isEmpty && !authToken.isEmpty { + await fetchAndUpdateFromAPI( + existingTimeTable: existingTimeTable, + username: username, + authToken: authToken, + context: context + ) + } else { + logger.error("Cannot force sync without credentials") + stage = .error + } } + // MARK: - Fetch and Update from API (for sync) @MainActor - private func fetchTimeTableFromAPI( + private func fetchAndUpdateFromAPI( + existingTimeTable: TimeTable?, username: String, authToken: String, context: ModelContext ) async { - logger.info("Fetching TimeTable from API for initial load.") - stage = .loading - - guard !username.isEmpty && !authToken.isEmpty else { - logger.error("Username or auth token is empty") - stage = .error - return - } - do { + logger.info("Fetching updated timetable from API") let remoteTimeTable = try await TimeTableAPIService.shared.getTimeTable( with: username, authToken: authToken ) - logger.info("TimeTable fetched from API.") - - context.insert(remoteTimeTable) - try context.save() + // Preserve Saturday customization if it exists + let finalTimeTable = preserveSaturdayCustomization( + remote: remoteTimeTable, + local: existingTimeTable + ) - self.timeTable = remoteTimeTable + // Update local storage + await updateLocalStorage( + newTimeTable: finalTimeTable, + oldTimeTable: existingTimeTable, + context: context + ) + + // Update UI + self.timeTable = finalTimeTable changeDay() stage = .data - hasSyncedThisSession = true + + logger.info("Successfully synced timetable from API") } catch { - logger.error("API fetch failed: \(error.localizedDescription)") + logger.error("Failed to sync from API: \(error.localizedDescription)") stage = .error } } + // MARK: - Update Local Storage (for sync) + @MainActor + private func updateLocalStorage( + newTimeTable: TimeTable, + oldTimeTable: TimeTable?, + context: ModelContext + ) async { + do { + // Remove old timetable if it exists + if let oldTimeTable = oldTimeTable { + context.delete(oldTimeTable) + } + + // Insert new timetable + context.insert(newTimeTable) + try context.save() + + logger.info("Successfully updated local storage") + + // Notify other components + NotificationCenter.default.post( + name: NSNotification.Name("TimetableDidChange"), + object: nil + ) + + } catch { + logger.error("Failed to update local storage: \(error.localizedDescription)") + context.rollback() + + // Re-insert old timetable if it existed + if let oldTimeTable = oldTimeTable { + context.insert(oldTimeTable) + try? context.save() + } + } + } + + // MARK: - Preserve Saturday Customization + private func preserveSaturdayCustomization( + remote: TimeTable, + local: TimeTable? + ) -> TimeTable { + // Create new timetable with remote data + let newTimeTable = TimeTable( + monday: remote.monday.map { $0.deepCopy() }, + tuesday: remote.tuesday.map { $0.deepCopy() }, + wednesday: remote.wednesday.map { $0.deepCopy() }, + thursday: remote.thursday.map { $0.deepCopy() }, + friday: remote.friday.map { $0.deepCopy() }, + saturday: remote.saturday.map { $0.deepCopy() }, + sunday: remote.sunday.map { $0.deepCopy() } + ) + + // Preserve Saturday customization from local if it exists + if let local = local, + let saturdaySourceDay = local.saturdaySourceDay { + logger.info("Preserving Saturday customization from: \(saturdaySourceDay)") + + let lecturesToCopy = newTimeTable.lectures(forDay: saturdaySourceDay) + newTimeTable.saturday = lecturesToCopy.map { $0.deepCopy() } + newTimeTable.saturdaySourceDay = saturdaySourceDay + } + + return newTimeTable + } + + // MARK: - Saturday Management + @MainActor + func copyLecturesToSaturday( + from day: String, + context: ModelContext + ) async { + guard let currentTimeTable = timeTable else { + logger.error("No timetable available to copy from") + return + } + + logger.info("Copying lectures from \(day) to Saturday") + + let lecturesToCopy = currentTimeTable.lectures(forDay: day) + let newSaturdayLectures = lecturesToCopy.map { originalLecture in + Lecture( + name: originalLecture.name, + code: originalLecture.code, + venue: originalLecture.venue, + slot: originalLecture.slot, + type: originalLecture.type, + startTime: originalLecture.startTime, + endTime: originalLecture.endTime + ) + } + + // Create new timetable with Saturday lectures + let newTimeTable = TimeTable( + monday: currentTimeTable.monday.map { $0.deepCopy() }, + tuesday: currentTimeTable.tuesday.map { $0.deepCopy() }, + wednesday: currentTimeTable.wednesday.map { $0.deepCopy() }, + thursday: currentTimeTable.thursday.map { $0.deepCopy() }, + friday: currentTimeTable.friday.map { $0.deepCopy() }, + saturday: newSaturdayLectures, + sunday: currentTimeTable.sunday.map { $0.deepCopy() }, + saturdaySourceDay: day + ) + + await updateLocalStorage( + newTimeTable: newTimeTable, + oldTimeTable: currentTimeTable, + context: context + ) + + // Update UI + self.timeTable = newTimeTable + changeDay() + + logger.info("Successfully copied \(day) lectures to Saturday") + } + + // MARK: - Utility Methods func resetSyncStatus() { - hasSyncedThisSession = false - logger.debug("Sync status reset.") + // Simple reset - just refresh current day + changeDay() } var updatedTimeTable: TimeTable? { timeTable } + // MARK: - Deprecated Methods (kept for compatibility) @MainActor func forceRefresh( username: String, authToken: String, context: ModelContext ) async { - logger.info("Force refreshing timetable data.") - hasSyncedThisSession = false - isSyncing = false - - - if let existingTimeTable = timeTable { - do { - context.delete(existingTimeTable) - try context.save() - } catch { - logger.error("Failed to delete existing timetable: \(error.localizedDescription)") - - } - } - - - self.timeTable = nil - self.lectures = [] - - - await fetchTimeTableFromAPI( + // Redirect to forceSync for consistency + await forceSync( username: username, authToken: authToken, context: context diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index eb75e5f..a091267 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -109,7 +109,6 @@ struct TimeTableView: View { )! viewModel.changeDay() - proxy.scrollTo(day, anchor: .center) } } @@ -121,12 +120,10 @@ struct TimeTableView: View { } .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) @@ -199,29 +196,21 @@ struct TimeTableView: View { .onChange(of: timetableItem) { oldValue, newValue in logger.debug("Timetable data changed, reloading view.") - // NEW: Check if this is a meaningful change - let oldCount = oldValue.count - let newCount = newValue.count - - // Handle different change scenarios - if oldCount != newCount { + // Simplified change detection + if oldValue.count != newValue.count { // Data was added or removed loadTimetable() - } else if let oldTable = oldValue.first, let newTable = newValue.first { - // Check if the actual content changed (especially Saturday) - if oldTable.isDifferentFrom(newTable) { - logger.debug("Timetable content changed, refreshing ViewModel") - // Directly refresh the ViewModel with the new data - viewModel.refreshFromDatabase(newTable) - } + } else if let newTable = newValue.first { + // Data content changed + viewModel.refreshFromDatabase(newTable) } } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { viewModel.resetSyncStatus() - // Check if we need to reload due to potential data corruption - if viewModel.stage == .error || viewModel.timeTable == nil { + // Reload if in error state + if viewModel.stage == .error { loadTimetable() } } @@ -229,19 +218,30 @@ struct TimeTableView: View { } private func loadTimetable() { - guard !isRefreshing else { return } + logger.debug("Loading timetable with local-first approach") + + + let calendar = Calendar.current + let today = calendar.component(.weekday, from: Date()) - - Task { @MainActor in + + let dayIndex = (today == 1) ? 6 : today - 2 + + if dayIndex >= 0 && dayIndex < daysOfWeek.count { + viewModel.dayNo = dayIndex + } else { + viewModel.dayNo = 0 + } + + + Task { await viewModel.loadTimeTable( existingTimeTable: timetableItem.first, - username: friend?.username ?? (authViewModel.loggedInBackendUser?.username ?? ""), + username: authViewModel.loggedInBackendUser?.username ?? "", authToken: authViewModel.loggedInBackendUser?.token ?? "", context: context ) } - - logger.debug("User token: \(authViewModel.loggedInBackendUser?.token ?? "empty")") } private func refreshTimetable() async { diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 77acbfd..6833873 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -54,13 +54,11 @@ struct UserProfileSidebar: View { } Divider().background(Color.clear) - -// MenuOption(icon: "share", title: "Share") + MenuOption(icon: "support", title: "Support").onTapGesture { let supportUrl = URL(string: "https://github.com/GDGVIT/vitty-ios/issues/new?template=bug_report.md") UIApplication.shared.open(supportUrl!) } -// MenuOption(icon: "about", title: "About") Divider().background(Color.clear) diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index f55add2..6fd55c8 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -10,7 +10,7 @@ import Foundation struct APIConstants { - static let base_url = "http://localhost:80/api/v2/" + static let base_url = "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" static let createCircle = "circles/create/" static let sendRequest = "circles/sendRequest/" static let acceptRequest = "circles/acceptRequest/" diff --git a/VITTY/VittyWidget/Providers/ScheduleProvider.swift b/VITTY/VittyWidget/Providers/ScheduleProvider.swift index bae6de7..3495ea2 100644 --- a/VITTY/VittyWidget/Providers/ScheduleProvider.swift +++ b/VITTY/VittyWidget/Providers/ScheduleProvider.swift @@ -115,9 +115,9 @@ struct Provider: TimelineProvider { }.count } - // MARK: - Widget Content Preparation + // MARK: - Widget Size-Specific Content Preparation - private func prepareWidgetContent() -> (classes: [Classes], total: Int, completed: Int) { + private func prepareSmallMediumContent() -> (classes: [Classes], total: Int, completed: Int) { let allClasses = fetchAllTodaysClasses() let upcomingClasses = fetchUpcomingClasses() let currentClass = fetchCurrentClass() @@ -125,12 +125,12 @@ struct Provider: TimelineProvider { var displayClasses: [Classes] = [] - + // Add current class first if let current = currentClass { displayClasses.append(current) } - + // Add upcoming classes displayClasses.append(contentsOf: upcomingClasses) return ( @@ -140,6 +140,75 @@ struct Provider: TimelineProvider { ) } + private func prepareLargeContent() -> (classes: [Classes], total: Int, completed: Int) { + let allClasses = fetchAllTodaysClasses() + let completedCount = calculateCompletedClassesCount() + + // Get all classes sorted by time + let sortedClasses = getSortedClasses(allClasses) + + // Group classes into batches of 4 + let currentGroupClasses = getCurrentGroupOfFour(sortedClasses) + + return ( + classes: currentGroupClasses, + total: allClasses.count, + completed: completedCount + ) + } + + private func getSortedClasses(_ classes: [Classes]) -> [Classes] { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm a" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + return 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 + } + } + + private func getCurrentGroupOfFour(_ sortedClasses: [Classes]) -> [Classes] { + let currentTime = Date() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm a" + 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() { + let status = getClassStatus(classItem, at: currentTime) + if status == .current || status == .upcoming { + pivotIndex = index + break + } + } + + // Create groups of 4 starting from the beginning + let groupSize = 4 + let currentGroupIndex = pivotIndex / groupSize + let startIndex = currentGroupIndex * groupSize + let endIndex = min(startIndex + groupSize, sortedClasses.count) + + return Array(sortedClasses[startIndex..<endIndex]) + } + // MARK: - Timeline Provider Methods func placeholder(in context: Context) -> ScheduleEntry { @@ -154,7 +223,15 @@ struct Provider: TimelineProvider { } func getSnapshot(in context: Context, completion: @escaping (ScheduleEntry) -> ()) { - let content = prepareWidgetContent() + let content: (classes: [Classes], total: Int, completed: Int) + + // Use different content preparation based on widget size + switch context.family { + case .systemLarge: + content = prepareLargeContent() + default: + content = prepareSmallMediumContent() + } completion(ScheduleEntry( date: Date(), @@ -165,9 +242,17 @@ struct Provider: TimelineProvider { } func getTimeline(in context: Context, completion: @escaping (Timeline<ScheduleEntry>) -> ()) { - let content = prepareWidgetContent() + 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() + default: + content = prepareSmallMediumContent() + } + let entry = ScheduleEntry( date: currentTime, total: content.total, @@ -175,8 +260,14 @@ struct Provider: TimelineProvider { completed: content.completed ) - - let nextRefreshTime = calculateNextRefreshTime(currentTime: currentTime, classes: content.classes) + // Calculate next refresh time based on widget size + let nextRefreshTime: Date + switch context.family { + case .systemLarge: + nextRefreshTime = calculateNextGroupRefreshTime(currentTime: currentTime, classes: content.classes) + default: + nextRefreshTime = calculateNextRefreshTime(currentTime: currentTime, classes: content.classes) + } let timeline = Timeline(entries: [entry], policy: .after(nextRefreshTime)) completion(timeline) @@ -187,20 +278,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 @@ -208,12 +299,76 @@ struct Provider: TimelineProvider { } } - + // Use significant time if found, otherwise refresh in 15 minutes if let significantTime = nextSignificantTime { return significantTime } - return calendar.date(byAdding: .minute, value: 15, to: currentTime) ?? currentTime } + + private func calculateNextGroupRefreshTime(currentTime: Date, classes: [Classes]) -> Date { + let calendar = Calendar.current + 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..<currentGroupEnd { + if i < sortedClasses.count { + let classItem = sortedClasses[i] + let (_, endTime) = parseClassTime(classItem.time) + + if let end = endTime, end > 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 + } + } + } + } + } + + // 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() { + let status = getClassStatus(classItem, at: currentTime) + if status == .current || status == .upcoming { + pivotIndex = index + break + } + } + + return pivotIndex / groupSize + } + + private func isLastClassInGroup(index: Int, groupSize: Int, totalClasses: Int) -> Bool { + let groupIndex = index / groupSize + let groupStart = groupIndex * groupSize + let groupEnd = min(groupStart + groupSize, totalClasses) + + return index == groupEnd - 1 + } } diff --git a/VITTY/VittyWidget/Views/LargeWidget.swift b/VITTY/VittyWidget/Views/LargeWidget.swift index 68fa3ed..a4ada53 100644 --- a/VITTY/VittyWidget/Views/LargeWidget.swift +++ b/VITTY/VittyWidget/Views/LargeWidget.swift @@ -75,7 +75,7 @@ struct ScheduleLargeWidgetView: View { HStack(alignment: .top) { Spacer().frame(width: 2) VStack(alignment: .leading, spacing: 15) { - + Spacer().frame(height: 5) WidgetTitle(title: "Today's Schedule", fontSize: 18) Spacer().frame(height: 5) From 310df3f1aa4ed74dddf132798f7b33333ab07a7f Mon Sep 17 00:00:00 2001 From: rujin2003 <rujindevkota@gmail.com> Date: Mon, 14 Jul 2025 13:47:05 +0530 Subject: [PATCH 10/10] feat: latest merge fixes --- .../Connect/View/Circles/View/Circles.swift | 23 ------------------- .../VITTY/TimeTable/Views/TimeTableView.swift | 2 -- 2 files changed, 25 deletions(-) diff --git a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift index 364f149..86231d6 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift @@ -15,10 +15,6 @@ struct CirclesView: View { @Environment(AuthViewModel.self) private var authViewModel - @EnvironmentObject private var navigationCoordinator: NavigationCoordinator - - - @EnvironmentObject private var navigationCoordinator: NavigationCoordinator var body: some View { @@ -61,7 +57,6 @@ struct CirclesView: View { VStack(spacing: 10) { ForEach(filteredCircles, id: \.circleID) { circle in - NavigationLink(destination: InsideCircle(circleName: circle.circleName, circle_id: circle.circleID, circle_join_code: circle.circleJoinCode, circle_role: circle.circleRole)) { NavigationLink(destination: InsideCircle(circleName: circle.circleName, circle_id: circle.circleID, circle_join_code: circle.circleJoinCode, circle_role: circle.circleRole)) { CirclesRow(circle: circle) } @@ -90,24 +85,6 @@ struct CirclesView: View { ) } - .onAppear { - - if let pendingInvite = navigationCoordinator.pendingCircleInvite { - - print("CirclesView appeared with pending invite: \(pendingInvite.code)") - - - } - } - .onReceive(NotificationCenter.default.publisher(for: Notification.Name("CircleJoinedSuccessfully"))) { _ in - - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) - } - .onAppear { if let pendingInvite = navigationCoordinator.pendingCircleInvite { diff --git a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift index b15ddd7..a091267 100644 --- a/VITTY/VITTY/TimeTable/Views/TimeTableView.swift +++ b/VITTY/VITTY/TimeTable/Views/TimeTableView.swift @@ -140,7 +140,6 @@ struct TimeTableView: View { Image(systemName: "calendar.badge.exclamationmark") .font(.system(size: 50)) .foregroundColor(.secondary) - .foregroundColor(.secondary) Text("No classes today!") .font(Font.custom("Poppins-Bold", size: 24)) @@ -153,7 +152,6 @@ struct TimeTableView: View { } Spacer() - } else { ScrollView { VStack(spacing: 12) {