diff --git a/VITTY/ContentView.swift b/VITTY/ContentView.swift index 498ed34..55cbd4a 100644 --- a/VITTY/ContentView.swift +++ b/VITTY/ContentView.swift @@ -5,9 +5,8 @@ // Created by Ananya George on 11/7/21. // - - import SwiftUI +import StoreKit struct ContentView: View { @State private var communityPageViewModel = CommunityPageViewModel() @@ -15,20 +14,21 @@ struct ContentView: View { @State private var friendRequestViewModel = FriendRequestViewModel() @State private var authViewModel = AuthViewModel() @State private var requestViewModel = RequestsViewModel() - @State private var academicsViewModel = AcademicsViewModel() var body: some View { Group { - if authViewModel.loggedInBackendUser != nil { HomeView() + .onAppear { + + ReviewManager.shared.trackAppUsage() + ReviewManager.shared.requestReviewIfAppropriate() + } } - else if authViewModel.loggedInFirebaseUser != nil { InstructionView() } - else { LoginView() } @@ -38,8 +38,123 @@ struct ContentView: View { .environment(suggestedFriendsViewModel) .environment(friendRequestViewModel) .environment(academicsViewModel) - .environment(requestViewModel) + .environment(requestViewModel).alert("Update Available", isPresented: .constant(UpdateManager.shared.showUpdateAlert)) { + Button("Update Now") { + UpdateManager.shared.openAppStore() + UpdateManager.shared.dismissUpdateAlert() + } + + if let updateInfo = UpdateManager.shared.updateInfo, !updateInfo.isForced { + Button("Skip This Version") { + UpdateManager.shared.skipThisVersion() + } + + Button("Later") { + UpdateManager.shared.dismissUpdateAlert() + } + } + } message: { + if let updateInfo = UpdateManager.shared.updateInfo { + Text("Version \(updateInfo.latestVersion) is available.\n\n\(updateInfo.releaseNotes)") + } + } + } +} + +// MARK: - Review Manager +class ReviewManager: ObservableObject { + static let shared = ReviewManager() + + private let reviewRequestKey = "LastReviewRequestDate" + private let hasReviewedKey = "HasUserReviewed" + private let appUsageCountKey = "AppUsageCount" + private let firstLaunchDateKey = "FirstLaunchDate" + + // Configuration + private let minimumUsageCount = 10 // Minimum number of app uses before review + private let minimumDaysOfUsage = 7 // Minimum days since first launch + private let monthsInterval: TimeInterval = 30 * 24 * 60 * 60 // 30 days between requests + + private init() { + + if UserDefaults.standard.object(forKey: firstLaunchDateKey) == nil { + UserDefaults.standard.set(Date(), forKey: firstLaunchDateKey) + } + } + + func trackAppUsage() { + let currentCount = UserDefaults.standard.integer(forKey: appUsageCountKey) + UserDefaults.standard.set(currentCount + 1, forKey: appUsageCountKey) + } + + func requestReviewIfAppropriate() { + + if UserDefaults.standard.bool(forKey: hasReviewedKey) { + return + } + + + guard meetsUsageRequirements() else { + return + } + + + guard hasEnoughTimePassed() else { + return + } + + DispatchQueue.main.async { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + SKStoreReviewController.requestReview(in: windowScene) + } + } + + + UserDefaults.standard.set(Date(), forKey: reviewRequestKey) + } + + private func meetsUsageRequirements() -> Bool { + let usageCount = UserDefaults.standard.integer(forKey: appUsageCountKey) + + guard let firstLaunchDate = UserDefaults.standard.object(forKey: firstLaunchDateKey) as? Date else { + return false + } + let daysSinceFirstLaunch = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 + + return usageCount >= minimumUsageCount && daysSinceFirstLaunch >= minimumDaysOfUsage + } + + private func hasEnoughTimePassed() -> Bool { + guard let lastRequestDate = UserDefaults.standard.object(forKey: reviewRequestKey) as? Date else { + return true + } + + let now = Date() + return now.timeIntervalSince(lastRequestDate) >= monthsInterval + } + + func markAsReviewed() { + UserDefaults.standard.set(true, forKey: hasReviewedKey) + } + + func resetReviewStatus() { + UserDefaults.standard.removeObject(forKey: hasReviewedKey) + UserDefaults.standard.removeObject(forKey: reviewRequestKey) + UserDefaults.standard.removeObject(forKey: appUsageCountKey) + UserDefaults.standard.removeObject(forKey: firstLaunchDateKey) + } + + // MARK: - Debug/Testing Methods + func getCurrentUsageCount() -> Int { + return UserDefaults.standard.integer(forKey: appUsageCountKey) + } + + func getDaysSinceFirstLaunch() -> Int { + guard let firstLaunchDate = UserDefaults.standard.object(forKey: firstLaunchDateKey) as? Date else { + return 0 + } + return Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0 } } diff --git a/VITTY/VITTY.xcodeproj/project.pbxproj b/VITTY/VITTY.xcodeproj/project.pbxproj index 3722e0c..0fb20d6 100644 --- a/VITTY/VITTY.xcodeproj/project.pbxproj +++ b/VITTY/VITTY.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 4B7DA5F52D7237BE007354A3 /* CreateGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DA5F42D7237B8007354A3 /* CreateGroup.swift */; }; 4B80972B2E2947A300FF2F63 /* FriendsTimetableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B80972A2E29479800FF2F63 /* FriendsTimetableView.swift */; }; 4B80972D2E2ABD8500FF2F63 /* CircleTimetable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B80972C2E2ABD8100FF2F63 /* CircleTimetable.swift */; }; + 4B8097552E2B7B6300FF2F63 /* UpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B8097542E2B7B5F00FF2F63 /* UpdateManager.swift */; }; 4B8B32CA2D6D75F4004F01BA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4B8B32C92D6D75F4004F01BA /* WidgetKit.framework */; }; 4B8B32CB2D6D75F4004F01BA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3105871C27A3ECBB00C2FC41 /* SwiftUI.framework */; }; 4B8B32DC2D6D75F6004F01BA /* VittyWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4B8B32C82D6D75F4004F01BA /* VittyWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -226,6 +227,7 @@ 4B7DA5F42D7237B8007354A3 /* CreateGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroup.swift; sourceTree = ""; }; 4B80972A2E29479800FF2F63 /* FriendsTimetableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsTimetableView.swift; sourceTree = ""; }; 4B80972C2E2ABD8100FF2F63 /* CircleTimetable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleTimetable.swift; sourceTree = ""; }; + 4B8097542E2B7B5F00FF2F63 /* UpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateManager.swift; sourceTree = ""; }; 4B8B32C82D6D75F4004F01BA /* VittyWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VittyWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 4B8B32C92D6D75F4004F01BA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 4B8B33742D7029A3004F01BA /* SideBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideBar.swift; sourceTree = ""; }; @@ -471,6 +473,7 @@ 314A40A127383C0A0058082F /* Utilities */ = { isa = PBXGroup; children = ( + 4B8097542E2B7B5F00FF2F63 /* UpdateManager.swift */, 31128D0A2773003C0084C9EA /* Constants */, 31128D052772FA030084C9EA /* Extensions */, 31128CF12772F55D0084C9EA /* Fonts */, @@ -1211,6 +1214,7 @@ 4BF03C992D7819E30098C803 /* Notes.swift in Sources */, 52DBBE882B47B6B30014C57A /* FriendCard.swift in Sources */, 4B47CD7B2D7DCB8B00A46FEF /* CreateReminder.swift in Sources */, + 4B8097552E2B7B6300FF2F63 /* UpdateManager.swift in Sources */, 317715DE279F1431009A532E /* IndexedCollection.swift in Sources */, 4B7DA5E72D71AC54007354A3 /* CirclesRow.swift in Sources */, 4B7DA5E52D70B2CA007354A3 /* Circles.swift in Sources */, diff --git a/VITTY/VITTY/Connect/FriendRequest/Views/Components/FriendReqCard.swift b/VITTY/VITTY/Connect/FriendRequest/Views/Components/FriendReqCard.swift index 45fba68..908abf5 100644 --- a/VITTY/VITTY/Connect/FriendRequest/Views/Components/FriendReqCard.swift +++ b/VITTY/VITTY/Connect/FriendRequest/Views/Components/FriendReqCard.swift @@ -38,7 +38,7 @@ struct FriendReqCard: View { Task { let url = URL( string: - "\(APIConstants.base_url)/api/v2/requests/\(friend.username)/accept/" + "\(APIConstants.base_url)requests/\(friend.username)/accept/" )! var request = URLRequest(url: url) @@ -50,7 +50,7 @@ struct FriendReqCard: View { do { let (_, _) = try await URLSession.shared.data(for: request) friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)/api/v2/requests/")!, + from: URL(string: "\(APIConstants.base_url)requests/")!, authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: false ) @@ -68,7 +68,7 @@ struct FriendReqCard: View { Task { let url = URL( string: - "\(APIConstants.base_url)/api/v2/requests/\(friend.username)/decline/" + "\(APIConstants.base_url)requests/\(friend.username)/decline/" )! var request = URLRequest(url: url) @@ -80,7 +80,7 @@ struct FriendReqCard: View { do { let (_, _) = try await URLSession.shared.data(for: request) friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)/api/v2/requests/")!, + from: URL(string: "\(APIConstants.base_url)requests/")!, authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: false ) diff --git a/VITTY/VITTY/Connect/FriendRequest/Views/FriendRequestView.swift b/VITTY/VITTY/Connect/FriendRequest/Views/FriendRequestView.swift index d7dc865..1b33578 100644 --- a/VITTY/VITTY/Connect/FriendRequest/Views/FriendRequestView.swift +++ b/VITTY/VITTY/Connect/FriendRequest/Views/FriendRequestView.swift @@ -32,7 +32,7 @@ struct FriendRequestView: View { .scrollContentBackground(.hidden) .refreshable { friendRequestViewModel.fetchFriendRequests( - from: URL(string: "\(APIConstants.base_url)/api/v2/requests/")!, + from: URL(string: "\(APIConstants.base_url)requests/")!, authToken: authViewModel.loggedInBackendUser?.token ?? "", loading: false ) diff --git a/VITTY/VITTY/Connect/Search/Views/SearchView.swift b/VITTY/VITTY/Connect/Search/Views/SearchView.swift index 0e3279b..a3fdf40 100644 --- a/VITTY/VITTY/Connect/Search/Views/SearchView.swift +++ b/VITTY/VITTY/Connect/Search/Views/SearchView.swift @@ -82,48 +82,54 @@ struct SearchView: View { Spacer() } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if !hasSearched { - VStack(spacing: 20) { + + HStack { Spacer() - - Image(systemName: "magnifyingglass.circle") - .font(.system(size: 60)) - .foregroundColor(Color("Accent")) - - Text("Search for Friends") - .font(Font.custom("Poppins-SemiBold", size: 20)) - .foregroundColor(Color.white) - - Text("Enter a username or name to find friends on VITTY") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color.white.opacity(0.8)) - .padding(.horizontal, 40) - + VStack(spacing: 20) { + Image(systemName: "magnifyingglass.circle") + .font(.system(size: 60)) + .foregroundColor(Color("Accent")) + + Text("Search for Friends") + .font(Font.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(Color.white) + + Text("Enter a username or name to find friends on VITTY") + .multilineTextAlignment(.center) + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color.white.opacity(0.8)) + .padding(.horizontal, 40) + } Spacer() } + .frame(maxHeight: .infinity) + } else if searchedFriends.isEmpty && !searchText.isEmpty { - VStack(spacing: 20) { + + HStack { Spacer() - - Image(systemName: "person.crop.circle.badge.questionmark") - .font(.system(size: 60)) - .foregroundColor(Color("Accent")) - - Text("No Results Found") - .font(Font.custom("Poppins-SemiBold", size: 20)) - .foregroundColor(Color.white) - - Text("No users found for '\(searchText)'. Try a different search term.") - .multilineTextAlignment(.center) - .font(Font.custom("Poppins-Regular", size: 14)) - .foregroundColor(Color.white.opacity(0.8)) - .padding(.horizontal, 40) - + VStack(spacing: 20) { + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 60)) + .foregroundColor(Color("Accent")) + + Text("No Results Found") + .font(Font.custom("Poppins-SemiBold", size: 20)) + .foregroundColor(Color.white) + + Text("No users found for '\(searchText)'. Try a different search term.") + .multilineTextAlignment(.center) + .font(Font.custom("Poppins-Regular", size: 14)) + .foregroundColor(Color.white.opacity(0.8)) + .padding(.horizontal, 40) + } Spacer() } + .frame(maxHeight: .infinity) + } else { List($searchedFriends, id: \.username) { searchfriend in AddFriendCardSearch(friend: searchfriend , search: searchText) @@ -222,7 +228,7 @@ struct SearchView: View { cancelSearch() - // Reset all states + searchText = "" searchedFriends = [] hasSearched = false @@ -334,7 +340,7 @@ struct SearchView: View { } } - // Update the cancelSearch function to work with Alamofire + func cancelSearch() { currentSearchTask?.cancel() currentSearchTask = nil diff --git a/VITTY/VITTY/Connect/SuggestedFriends/Views/SuggestedFriendsView.swift b/VITTY/VITTY/Connect/SuggestedFriends/Views/SuggestedFriendsView.swift index 566e022..6bcccce 100644 --- a/VITTY/VITTY/Connect/SuggestedFriends/Views/SuggestedFriendsView.swift +++ b/VITTY/VITTY/Connect/SuggestedFriends/Views/SuggestedFriendsView.swift @@ -34,7 +34,7 @@ struct SuggestedFriendsView: View { .scrollContentBackground(.hidden) .refreshable { suggestedFriendsViewModel.fetchData( - from: "\(APIConstants.base_url)/api/v2/users/suggested/", + from: "\(APIConstants.base_url)users/suggested/", token: authViewModel.loggedInBackendUser?.token ?? "", loading: false ) diff --git a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift index 61567ce..c597a15 100644 --- a/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift +++ b/VITTY/VITTY/Connect/View/Circles/Components/CreateGroup.swift @@ -36,7 +36,7 @@ struct CreateGroup: View { .frame(width: 80, height: 5) .padding(.top, 10) - Text("Create Group") + Text("Create Circle") .font(.system(size: 23, weight: .semibold)) .foregroundColor(.white) @@ -72,11 +72,11 @@ struct CreateGroup: View { VStack(alignment: .leading, spacing: 10) { - Text("Enter group name") + Text("Enter circle name") .font(.system(size: 18, weight: .bold)) .foregroundColor(Color("Accent")) - TextField("Group Name", text: $groupName) + TextField("Circle Name", text: $groupName) .padding() .background(Color.black.opacity(0.3)) .cornerRadius(8) @@ -93,7 +93,7 @@ struct CreateGroup: View { } - if groupName.count > 20 { + if groupName.count > 50 { groupName = String(groupName.prefix(20)) } } @@ -101,7 +101,7 @@ struct CreateGroup: View { .textInputAutocapitalization(.never) - Text("No spaces allowed • Max 20 characters") + Text("No spaces allowed • Max 50 characters") .font(.system(size: 12)) .foregroundColor(.gray) .padding(.leading, 5) diff --git a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift index 09bbc11..692ac08 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/CircleRequests.swift @@ -103,7 +103,7 @@ struct CircleRequestsView: View { Spacer() - Text("Group Requests") + Text("Circle Requests") .font(.custom("Poppins-SemiBold", size: 20)) .foregroundColor(.white) diff --git a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift index 86231d6..77cf93d 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/Circles.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/Circles.swift @@ -7,14 +7,12 @@ 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 { @@ -56,7 +54,6 @@ struct CirclesView: View { ScrollView { 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)) { CirclesRow(circle: circle) } @@ -70,30 +67,34 @@ struct CirclesView: View { } } .refreshable { - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) + fetchCircleData() } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("CircleJoinedSuccessfully"))) { _ in - - communityPageViewModel.fetchCircleData( - from: "\(APIConstants.base_url)circles", - token: authViewModel.loggedInBackendUser?.token ?? "", - loading: true - ) + fetchCircleData() } - .onAppear { + + fetchCircleData() + if let pendingInvite = navigationCoordinator.pendingCircleInvite { - print("CirclesView appeared with pending invite: \(pendingInvite.code)") - - } } } } + + // MARK: - Helper Methods + private func fetchCircleData() { + guard let token = authViewModel.loggedInBackendUser?.token else { + print("No authentication token available") + return + } + + communityPageViewModel.fetchCircleData( + from: "\(APIConstants.base_urlv3)circles", + token: token, + loading: true + ) + } } diff --git a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift index 82f5b2c..2aa8f51 100644 --- a/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift +++ b/VITTY/VITTY/Connect/View/Circles/View/InsideCircle.swift @@ -506,7 +506,7 @@ struct InsideCircle: View { }) .onAppear { communityPageViewModel.fetchCircleMemberData( - from: "\(APIConstants.base_url)circles/\(circle_id)", + from: "\(APIConstants.base_urlv3)circles/\(circle_id)", token: authViewModel.loggedInBackendUser?.token ?? "", loading: true ) @@ -517,13 +517,13 @@ struct InsideCircle: View { LeaveCircleAlert(circleName: "\(circleName)", onCancel: { showLeaveAlert = false }, onLeave: { - let url = "\(APIConstants.base_url)circles/leave/\(circle_id)" + let url = "\(APIConstants.base_urlv3)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) + communityPageViewModel.fetchCircleData(from:"\(APIConstants.base_urlv3)circles" , token: token) showLeaveAlert = false presentationMode.wrappedValue.dismiss() } @@ -534,7 +534,7 @@ struct InsideCircle: View { DeleteCircleAlert(circleName: "\(circleName)", onCancel: { showDeleteAlert = false }, onDelete: { - let url = "\(APIConstants.base_url)circles/\(circle_id)" + let url = "\(APIConstants.base_urlv3)circles/\(circle_id)" let token = authViewModel.loggedInBackendUser?.token ?? "" communityPageViewModel.deleteCircle(from: url, token: token) diff --git a/VITTY/VITTY/Connect/View/ConnectPage.swift b/VITTY/VITTY/Connect/View/ConnectPage.swift index 4e23323..ee29412 100644 --- a/VITTY/VITTY/Connect/View/ConnectPage.swift +++ b/VITTY/VITTY/Connect/View/ConnectPage.swift @@ -233,7 +233,7 @@ struct ConnectCircleMenuView: View { Image("joingroup") .resizable() .frame(width: 24, height: 24) - Text("Join Group") + Text("Join Circle") .font(.custom("Poppins-Regular", size: 16)) .foregroundColor(.white) Spacer() @@ -252,7 +252,7 @@ struct ConnectCircleMenuView: View { HStack { Image(systemName: "person.badge.plus") .foregroundColor(.white) - Text("Group Requests") + Text("Circle Requests") .font(.custom("Poppins-Regular", size: 16)) .foregroundColor(.white) Spacer() @@ -301,7 +301,7 @@ struct AddCircleOptionsView: View { Image("joingroup") .resizable() .frame(width: 55, height: 55) - Text("Join Group") + Text("Join Circle") .font(.system(size: 15)) .foregroundStyle(Color.white) } diff --git a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift index 7970104..83498d9 100644 --- a/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift +++ b/VITTY/VITTY/Connect/ViewModel/CommunityPageViewModel.swift @@ -114,7 +114,7 @@ class CommunityPageViewModel { for circle in circles { dispatchGroup.enter() - let url = "\(APIConstants.base_url)circles/\(circle.circleID)" + let url = "\(APIConstants.base_urlv3)circles/\(circle.circleID)" AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() @@ -149,7 +149,7 @@ class CommunityPageViewModel { self.errorMemberTimetable = false - let url = "\(APIConstants.base_url)circles/\(circleId)/\(username)" + let url = "\(APIConstants.base_urlv3)circles/\(circleId)/\(username)" print("Fetching member timetable from: \(url)") @@ -191,7 +191,7 @@ class CommunityPageViewModel { self.errorCircleRequests = false - let url = "\(APIConstants.base_url)circles/requests/received" + let url = "\(APIConstants.base_urlv3)circles/requests/received" AF.request(url, method: .get, headers: ["Authorization": "Token \(token)"]) .validate() @@ -230,7 +230,7 @@ class CommunityPageViewModel { func acceptCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { self.loadingRequestAction = true - let url = "\(APIConstants.base_url)circles/acceptRequest/\(circleId)" + let url = "\(APIConstants.base_urlv3)circles/acceptRequest/\(circleId)" logger.info("Attempting to accept circle request with URL: \(url)") @@ -247,7 +247,7 @@ class CommunityPageViewModel { case .success(let data): self.logger.info("Successfully accepted circle request for circle: \(circleId)") - // Log the response for debugging + if let responseString = String(data: data, encoding: .utf8) { self.logger.info("Response: \(responseString)") } @@ -257,7 +257,7 @@ class CommunityPageViewModel { self.fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: token, loading: false ) @@ -285,7 +285,7 @@ class CommunityPageViewModel { func declineCircleRequest(circleId: String, token: String, completion: @escaping (Bool) -> Void) { self.loadingRequestAction = true - let url = "\(APIConstants.base_url)circles/declineRequest/\(circleId)" + let url = "\(APIConstants.base_urlv3)circles/declineRequest/\(circleId)" AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() @@ -420,7 +420,7 @@ class CommunityPageViewModel { self.fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: token, loading: false ) @@ -464,7 +464,7 @@ class CommunityPageViewModel { return } - let url = "\(APIConstants.base_url)circles/create/\(encodedName)" + let url = "\(APIConstants.base_urlv3)circles/create/\(encodedName)" AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) .validate() @@ -477,7 +477,7 @@ class CommunityPageViewModel { // Now fetch the updated circles data and wait for completion self.fetchCircleDataWithCompletion( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: token, circleName: name, completion: completion @@ -510,7 +510,7 @@ class CommunityPageViewModel { self.errorCircle = false print("Successfully fetched circles after creation: \(data.data)") - // Fetch member data for all circles after successfully fetching circles + self.fetchAllCircleMemberData(token: token) if let createdCircle = self.circles.first(where: { $0.circleName == circleName }) { @@ -530,9 +530,11 @@ class CommunityPageViewModel { } } + // MARK: - Updated Circle Invitations with New Endpoint + func sendCircleInvitation(circleId: String, username: String, token: String, completion: @escaping (Bool) -> Void) { - let url = "\(APIConstants.base_url)circles/sendRequest/\(circleId)/\(username)" + let url = "\(APIConstants.base_urlv3)circles/sendRequest/\(circleId)/\(username)" print("this is the endpoint \(url)") AF.request(url, method: .post, headers: ["Authorization": "Token \(token)"]) @@ -552,35 +554,102 @@ class CommunityPageViewModel { } } + + + struct SendInvitationsRequest: Codable { + let usernames: [String] + } + + struct SendInvitationsResponse: Codable { + let data: [InvitationResult]? + let detail: String? + let message: String? + } + struct InvitationResult: Codable { + let request_status: String + let username: String + } + func sendMultipleInvitations(circleId: String, usernames: [String], token: String, completion: @escaping ([String: Bool]) -> Void) { - let dispatchGroup = DispatchGroup() - var results: [String: Bool] = [:] - - for username in usernames { - dispatchGroup.enter() - - sendCircleInvitation(circleId: circleId, username: username, token: token) { success in - results[username] = success - dispatchGroup.leave() - } + guard !usernames.isEmpty else { + completion([:]) + return } - dispatchGroup.notify(queue: .main) { - completion(results) + let url = "\(APIConstants.base_urlv3)circles/sendRequest/\(circleId)" + let requestBody = SendInvitationsRequest(usernames: usernames) + + print("Sending multiple invitations to endpoint: \(url)") + print("Usernames: \(usernames)") + + AF.request( + url, + method: .post, + parameters: requestBody, + encoder: JSONParameterEncoder.default, + headers: ["Authorization": "Token \(token)", "Content-Type": "application/json"] + ) + .validate() + .responseData { response in + DispatchQueue.main.async { + var results: [String: Bool] = [:] + + + for username in usernames { + results[username] = false + } + + switch response.result { + case .success(let data): + self.logger.info("Multiple invitations response received") + + + do { + let decodedResponse = try JSONDecoder().decode(SendInvitationsResponse.self, from: data) + + if let invitationResults = decodedResponse.data { + for result in invitationResults { + + results[result.username] = (result.request_status == "added") + self.logger.info("User \(result.username): \(result.request_status)") + } + } + + } catch { + self.logger.error("Error decoding multiple invitations response: \(error)") + + + if let responseString = String(data: data, encoding: .utf8) { + self.logger.info("Raw response: \(responseString)") + } + + + + return + } + + completion(results) + + case .failure(let error): + self.logger.error("Error sending multiple invitations: \(error)") + + + } + } } } - + // MARK: - Refresh Methods func refreshAllData(token: String, username: String) { fetchFriendsData( - from: "\(APIConstants.base_url)friends/\(username)/", + from: "\(APIConstants.base_urlv3)friends/\(username)/", token: token, loading: false ) fetchCircleData( - from: "\(APIConstants.base_url)circles", + from: "\(APIConstants.base_urlv3)circles", token: token, loading: false ) @@ -601,7 +670,7 @@ class CommunityPageViewModel { } func generateJoinCode(circleId: String, token: String, completion: @escaping (Result) -> Void) { - let url = "\(APIConstants.base_url)circles/\(circleId)/generateJoinCode" + let url = "\(APIConstants.base_urlv3)circles/\(circleId)/generateJoinCode" print("Generating join code for circle: \(circleId)") print("Request URL: \(url)") @@ -631,5 +700,6 @@ class CommunityPageViewModel { } } } + } } -} + diff --git a/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift b/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift index 203041c..a3de98f 100644 --- a/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift +++ b/VITTY/VITTY/EmptyClassroom/Service/EmptyClassAPIService.swift @@ -13,7 +13,7 @@ class EmptyClassRoomAPIService { slot: String, authToken: String ) async throws -> [String] { - let url = URL(string: "\(APIConstants.base_url)users/emptyClassRooms?slot=\(slot)")! + let url = URL(string: "\(APIConstants.base_urlv3)users/emptyClassRooms?slot=\(slot)")! var request = URLRequest(url: url) request.httpMethod = "GET" print(authToken) diff --git a/VITTY/VITTY/Home/View/HomeView.swift b/VITTY/VITTY/Home/View/HomeView.swift index 1ee84bd..44b2b01 100644 --- a/VITTY/VITTY/Home/View/HomeView.swift +++ b/VITTY/VITTY/Home/View/HomeView.swift @@ -21,7 +21,7 @@ class CampusUpdateService { private init() {} func updateCampus(campus: String, token: String) async throws { - guard let url = URL(string: "\(APIConstants.base_url)users/campus") else { + guard let url = URL(string: "\(APIConstants.base_urlv3)users/campus") else { throw URLError(.badURL) } diff --git a/VITTY/VITTY/Info.plist b/VITTY/VITTY/Info.plist index d1aca8e..827548b 100644 --- a/VITTY/VITTY/Info.plist +++ b/VITTY/VITTY/Info.plist @@ -57,6 +57,10 @@ fetch remote-notification + NSCameraUsageDescription + Allow camera access to enable capturing notes and information + NSPhotoLibraryUsageDescription + Allow photo library access to save or attach notes, files . UIViewControllerBasedStatusBarAppearance diff --git a/VITTY/VITTY/UserProfileSideBar/SideBar.swift b/VITTY/VITTY/UserProfileSideBar/SideBar.swift index 886c3b7..057dc4f 100644 --- a/VITTY/VITTY/UserProfileSideBar/SideBar.swift +++ b/VITTY/VITTY/UserProfileSideBar/SideBar.swift @@ -151,7 +151,7 @@ struct UserProfileSidebar: View { let endpoint = enabled ? "ghost" : "alive" - let urlString = "\(APIConstants.base_url)friends/\(endpoint)/\(username)" + let urlString = "\(APIConstants.base_urlv3)friends/\(endpoint)/\(username)" guard let url = URL(string: urlString) else { isUpdatingGhostMode = false diff --git a/VITTY/VITTY/Utilities/Constants/APIConstants.swift b/VITTY/VITTY/Utilities/Constants/APIConstants.swift index 6fd55c8..74bf1c7 100644 --- a/VITTY/VITTY/Utilities/Constants/APIConstants.swift +++ b/VITTY/VITTY/Utilities/Constants/APIConstants.swift @@ -10,7 +10,15 @@ import Foundation struct APIConstants { + + + static let base_url = "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v2/" + + static let base_urlv3 = "https://visiting-eba-vitty-d61856bb.koyeb.app/api/v3/" + + + static let createCircle = "circles/create/" static let sendRequest = "circles/sendRequest/" static let acceptRequest = "circles/acceptRequest/" diff --git a/VITTY/VITTY/Utilities/UpdateManager.swift b/VITTY/VITTY/Utilities/UpdateManager.swift new file mode 100644 index 0000000..3e0e091 --- /dev/null +++ b/VITTY/VITTY/Utilities/UpdateManager.swift @@ -0,0 +1,264 @@ +// +// UpdateManager.swift +// VITTY +// +// In-App Update Check Manager +// + +import Foundation +import StoreKit +import SwiftUI + +// MARK: - Update Manager +class UpdateManager: ObservableObject { + static let shared = UpdateManager() + + @Published var isUpdateAvailable = false + @Published var updateInfo: AppUpdateInfo? + @Published var showUpdateAlert = false + + private let appStoreURL = "https://apps.apple.com/app/id1611750267" + + private let lastUpdateCheckKey = "LastUpdateCheckDate" + private let skipVersionKey = "SkippedVersion" + + // Configuration + private let checkInterval: TimeInterval = 24 * 60 * 60 + private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + + struct AppUpdateInfo { + let latestVersion: String + let releaseNotes: String + let isForced: Bool + let downloadURL: String + } + + private init() {} + + // MARK: - Public Methods + + func checkForUpdates(forced: Bool = false) { + + if !forced && !shouldCheckForUpdates() { + return + } + + guard let appID = getAppID() else { + print("App ID not found") + return + } + + fetchAppStoreVersion(appID: appID) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success(let updateInfo): + self?.handleUpdateInfo(updateInfo) + case .failure(let error): + print("Update check failed: \(error)") + } + } + } + + // Update last check date + UserDefaults.standard.set(Date(), forKey: lastUpdateCheckKey) + } + + func presentUpdateAlert() { + showUpdateAlert = true + } + + func skipThisVersion() { + if let version = updateInfo?.latestVersion { + UserDefaults.standard.set(version, forKey: skipVersionKey) + } + dismissUpdateAlert() + } + + func dismissUpdateAlert() { + showUpdateAlert = false + } + + func openAppStore() { + guard let url = URL(string: appStoreURL) else { return } + UIApplication.shared.open(url) + } + + // MARK: - Private Methods + + private func shouldCheckForUpdates() -> Bool { + guard let lastCheck = UserDefaults.standard.object(forKey: lastUpdateCheckKey) as? Date else { + return true + } + + return Date().timeIntervalSince(lastCheck) >= checkInterval + } + + private func getAppID() -> String? { + + return "1611750267" + } + + private func fetchAppStoreVersion(appID: String, completion: @escaping (Result) -> Void) { + let urlString = "https://itunes.apple.com/lookup?id=\(appID)" + guard let url = URL(string: urlString) else { + completion(.failure(UpdateError.invalidURL)) + return + } + + URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data else { + completion(.failure(UpdateError.noData)) + return + } + + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + let results = json?["results"] as? [[String: Any]] + + guard let appInfo = results?.first else { + completion(.failure(UpdateError.noAppInfo)) + return + } + + let latestVersion = appInfo["version"] as? String ?? "1.0" + let releaseNotes = appInfo["releaseNotes"] as? String ?? "Bug fixes and improvements" + let downloadURL = appInfo["trackViewUrl"] as? String ?? self.appStoreURL + + let updateInfo = AppUpdateInfo( + latestVersion: latestVersion, + releaseNotes: releaseNotes, + isForced: self.isCriticalUpdate(latestVersion), + downloadURL: downloadURL + ) + + completion(.success(updateInfo)) + + } catch { + completion(.failure(error)) + } + }.resume() + } + + private func handleUpdateInfo(_ updateInfo: AppUpdateInfo) { + self.updateInfo = updateInfo + + // Check if update is available and not skipped + if isNewerVersion(updateInfo.latestVersion, than: currentVersion) { + let skippedVersion = UserDefaults.standard.string(forKey: skipVersionKey) + + // Show alert if it's a forced update or user hasn't skipped this version + if updateInfo.isForced || skippedVersion != updateInfo.latestVersion { + isUpdateAvailable = true + presentUpdateAlert() + } + } + } + + private func isNewerVersion(_ version1: String, than version2: String) -> Bool { + return version1.compare(version2, options: .numeric) == .orderedDescending + } + + private func isCriticalUpdate(_ version: String) -> Bool { + // Define logic for critical updates + // For example, major version changes or security updates + let currentMajor = currentVersion.components(separatedBy: ".").first ?? "1" + let latestMajor = version.components(separatedBy: ".").first ?? "1" + + return currentMajor != latestMajor + } +} + +// MARK: - Update Error +enum UpdateError: Error, LocalizedError { + case invalidURL + case noData + case noAppInfo + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid App Store URL" + case .noData: + return "No data received from App Store" + case .noAppInfo: + return "App information not found" + } + } +} + +// MARK: - Update Alert View +struct UpdateAlertView: View { + @Environment(\.dismiss) private var dismiss + let updateInfo: UpdateManager.AppUpdateInfo + let onUpdate: () -> Void + let onSkip: (() -> Void)? + let onLater: () -> Void + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("Update Available") + .font(.title2) + .fontWeight(.bold) + + Text("Version \(updateInfo.latestVersion) is now available") + .font(.subheadline) + .foregroundColor(.secondary) + + if !updateInfo.releaseNotes.isEmpty { + ScrollView { + Text("What's New:") + .font(.headline) + .padding(.bottom, 5) + + Text(updateInfo.releaseNotes) + .font(.body) + .multilineTextAlignment(.leading) + } + .frame(maxHeight: 150) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + } + + VStack(spacing: 10) { + Button("Update Now") { + onUpdate() + dismiss() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + if !updateInfo.isForced { + HStack(spacing: 20) { + if let onSkip = onSkip { + Button("Skip This Version") { + onSkip() + dismiss() + } + .foregroundColor(.secondary) + } + + Button("Later") { + onLater() + dismiss() + } + .foregroundColor(.secondary) + } + .font(.subheadline) + } + } + } + .padding() + .frame(maxWidth: 350) + } +} + diff --git a/VITTY/VITTYApp.swift b/VITTY/VITTYApp.swift index d5ac2b3..84046c8 100644 --- a/VITTY/VITTYApp.swift +++ b/VITTY/VITTYApp.swift @@ -277,7 +277,7 @@ extension VITTYApp { return } - let urlString = "\(APIConstants.base_url)circles/join?code=\(invite.code)" + let urlString = "\(APIConstants.base_urlv3)circles/join?code=\(invite.code)" guard let url = URL(string: urlString) else { logger.error("Invalid URL: \(urlString)") showToast(message: "Error: Invalid URL", isError: true)