diff --git a/PennMobile.xcodeproj/project.pbxproj b/PennMobile.xcodeproj/project.pbxproj index 4e6aabf33..d454d2d77 100644 --- a/PennMobile.xcodeproj/project.pbxproj +++ b/PennMobile.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 123D017B2CB1CB53006A8916 /* quickBookingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 123D017A2CB1CB53006A8916 /* quickBookingViewController.swift */; }; + 12A6DCED2CCC770600FDF591 /* GSRMappingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12A6DCEC2CCC770600FDF591 /* GSRMappingController.swift */; }; 2108CF211F3F73FF00CEC3F4 /* ContactsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2108CF201F3F73FF00CEC3F4 /* ContactsTableViewController.swift */; }; 2108CF231F3F762500CEC3F4 /* ContactCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2108CF221F3F762500CEC3F4 /* ContactCell.swift */; }; 210AC14520684F9B0050D837 /* HomeCellProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210AC14420684F9B0050D837 /* HomeCellProtocols.swift */; }; @@ -454,6 +456,8 @@ /* Begin PBXFileReference section */ 059FD41D74734ECD9DE8209C /* Pods-PennMobile.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PennMobile.release.xcconfig"; path = "Target Support Files/Pods-PennMobile/Pods-PennMobile.release.xcconfig"; sourceTree = ""; }; + 123D017A2CB1CB53006A8916 /* quickBookingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = quickBookingViewController.swift; sourceTree = ""; }; + 12A6DCEC2CCC770600FDF591 /* GSRMappingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GSRMappingController.swift; sourceTree = ""; }; 2108CF201F3F73FF00CEC3F4 /* ContactsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactsTableViewController.swift; sourceTree = ""; }; 2108CF221F3F762500CEC3F4 /* ContactCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactCell.swift; sourceTree = ""; }; 210AC14420684F9B0050D837 /* HomeCellProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCellProtocols.swift; sourceTree = ""; }; @@ -1279,7 +1283,9 @@ EFAE454823F12185005C2286 /* GSRGroups */, 2189C0802027CDB800771C1F /* GSRBookable.swift */, 2138D55222597FCB00D67CA2 /* GSRLocationsController.swift */, + 123D017A2CB1CB53006A8916 /* quickBookingViewController.swift */, 214E254822480818004CB9C4 /* GSRDeletable.swift */, + 12A6DCEC2CCC770600FDF591 /* GSRMappingController.swift */, 2189C0822027CDB800771C1F /* GSRController.swift */, 21A6B6CF22162652003A357D /* GSRReservationsController.swift */, 2138D55422598AA800D67CA2 /* GSRTabController.swift */, @@ -2591,6 +2597,7 @@ 42D9237329E0C9AF00E9E18E /* FitnessViewController.swift in Sources */, 429EA1E02B8B96B200824455 /* SubletCandidatesView.swift in Sources */, 6C6FE1D327B9B8CB0093FD13 /* ProfilePageTableViewCell.swift in Sources */, + 123D017B2CB1CB53006A8916 /* quickBookingViewController.swift in Sources */, F206DDB328D78A85008F572F /* PreferencesView.swift in Sources */, 2189C0832027CDB800771C1F /* GSRBookable.swift in Sources */, 2189C09C2027CE4100771C1F /* GSRDateHandler.swift in Sources */, @@ -2700,6 +2707,7 @@ 21B653BB2245FFA3001A97C5 /* CampusExpressNetworkManager.swift in Sources */, EFE2D6F3239B11050020F6BF /* CreateGroupCell.swift in Sources */, 2180D2302013F3B4008C94CF /* NotificationRequestable.swift in Sources */, + 12A6DCED2CCC770600FDF591 /* GSRMappingController.swift in Sources */, 212B8359222A331D00F835D6 /* Post.swift in Sources */, B67881D8211CBF2A000DA750 /* MenuTableView.swift in Sources */, EFE2D700239B124D0020F6BF /* GSRGroupUser.swift in Sources */, diff --git a/PennMobile/GSR-Booking/Controllers/GSRLocationsController.swift b/PennMobile/GSR-Booking/Controllers/GSRLocationsController.swift index 6b3459cf3..1d66fb2ac 100755 --- a/PennMobile/GSR-Booking/Controllers/GSRLocationsController.swift +++ b/PennMobile/GSR-Booking/Controllers/GSRLocationsController.swift @@ -19,9 +19,45 @@ class GSRLocationsController: GenericViewController { override func viewDidLoad() { super.viewDidLoad() self.locations = GSRLocationModel.shared.getLocations() + setupButton() setupTableView() } + + fileprivate func setupButton() { + let button = UIButton(type: .system) + button.setTitle("Quick Booking", for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18) + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.systemBlue.cgColor, UIColor.systemGreen.cgColor] + gradientLayer.frame = button.bounds + gradientLayer.cornerRadius = 10 + button.layer.insertSublayer(gradientLayer, at: 0) + + button.layer.cornerRadius = 10 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.3 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowRadius = 4 + button.addTarget(self, action: #selector(quickTapped), for: .touchUpInside) + + view.addSubview(button) + + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + button.centerXAnchor.constraint(equalTo: view.centerXAnchor), + button.heightAnchor.constraint(equalToConstant: 44), + button.widthAnchor.constraint(equalToConstant: 200) + ]) + } + + @objc private func quickTapped() { + let quickView = QuickBookingViewController() + navigationController?.pushViewController(quickView, animated: true) + } + override func setupNavBar() { super.setupNavBar() self.tabBarController?.title = "Study Room Booking" @@ -44,7 +80,14 @@ extension GSRLocationsController { tableView.delegate = self view.addSubview(tableView) - _ = tableView.anchor(view.topAnchor, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, topConstant: 0, leftConstant: 0, bottomConstant: 0, rightConstant: 0, widthConstant: 0, heightConstant: 0) + + tableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 72), // Adjust based on button height + margin + tableView.leftAnchor.constraint(equalTo: view.leftAnchor), + tableView.rightAnchor.constraint(equalTo: view.rightAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) tableView.register(GSRLocationCell.self, forCellReuseIdentifier: GSRLocationCell.identifier) } diff --git a/PennMobile/GSR-Booking/Controllers/GSRMappingController.swift b/PennMobile/GSR-Booking/Controllers/GSRMappingController.swift new file mode 100644 index 000000000..4800ce99c --- /dev/null +++ b/PennMobile/GSR-Booking/Controllers/GSRMappingController.swift @@ -0,0 +1,110 @@ +// +// GSRMappingController.swift +// PennMobile +// +// Created by Kaitlyn Kwan on 10/25/24. +// Copyright © 2024 PennLabs. All rights reserved. +// + +import Foundation +import UIKit +import MapKit + +class GSRMappingController: UIViewController { + var destinationCoordinate: CLLocationCoordinate2D? { + didSet { + if let coordinate = destinationCoordinate { + updateMapAnnotations() + drawRoute(to: coordinate) + } + } + } + + private let mapView: MKMapView = { + let mapView = MKMapView() + mapView.translatesAutoresizingMaskIntoConstraints = false + mapView.showsUserLocation = true + return mapView + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupMapView() + updateMapAnnotations() + } + + private func setupMapView() { + view.addSubview(mapView) + NSLayoutConstraint.activate([ + mapView.topAnchor.constraint(equalTo: view.topAnchor), + mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func updateMapAnnotations() { + guard let destinationCoordinate = destinationCoordinate else { return } + + mapView.removeAnnotations(mapView.annotations) + + if let userLocation = mapView.userLocation.location { + let currentAnnotation = MKPointAnnotation() + currentAnnotation.coordinate = userLocation.coordinate + currentAnnotation.title = "Current Location" + mapView.addAnnotation(currentAnnotation) + } + + let destinationAnnotation = MKPointAnnotation() + destinationAnnotation.coordinate = destinationCoordinate + destinationAnnotation.title = "GSR Location" + mapView.addAnnotation(destinationAnnotation) + + if let userLocation = mapView.userLocation.location { + let midpoint = CLLocationCoordinate2D( + latitude: (userLocation.coordinate.latitude + destinationCoordinate.latitude) / 2, + longitude: (userLocation.coordinate.longitude + destinationCoordinate.longitude) / 2 + ) + let coordinates = [userLocation.coordinate, destinationCoordinate] + let region = MKCoordinateRegion(center: midpoint, latitudinalMeters: 1000, longitudinalMeters: 1000) + mapView.setRegion(mapView.regionThatFits(region), animated: true) + } + } + + private func drawRoute(to destinationCoordinate: CLLocationCoordinate2D) { + guard let userLocation = mapView.userLocation.location else { return } + + let request = MKDirections.Request() + request.source = MKMapItem(placemark: MKPlacemark(coordinate: userLocation.coordinate)) + request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destinationCoordinate)) + request.transportType = .automobile // Change as needed (automobile, walking, etc.) + + let directions = MKDirections(request: request) + directions.calculate { [weak self] response, error in + guard let self = self, let response = response else { + if let error = error { + print("Error calculating directions: \(error.localizedDescription)") + } + return + } + + self.mapView.removeOverlays(self.mapView.overlays) + + for route in response.routes { + self.mapView.addOverlay(route.polyline, level: .aboveRoads) + } + } + } +} + +extension GSRMappingController: MKMapViewDelegate { + func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + if let polyline = overlay as? MKPolyline { + let renderer = MKPolylineRenderer(polyline: polyline) + renderer.strokeColor = .blue + renderer.lineWidth = 5 + return renderer + } + return MKOverlayRenderer(overlay: overlay) + } +} diff --git a/PennMobile/GSR-Booking/Controllers/quickBookingViewController.swift b/PennMobile/GSR-Booking/Controllers/quickBookingViewController.swift new file mode 100644 index 000000000..dd8f2368e --- /dev/null +++ b/PennMobile/GSR-Booking/Controllers/quickBookingViewController.swift @@ -0,0 +1,333 @@ +// +// quickBookingViewController.swift +// PennMobile +// +// Created by Kaitlyn Kwan on 10/5/24. +// Copyright © 2024 PennLabs. All rights reserved. +// + +import UIKit +import CoreLocation +import MapKit + +class QuickBookingViewController: UIViewController, ShowsAlert { + + var locations: [GSRLocation] = GSRLocationModel.shared.getLocations() + fileprivate var selectedOption: String? + fileprivate var currentLocation: String? + fileprivate var prefList: [GSRLocation] = [] + fileprivate var locRankedList: [GSRLocation] = [] + fileprivate var prefLocation: GSRLocation! + + fileprivate var startingLocation: GSRLocation! + fileprivate var soonestStartTimeString: String! + fileprivate var soonestEndTimeString: String! + fileprivate var soonesTimeSlot: GSRTimeSlot! + fileprivate var soonestRoom: GSRRoom! + fileprivate var soonestLocation: GSRLocation! + fileprivate var min: Date! = Date.distantFuture + + fileprivate var allRooms: [GSRRoom]! + + fileprivate let locationManager = CLLocationManager() + fileprivate var hasReceivedLocationUpdate = false + + let GSRCoords = [ + (latitude: 39.95346818228411, longitude: -75.19802835987453, title: "Huntsman"), + (latitude: 39.95127416568136, longitude: -75.19700321676956, title: "Academic Research"), + (latitude: 39.9498719027302, longitude: -75.1957015032777, title: "Biotech Commons"), + (latitude: 39.95059135463279, longitude: -75.18936553396598, title: "Education Commons"), + (latitude: 39.95287694035962, longitude: -75.1934213456054, title: "Weigle"), + (latitude: 39.94964995704518, longitude: -75.19927449163818, title: "Levin Building"), + (latitude: 39.952828782832924, longitude: -75.19349473211366, title: "Lippincott"), + (latitude: 39.95291806251846, longitude: -75.19342134560544, title: "Van Pelt"), + (latitude: 39.95357192013402, longitude: -75.19463651005043, title: "Perelman Center") + ] + + let mapVC = MapViewController() + + let submitButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Submit", for: .normal) + button.setTitleColor(UIColor(named: "labelPrimary"), for: .normal) + button.backgroundColor = UIColor(named: "baseGreen")! + button.layer.cornerRadius = 15 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.3 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowRadius = 5 + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + let unpreferButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Preferred Location", for: .normal) + button.setTitleColor(UIColor(named: "labelPrimary"), for: .normal) + button.backgroundColor = UIColor(named: "baseLabsBlue")! + button.layer.cornerRadius = 15 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.3 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowRadius = 5 + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + let bookButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle("Find GSR", for: .normal) + button.setTitleColor(UIColor(named: "labelPrimary"), for: .normal) + button.backgroundColor = UIColor(named: "baseLabsBlue")! + button.layer.cornerRadius = 15 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.3 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowRadius = 5 + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + let roomLabel: UILabel = { + let label = UILabel() + label.backgroundColor = UIColor.lightGray.withAlphaComponent(0.8) + label.layer.masksToBounds = true + label.layer.cornerRadius = 10 + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .black + label.font = UIFont.systemFont(ofSize: 16, weight: .medium) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let mappingController = GSRMappingController() + + fileprivate func setupDisplay(startSlot: String, endSlot: String, room: GSRRoom, location: GSRLocation) { + roomLabel.text = """ + Soonest available GSR: + Time Slot: \(startSlot) to \(endSlot) + Room: \(room.roomName) + Location: \(location.name) + """ + view.addSubview(roomLabel) + + NSLayoutConstraint.activate([ + roomLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 180), + roomLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + roomLabel.widthAnchor.constraint(equalToConstant: 300) + ]) + } + + override func viewDidLoad() { + super.viewDidLoad() + self.prefList = locations + setupUnpreferButton() + setupBook() + locationManager.delegate = self + locationManager.requestWhenInUseAuthorization() + bookButton.addTarget(self, action: #selector(findGSRButtonPressed), for: .touchUpInside) + } + + @objc func findGSRButtonPressed() { + prefList = orderLocations() + prefList = makePreference() + self.setupQuickBooking { + self.setupDisplay(startSlot: self.soonestStartTimeString, endSlot: self.soonestEndTimeString, room: self.soonestRoom, location: self.soonestLocation) + self.setupMapping() + self.setupSubmitButton() + } + } + + func setupMapping() { + var lat: CLLocationDegrees! + var long: CLLocationDegrees! + if let coords = GSRCoords.first(where: { $0.title == soonestLocation.name }) { + lat = coords.latitude + long = coords.longitude + } + mappingController.destinationCoordinate = CLLocationCoordinate2D(latitude: lat, longitude: long) + + addChild(mappingController) + + view.addSubview(mappingController.view) + + mappingController.view.layer.cornerRadius = 10 + mappingController.view.backgroundColor = UIColor.red.withAlphaComponent(0.5) + mappingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + mappingController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 280), + mappingController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor), + mappingController.view.widthAnchor.constraint(equalToConstant: 300), + mappingController.view.heightAnchor.constraint(equalToConstant: 300) + ]) + mappingController.didMove(toParent: self) + } + + func setupQuickBooking(completion: @escaping () -> Void) { + DispatchQueue.global().async { [self] in + var skip: Bool = false + var foundAvailableRoom = false + for location in prefList { + + if !UserDefaults.standard.isInWharton() { + skip = true + } + + if (location.kind == .wharton) && skip { + continue + } + + GSRNetworkManager.instance.getAvailability(lid: location.lid, gid: location.gid, startDate: nil) { [self] result in + switch result { + case .success(let rooms): + if !rooms.isEmpty { + self.startingLocation = location + self.allRooms = rooms + self.getSoonestTimeSlot() + foundAvailableRoom = true + } + case .failure: + present(toast: .apiError) + } + } + + if !foundAvailableRoom && location == self.locations.last { + present(toast: .apiError) + } + } + DispatchQueue.main.async { + if self.soonestRoom != nil { + completion() + } + } + } + } + + func setupSubmitButton() { + view.addSubview(submitButton) + submitButton.addTarget(self, action: #selector(quickBook(_:)), for: .touchUpInside) + + NSLayoutConstraint.activate([ + submitButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 600), + submitButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + submitButton.widthAnchor.constraint(equalToConstant: 300), + submitButton.heightAnchor.constraint(equalToConstant: 50) + ]) + } + + func setupUnpreferButton() { + view.addSubview(unpreferButton) + unpreferButton.addTarget(self, action: #selector(showDropdown), for: .touchUpInside) + + NSLayoutConstraint.activate([ + unpreferButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + unpreferButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + unpreferButton.widthAnchor.constraint(equalToConstant: 300), + unpreferButton.heightAnchor.constraint(equalToConstant: 50) + ]) + } + + func makePreference() -> [GSRLocation] { + self.prefList.insert(self.prefLocation, at: 0) + return prefList + } + + @objc func showDropdown() { + let alertController = UIAlertController(title: "Choose an option", message: nil, preferredStyle: .actionSheet) + + for option in locations { + alertController.addAction(UIAlertAction(title: option.name, style: .default, handler: { [weak self] _ in + self?.selectedOption = option.name + self!.prefLocation = (self?.locations.first(where: { $0.name == self?.selectedOption })!)! + self?.unpreferButton.setTitle(option.name, for: .normal) + })) + } + + alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + + present(alertController, animated: true, completion: nil) + } + + func setupBook() { + view.addSubview(bookButton) + + NSLayoutConstraint.activate([ + bookButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 100), + bookButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + bookButton.widthAnchor.constraint(equalToConstant: 200), + bookButton.heightAnchor.constraint(equalToConstant: 50) + ]) + } + + func getSoonestTimeSlot() { + let formatter = DateFormatter() + formatter.timeZone = TimeZone.current + formatter.dateFormat = "HH:mm" + for room in allRooms { + guard let availability = room.availability.first(where: { $0.startTime >= Date() }) else { + continue + } + let startTime = availability.startTime + + if startTime < min { + min = startTime + soonestStartTimeString = formatter.string(from: startTime) + soonestEndTimeString = formatter.string(from: availability.endTime) + soonesTimeSlot = availability + soonestRoom = room + soonestLocation = startingLocation + } + } + } +} + +extension QuickBookingViewController: CLLocationManagerDelegate { + + func orderLocations() -> [GSRLocation] { + locRankedList = locations + locRankedList.sort { (loc1, loc2) in + guard let loc1Coords = GSRCoords.first(where: { $0.title == loc1.name }), + let loc2Coords = GSRCoords.first(where: { $0.title == loc2.name }) else { + return false + } + let loc1 = CLLocation(latitude: loc1Coords.latitude, longitude: loc1Coords.longitude) + let loc2 = CLLocation(latitude: loc2Coords.latitude, longitude: loc2Coords.longitude) + + let distance1 = locationManager.location!.distance(from: loc1) + let distance2 = locationManager.location!.distance(from: loc2) + + return distance1 < distance2 + } + return locRankedList + } + + func setupLocationManager() { + if CLLocationManager.locationServicesEnabled() { + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + locationManager.startUpdatingLocation() + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + if hasReceivedLocationUpdate { return } + hasReceivedLocationUpdate = true + let latitude = location.coordinate.latitude + let longitude = location.coordinate.longitude + currentLocation = String(format: "%.6f, %.6f", latitude, longitude) + print("\(currentLocation ?? "current location unavailable")") + locationManager.stopUpdatingLocation() + hasReceivedLocationUpdate = false + } +} + +extension QuickBookingViewController: GSRBookable { + @objc fileprivate func quickBook(_ sender: Any) { + if !Account.isLoggedIn { + self.showAlert(withMsg: "You are not logged in!", title: "Error", completion: {self.navigationController?.popViewController(animated: true)}) + } else { + submitBooking(for: GSRBooking(gid: soonestLocation.gid, startTime: soonesTimeSlot.startTime, endTime: soonesTimeSlot.endTime, id: soonestRoom.id, roomName: soonestRoom.roomName)) + } + } +}