diff --git a/PennMobile/GSR-Booking/Controller/CalendarHelper.swift b/PennMobile/GSR-Booking/Controller/CalendarHelper.swift new file mode 100644 index 00000000..849ea100 --- /dev/null +++ b/PennMobile/GSR-Booking/Controller/CalendarHelper.swift @@ -0,0 +1,45 @@ +// +// CalendarHelper.swift +// PennMobile +// +// Created by Ximing Luo on 3/14/25. +// Copyright © 2025 PennLabs. All rights reserved. +// + +import EventKit +import SwiftUI + +struct CalendarHelper { + static func addToCalendar( + title: String, + location: String, + start: Date, + end: Date, + completion: @escaping (Bool) -> Void + ) { + let eventStore = EKEventStore() + eventStore.requestAccess(to: .event) { granted, error in + if granted, error == nil { + let event = EKEvent(eventStore: eventStore) + event.title = title + event.location = location + event.startDate = start + event.endDate = end + event.notes = "Created by Penn Mobile" + event.calendar = eventStore.defaultCalendarForNewEvents + + do { + try eventStore.save(event, span: .thisEvent) + print("Event added to calendar") + completion(true) + } catch { + print("Failed to save event: \(error)") + completion(false) + } + } else { + print("Calendar access not granted or error: \(String(describing: error))") + completion(false) + } + } + } +} diff --git a/PennMobile/GSR-Booking/Controller/GoogleCalendarLink.swift b/PennMobile/GSR-Booking/Controller/GoogleCalendarLink.swift new file mode 100644 index 00000000..95c4bd58 --- /dev/null +++ b/PennMobile/GSR-Booking/Controller/GoogleCalendarLink.swift @@ -0,0 +1,27 @@ +// +// GoogleCalendarLink.swift +// PennMobile +// +// Created by Ximing Luo on 3/14/25. +// Copyright © 2025 PennLabs. All rights reserved. +// + +import Foundation + +enum GoogleCalendarLink { + static func makeURL(title: String, location: String, start: Date, end: Date) -> URL? { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + dateFormatter.timeZone = TimeZone(abbreviation: "UTC") + + let startStr = dateFormatter.string(from: start) + let endStr = dateFormatter.string(from: end) + + let escapedTitle = title.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "Event" + let escapedLocation = location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + + let urlString = "https://calendar.google.com/calendar/render?action=TEMPLATE&text=\(escapedTitle)&location=\(escapedLocation)&dates=\(startStr)/\(endStr)" + + return URL(string: urlString) + } +} diff --git a/PennMobile/GSR-Booking/Model/DeepLinkManager.swift b/PennMobile/GSR-Booking/Model/DeepLinkManager.swift new file mode 100644 index 00000000..c629cdae --- /dev/null +++ b/PennMobile/GSR-Booking/Model/DeepLinkManager.swift @@ -0,0 +1,61 @@ +// +// DeepLinkManager.swift +// PennMobile +// +// Created by Ximing Luo on 3/2/25. +// Copyright © 2025 PennLabs. All rights reserved. +// + +import SwiftUI + +struct GSRShareModel: Codable { + let userName: String + let reservation: GSRReservation +} + +/// Observed by SwiftUI to detect new deep links. +class DeepLinkManager: ObservableObject { + @Published var lastResolvedLink: GSRShareModel? + + /// Attempt to parse a domain-based link like: + /// https://pennmobile.org/ios/gsr/share?data= + func handleOpenURL(_ url: URL) { + // 1) Check that it's https + correct host + correct path + guard url.scheme == "https", + url.host == "pennmobile.org", + url.path == "/ios/gsr/share" + else { + return + } + guard + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems, + let base64String = queryItems.first(where: { $0.name == "data" })?.value, + let jsonData = Data(base64Encoded: base64String) + else { + return + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + if let shareModel = try? decoder.decode(GSRShareModel.self, from: jsonData) { + lastResolvedLink = shareModel + } + } +} + +/// Encodes GSRShareModel into `https://pennmobile.org/ios/gsr/share?data=Base64` +extension GSRShareModel { + func encodedURL() -> URL? { + guard var components = URLComponents(string: "https://pennmobile.org/ios/gsr/share") else { + return nil + } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let jsonData = try? encoder.encode(self) else { return nil } + let base64String = jsonData.base64EncodedString() + components.queryItems = [ + URLQueryItem(name: "data", value: base64String) + ] + return components.url + } +} diff --git a/PennMobile/GSR-Booking/Views/GSRShareDetailView.swift b/PennMobile/GSR-Booking/Views/GSRShareDetailView.swift new file mode 100644 index 00000000..e08c8c14 --- /dev/null +++ b/PennMobile/GSR-Booking/Views/GSRShareDetailView.swift @@ -0,0 +1,216 @@ +// +// GSRShareDetailView.swift +// PennMobile +// +// Created by Ximing Luo on 3/2/25. +// Copyright © 2025 PennLabs. All rights reserved. +// + +import SwiftUI +import Kingfisher +import MapKit + +struct GSRShareDetailView: View { + let model: GSRShareModel + @State private var showCalendarAlert = false + + private var formatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + } + + private var dateFormatter: DateFormatter { + let df = DateFormatter() + df.dateStyle = .short + df.timeStyle = .none + return df + } + + private var timeFormatter: DateFormatter { + let tf = DateFormatter() + tf.timeStyle = .short + return tf + } + + struct Place: Identifiable { + let id = UUID() + let coordinate: CLLocationCoordinate2D + } + + @State private var region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1932), + span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005) + ) + + var body: some View { + let roomName = /\[Me\]\s*(.*)/ + let splitRoom = model.reservation.roomName.split(separator: ":").first ?? "" + let room = splitRoom.firstMatch(of: roomName)?.1 + let gsrLocation = model.reservation.gsr.name + + VStack(spacing: 0) { + ZStack(alignment: .bottomLeading) { + KFImage(URL(string: model.reservation.gsr.imageUrl)) + .resizable() + .allowsHitTesting(false) + + LinearGradient(gradient: Gradient(colors: [.black.opacity(0.6), .black.opacity(0.2), .clear, .black.opacity(0.3), .black]), startPoint: .init(x: 0.5, y: 0.2), endPoint: .init(x: 0.5, y: 1)) + + VStack (alignment: .leading) { + let room = model.reservation.roomName.split(separator: ":").first ?? "" + if let match = room.firstMatch(of: roomName) { + Text(match.1) + .foregroundColor(.white) + .font(.system(size: 40, weight: .bold)) + .minimumScaleFactor(0.2) + .lineLimit(1) + } + Text(model.reservation.gsr.name) + .foregroundColor(.white) + .font(.system(size: 25, weight: .bold)) + .minimumScaleFactor(0.2) + .lineLimit(1) + } + .padding() + } + .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 3.35/9) + + Spacer() + + VStack(spacing: 16) { + HStack(spacing: 8) { + let startStr = timeFormatter.string(from: model.reservation.start) + let endStr = timeFormatter.string(from: model.reservation.end) + let timeRange = "\(startStr) - \(endStr)" + + Text(timeRange) + .font(.system(size: 20, weight: .semibold)) + .padding(12) + .background(Color.gray.opacity(0.2)) + .cornerRadius(8) + + let dateStr = dateFormatter.string(from: model.reservation.start) + Text(dateStr) + .font(.system(size: 20, weight: .semibold)) + .padding(10) + .background(Color.gray.opacity(0.2)) + .cornerRadius(8) + } + if model.reservation.end < Date() { + Text("GSR Booking Expired") + .foregroundColor(.red) + .font(.system(size: 24, weight: .semibold)) + .padding(40) + } else { + Button { + CalendarHelper.addToCalendar( + title: "GSR Booking: \(gsrLocation + " " + (room ?? ""))", + location: gsrLocation, + start: model.reservation.start, + end: model.reservation.end + ){ success in + if success { + showCalendarAlert = true + } + } + } label: { + HStack { + Image(systemName: "calendar") + Text("Add to Calendar") + } + .font(.system(size: 18, weight: .semibold)) + .padding() + .foregroundColor(.white) + .background(Color.blue) + .cornerRadius(8) + } + .alert(isPresented: $showCalendarAlert) { + Alert( + title: Text("Success"), + message: Text("Event was added to your Calendar."), + dismissButton: .default(Text("OK")) + ) + } + Button { + if let url = GoogleCalendarLink.makeURL( + title: "GSR Booking: \(gsrLocation) \(room ?? "")", + location: gsrLocation, + start: model.reservation.start, + end: model.reservation.end + ) { + UIApplication.shared.open(url) + } + } label: { + HStack { + Image(systemName: "calendar") + Text("Google Calendar") + } + .font(.system(size: 18, weight: .semibold)) + .padding() + .foregroundColor(.black) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.black, lineWidth: 1) + ) + } + .background(Color.white) + .cornerRadius(8) + } + + Text("Booked by \(model.userName)") + .font(.title3) + .foregroundColor(.gray) + + Spacer() + + let locationName = gsrLocation + let coordinate = PennLocation.pennGSRLocation[locationName]?.coordinate + ?? CLLocationCoordinate2D(latitude: 39.9526, longitude: -75.1932) + + Map(coordinateRegion: $region, annotationItems: [Place(coordinate: coordinate)]) { place in + MapMarker(coordinate: place.coordinate, tint: .red) + } + .frame(height: 235) + .cornerRadius(12) + .onAppear { + region.center = coordinate + } + Spacer() + } + .padding() + } + .ignoresSafeArea() + } +} + + +struct GSRShareDetailView_Previews: PreviewProvider { + static var previews: some View { +// let urlString = "gsr://share?data=eyJ1c2VyTmFtZSI6IlhpbWluZyIsInJlc2VydmF0aW9uIjp7ImJvb2tpbmdJZCI6ImNzX3Bid1ZWYWNnIiwiZW5kIjoiMjAyNS0wMy0wMlQxNTozMDowMFoiLCJzdGFydCI6IjIwMjUtMDMtMDJUMTU6MDA6MDBaIiwiZ3NyIjp7ImxpZCI6IjI1ODciLCJpbWFnZVVybCI6Imh0dHBzOlwvXC9zMy51cy1lYXN0LTIuYW1hem9uYXdzLmNvbVwvbGFicy5hcGlcL2dzclwvbGlkLTI1ODctZ2lkLTQzNjguanBnIiwia2luZCI6IkxJQkNBTCIsImdpZCI6NDM2OCwibmFtZSI6IkxpcHBpbmNvdHQifSwicm9vbU5hbWUiOiJbTWVdIFJvb20gMjQ4OiBDbGFzcyBvZiAxOTU1IENvbnN1bHRhdGlvbiBSb29tIiwicm9vbUlkIjoxNjk5MH19" + let urlString = "gsr://share?data=eyJ1c2VyTmFtZSI6IlhpbWluZyIsInJlc2VydmF0aW9uIjp7InJvb21JZCI6NzE5Miwicm9vbU5hbWUiOiJbTWVdIEJvb3RoIDAxOiBTdHVkeSBCb290aCIsImdzciI6eyJnaWQiOjE4ODksImltYWdlVXJsIjoiaHR0cHM6XC9cL3MzLnVzLWVhc3QtMi5hbWF6b25hd3MuY29tXC9sYWJzLmFwaVwvZ3NyXC9saWQtMTA4Ni1naWQtMTg4OS5qcGciLCJsaWQiOiIxMDg2Iiwia2luZCI6IkxJQkNBTCIsIm5hbWUiOiJXZWlnbGUifSwic3RhcnQiOiIyMDI1LTAzLTE0VDE3OjAwOjAwWiIsImJvb2tpbmdJZCI6ImNzXzBYdnk4WEkyIiwiZW5kIjoiMjAyNS0wMy0xNFQxNzozMDowMFoifX0%3D" + let manager = DeepLinkManager() + var shareModel: GSRShareModel? + + if let encodedURL = URL(string: urlString) { + manager.handleOpenURL(encodedURL) + if let decoded = manager.lastResolvedLink { + shareModel = decoded + } else { + print("Failed to decode from URL.") + } + } else { + print("Failed to create URL from string.") + } + return Group { + if let shareModel = shareModel { + GSRShareDetailView(model: shareModel) + .previewLayout(.sizeThatFits) + } else { + Text("No share data found.") + } + } + } +} + diff --git a/PennMobile/PennMobile.entitlements b/PennMobile/PennMobile.entitlements index f2086a11..d77b588b 100755 --- a/PennMobile/PennMobile.entitlements +++ b/PennMobile/PennMobile.entitlements @@ -4,6 +4,11 @@ aps-environment development + com.apple.developer.associated-domains + + webcredentials:example.com + applinks:pennmobile.org/ios/gsr/share?data + com.apple.developer.usernotifications.time-sensitive com.apple.security.application-groups diff --git a/PennMobile/Setup + Navigation/PennMobile.swift b/PennMobile/Setup + Navigation/PennMobile.swift index dd94ea35..bbaddfc0 100644 --- a/PennMobile/Setup + Navigation/PennMobile.swift +++ b/PennMobile/Setup + Navigation/PennMobile.swift @@ -13,6 +13,7 @@ import LabsPlatformSwift @main struct PennMobile: App { @UIApplicationDelegateAdaptor var delegate: AppDelegate + @StateObject private var deepLinkManager = DeepLinkManager() @ObservedObject var authManager = AuthManager() @ObservedObject var homeViewModel = StandardHomeViewModel() @@ -53,9 +54,13 @@ struct PennMobile: App { .environmentObject(authManager) .environmentObject(homeViewModel) .environmentObject(BannerViewModel.shared) + .environmentObject(deepLinkManager) #if DEBUG .environmentObject(mockHomeViewModel) #endif + .onOpenURL { url in + deepLinkManager.handleOpenURL(url) + } .accentColor(Color("navigation")) .enableLabsPlatform(analyticsRoot: "pennmobile", clientId: InfoPlistEnvironment.labsOauthClientId, diff --git a/PennMobile/Setup + Navigation/RootView.swift b/PennMobile/Setup + Navigation/RootView.swift index 05b91c85..f87380ea 100644 --- a/PennMobile/Setup + Navigation/RootView.swift +++ b/PennMobile/Setup + Navigation/RootView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import Combine struct RootView: View { @EnvironmentObject var authManager: AuthManager @@ -15,7 +16,12 @@ struct RootView: View { @State var toastOffset: Double = 0.0 @StateObject var popupManager = PopupManager() @Environment(\.scenePhase) var scenePhase + @EnvironmentObject var deepLinkManager: DeepLinkManager + @State private var currentShare: GSRShareModel? + @State private var showShareDetail = false + @State private var cancellables = Set() + var isOnLogoutScreen: Bool { switch authManager.state { case .loggedOut: @@ -54,6 +60,24 @@ struct RootView: View { fatalError("Unhandled auth manager state: \(authManager.state)") } } + .onAppear { + if let alreadyResolved = deepLinkManager.lastResolvedLink { + currentShare = alreadyResolved + showShareDetail = true + } + deepLinkManager.$lastResolvedLink + .sink { shareModel in + guard let share = shareModel else { return } + currentShare = share + showShareDetail = true + } + .store(in: &cancellables) + } + .sheet(isPresented: $showShareDetail) { + if let model = currentShare { + GSRShareDetailView(model: model) + } + } .animation(.default, value: isOnLogoutScreen) .overlay(alignment: .top) { if let toast { diff --git a/PennMobile/Supporting_Files/Info.plist b/PennMobile/Supporting_Files/Info.plist index cac09f16..043db789 100755 --- a/PennMobile/Supporting_Files/Info.plist +++ b/PennMobile/Supporting_Files/Info.plist @@ -26,11 +26,27 @@ CFBundleURLIconFile CFBundleURLName - + org.pennlabs.PennMobile.dev + CFBundleURLSchemes + + https + + CFBundleURLTypes + + + CFBundleURLName + + + + + + CFBundleTypeRole + Editor CFBundleURLSchemes - + + https + - CFBundleVersion 6656 @@ -53,8 +69,6 @@ Contacts access is required to add emergency contacts NSFaceIDUsageDescription Use Face ID instead of a password for authentication - NSLocationWhenInUseUsageDescription - Enable to show your current location on the map NSPhotoLibraryUsageDescription Give Penn Mobile access to your photos to change your profile picutre NSSupportsLiveActivities