diff --git a/PennMobile/GSR-Booking/Controller/GSRQuickBook.swift b/PennMobile/GSR-Booking/Controller/GSRQuickBook.swift index a614727a..5f915420 100644 --- a/PennMobile/GSR-Booking/Controller/GSRQuickBook.swift +++ b/PennMobile/GSR-Booking/Controller/GSRQuickBook.swift @@ -9,139 +9,122 @@ import Foundation import SwiftUI import PennMobileShared +import Combine -struct AlertContent: Identifiable { - let id = UUID() - let title: String - let message: String - let onAccept: (() -> Void)? - let onCancel: (() -> Void)? -} -class GSRQuickBook: ObservableObject, GSRBookable { - let vm: GSRViewModel - - fileprivate var location: GSRLocation? - fileprivate var soonestDetails: QuickRoomDetails? - fileprivate var allRooms: [GSRRoom] = [] - fileprivate var selectedStartTime: Date? +class GSRQuickBook: ObservableObject { + @ObservedObject var vm: GSRViewModel + @Published var configuration: QuickBookRequestConfiguration - fileprivate struct QuickRoomDetails { - var slot: GSRTimeSlot - var room: GSRRoom + var numberOfOptions: Int { + var result = 0 + for duration in self.configuration.durationsAllowed { + // if i'm looking for a 90-min booking with an end-time of 5pm: that booking would have to start at 3:30 + // note that configuration.endTime is the ending of the window of allowed booking start times. + let adjustedEnd = self.configuration.endTime.add(minutes: -1 * duration) + guard let times = durationTimeLookupTable[duration] else { continue } + result += times.reduce(into: 0) { sum, slot in + guard slot.key.startTime >= self.configuration.startTime && slot.key.startTime <= adjustedEnd else { return } + sum += slot.value + } + } + return result } - var onQuickBookSuccess: ((GSRBooking) -> Void)? + private(set) var durationTimeLookupTable = [Int:[GSRTimeSlot:Int]]() - init(vm: GSRViewModel) { - self.vm = vm + private(set) var availabilityMemoization: [GSRRoom:[GSRTimeSlot:[Int: Bool]]] { // 3D array: 2D array of roomtimeslots for DP, third dimension is whether a timeslot is available + didSet { + // Make sure we minimize in-place modifications to availabilityMemoization to prevent this from re-firing all the time. + durationTimeLookupTable = Self.getNumericalTimeslotAvailability(from: availabilityMemoization) + } } - @Published var activeAlert: AlertContent? - - func showAlert(withMsg: String, title: String, completion: (() -> Void)?) {} + static func getNumericalTimeslotAvailability(from avail: [GSRRoom:[GSRTimeSlot:[Int: Bool]]]) -> [Int:[GSRTimeSlot:Int]] { + var result = [Int:[GSRTimeSlot:Int]]() + for (_, timeslotDict) in avail { + for (timeslot, durationDict) in timeslotDict { + for (duration, isAvailable) in durationDict where isAvailable { + result[duration, default: [:]][timeslot, default: 0] += 1 + } + } + } + + return result + } - func showOption(withMsg: String, title: String, onAccept: (() -> Void)?, onCancel: (() -> Void)?) {} - - @MainActor - internal func populateSoonestTimeslot(location: GSRLocation, duration: Int, time: Date) async throws{ - self.location = location - let avail = try await GSRNetworkManager.getAvailability(for: location, startDate: Date.now, endDate: Date.now) - self.allRooms = avail - self.selectedStartTime = time - soonestDetails = getSoonestTimeSlot(duration: duration, time: time) + struct RoomTimeslot: Hashable { + let room: GSRRoom + let timeslot: GSRTimeSlot } + var locationObserver: (AnyCancellable)? + var dayObserver: (AnyCancellable)? - private func getSoonestTimeSlot(duration: Int, time: Date) -> QuickRoomDetails? { - var bestDetails: QuickRoomDetails? - var bestStart: Date = .distantFuture - - for room in allRooms { - let slots = room.availability - .sorted(by: { $0.startTime < $1.startTime }) - .filter { $0.isAvailable && $0.startTime >= time } - - for slot in slots { - let slotMinutes = Int(slot.endTime.timeIntervalSince(slot.startTime) / 60) - if slotMinutes == duration { - if slot.startTime < bestStart { - bestDetails = QuickRoomDetails(slot: slot, room: room) - bestStart = slot.startTime - } - } - } - - let count = slots.count - var i = 0 - while i < count { - let startSlot = slots[i] - var sumMinutes = 0 - var lastEnd = startSlot.startTime - var j = i - - while j < count && sumMinutes < duration { - let s = slots[j] - if s.startTime != lastEnd { break } - let minutes = Int(s.endTime.timeIntervalSince(s.startTime) / 60) - sumMinutes += minutes - lastEnd = s.endTime - j += 1 - } - - if sumMinutes == duration { - let composed = GSRTimeSlot(startTime: startSlot.startTime, endTime: lastEnd, isAvailable: true) - if composed.startTime < bestStart { - bestDetails = QuickRoomDetails(slot: composed, room: room) - bestStart = composed.startTime - } - } - i += 1 - } + init(vm: GSRViewModel, configuration: QuickBookRequestConfiguration) { + self._vm = ObservedObject(wrappedValue: vm) + self.configuration = configuration + self.availabilityMemoization = Self.getMemoizedTimeslotAvailability(rooms: vm.roomsAtSelectedLocation, gsrType: vm.selectedLocation?.kind ?? .libcal) + self.locationObserver = self.vm.$selectedLocation.sink { new in + let newRooms = self.vm.roomsAtSelectedLocation // THIS COULD BE A RACE CONDITION + self.configuration = .init(defaultValueFor: new, with: newRooms) + self.availabilityMemoization = Self.getMemoizedTimeslotAvailability(rooms: newRooms, gsrType: new?.kind ?? .libcal) + } + self.dayObserver = self.vm.$selectedDate.sink { new in + self.configuration = .init(defaultValueFor: vm.selectedLocation, with: vm.roomsAtSelectedLocation, on: new) } - return bestDetails } - @MainActor - internal func quickBook(location: GSRLocation, duration: Int, time: Date) async throws { - do { - try await populateSoonestTimeslot(location: location, duration: duration, time: time) - } catch { - print(error) - } - - guard let details = soonestDetails, let location = self.location else { - return + private static func getMemoizedTimeslotAvailability(rooms: [GSRRoom], gsrType: GSRLocation.GSRServiceType) -> [GSRRoom:[GSRTimeSlot:[Int: Bool]]] { + // Base case + var newMemo = [GSRRoom:[GSRTimeSlot:[Int: Bool]]]() + for room in rooms { + newMemo[room] = [GSRTimeSlot:[Int: Bool]]() + for timeslot in room.availability { + newMemo[room]![timeslot] = [30: timeslot.isAvailable] + } } - let timeSlot = details.slot - let room = details.room - let booking = GSRBooking(gid: location.gid, startTime: timeSlot.startTime, endTime: timeSlot.endTime, id: room.id, roomName: room.roomName) - let comparedTime = selectedStartTime ?? Date.now - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short - - let startString = formatter.string(from: timeSlot.startTime) - let endString = formatter.string(from: timeSlot.endTime) - - let attemptBooking = { [weak self] in - guard let self else { return } - Task { @MainActor in - do { - try await GSRNetworkManager.makeBooking(for: booking) - self.onQuickBookSuccess?(booking) - } catch { - print(error) + for duration in Array(stride(from: 30, through: gsrType.maxConsecutiveBookings * 30, by: 30)) { + for room in rooms { + var nextTimeslot: GSRTimeSlot? = nil + for timeslot in room.availability.sorted(by: { $0.startTime > $1.startTime }) { + let oldNextTimeslot = nextTimeslot + nextTimeslot = timeslot + + guard let oldNextTimeslot else { continue } + if newMemo[room]![timeslot]![duration] != nil { continue } + //handle wrapping days + //timeslots more than 30 minutes apart should be excluded + guard abs(oldNextTimeslot.startTime.minutesFrom(date: timeslot.startTime)) <= 30 else { + newMemo[room]![timeslot]![duration] = false + nextTimeslot = nil + continue + } + + // i.e. we can book a 90-minute reservation if we're available right now and there's a 60-minute reservation 30 minutes from now + newMemo[room]![timeslot]![duration] = timeslot.isAvailable && (newMemo[room]![oldNextTimeslot]![duration - 30] ?? false) } } } + return newMemo + } + + struct QuickBookRequestConfiguration { + var durationsAllowed: [Int] + var startTime: Date + var endTime: Date + let timeLower: Date + let timeUpper: Date + let rooms: [GSRRoom] - if timeSlot.startTime > comparedTime { - activeAlert = AlertContent(title: "Later Booking Available", message: "\(room.roomName) is available from \(startString) to \(endString).\nWould you still like to book this time?", onAccept: attemptBooking, onCancel: nil) - return + init(defaultValueFor location: GSRLocation?, with rooms: [GSRRoom], on day: Date = Date.now) { + self.durationsAllowed = Array(stride(from: 60, through: (location?.kind.maxConsecutiveBookings ?? 3) * 30, by: 30)) + self.timeLower = (Calendar.current.isDateInToday(day) ? Date.now : day).floorHalfHour + self.timeUpper = Calendar.current.startOfDay(for: day.addingTimeInterval(60 * 60 * 24)).addingTimeInterval(-1 * 60) // Day at 11:59pm + self.startTime = self.timeLower + self.endTime = self.timeUpper + self.rooms = rooms } - - activeAlert = AlertContent(title: "Booking Available", message: "\(room.roomName) is available from \(startString) to \(endString).\nWould you like to book this time?", onAccept: attemptBooking, onCancel: nil) } } diff --git a/PennMobile/GSR-Booking/Views/Booking/GSRBookingToolbarView.swift b/PennMobile/GSR-Booking/Views/Booking/GSRBookingToolbarView.swift index 03327bf1..2351a7f1 100644 --- a/PennMobile/GSR-Booking/Views/Booking/GSRBookingToolbarView.swift +++ b/PennMobile/GSR-Booking/Views/Booking/GSRBookingToolbarView.swift @@ -9,26 +9,32 @@ import SwiftUI import PennMobileShared +enum QuickBookStatus { + case closed, search, explore +} + struct GSRBookingToolbarView: View { @EnvironmentObject var vm: GSRViewModel @Environment(\.presentToast) var presentToast - @State var startedQuickBook = false + @State var quickBookStatus = QuickBookStatus.closed var body: some View { ZStack { - if startedQuickBook { + if quickBookStatus == .search { Rectangle() - .foregroundStyle(Color.black.opacity(0.001)) + .foregroundStyle(.clear) + .contentShape(Rectangle()) .onTapGesture { withAnimation(.snappy(duration: 0.2)) { - startedQuickBook = false + quickBookStatus = .closed } } + } VStack { Spacer() HStack(spacing: 12) { - if !vm.selectedTimeslots.isEmpty { + if !vm.selectedTimeslots.isEmpty && quickBookStatus == .closed { Button { Task { do { @@ -68,8 +74,8 @@ struct GSRBookingToolbarView: View { } } .transition(.move(edge: .leading).combined(with: .opacity)) - } else if FeatureFlags.shared.gsrQuickBook { - RoomFinderSelectionPanel(vm: vm, isEnabled: $startedQuickBook) + } else { + RoomFinderSelectionPanel(vm: vm, status: $quickBookStatus) .transition(.move(edge: .leading).combined(with: .opacity)) } } diff --git a/PennMobile/GSR-Booking/Views/Booking/RangeSlider.swift b/PennMobile/GSR-Booking/Views/Booking/RangeSlider.swift new file mode 100644 index 00000000..aa7b9b45 --- /dev/null +++ b/PennMobile/GSR-Booking/Views/Booking/RangeSlider.swift @@ -0,0 +1,281 @@ +// +// RangeSlider.swift +// PennMobile +// +// Created by Jonathan Melitski on 4/28/26. +// Copyright © 2026 PennLabs. All rights reserved. +// + +// Generated by Claude because I didn't feel like building this from scratch + +import SwiftUI + +// MARK: - RangeSlider + +struct RangeSlider: View + where V.Stride: BinaryFloatingPoint +{ + // MARK: Public interface + + @Binding var lowerValue: V + @Binding var upperValue: V + + /// The full range of valid values. + let bounds: ClosedRange + + /// Discrete step size. Pass `nil` for continuous (default). + var step: V? = nil + + /// Minimum value-space gap between the two knobs. + /// Defaults to 1 % of the total range when `nil`. + var minimumSpan: V? = nil + + // MARK: Layout + + var knobDiameter: CGFloat = 28 + var trackHeight: CGFloat = 4 + + // MARK: Label + + let label: (V) -> Content + var labelSpacing: CGFloat = 8 + @State private var measuredLabelHeight: CGFloat = 0 + + // MARK: Drag state + + @State private var isDraggingLower = false + @State private var isDraggingUpper = false + + // MARK: Body + + var body: some View { + GeometryReader { geo in + let trackWidth = geo.size.width - knobDiameter + let lowerX = xPosition(for: lowerValue, trackWidth: trackWidth) + let upperX = xPosition(for: upperValue, trackWidth: trackWidth) + + ZStack(alignment: .leading) { + + // ── Background track ───────────────────────────────────────── + Capsule() + .fill(.secondary.opacity(0.2)) + .frame(height: trackHeight) + .padding(.horizontal, knobDiameter / 2) + .offset(y: (measuredLabelHeight + labelSpacing) / 2) + + // ── Active fill ────────────────────────────────────────────── + Capsule() + .fill(.tint) + .frame( + width: max(0, (upperX + knobDiameter / 2) - (lowerX + knobDiameter / 2)), + height: trackHeight + ) + .offset(x: lowerX + knobDiameter / 2, y: (measuredLabelHeight + labelSpacing) / 2) + + // ── Lower label + knob ─────────────────────────────────────── + VStack(spacing: labelSpacing) { + label(lowerValue) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { + measuredLabelHeight = $0 + } + KnobView(isDragging: isDraggingLower, diameter: knobDiameter) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .named("RangeSlider")) + .onChanged { drag in + isDraggingLower = true + let raw = value(at: drag.location.x - knobDiameter / 2, trackWidth: trackWidth) + let ceiling = upperValue - effectiveMinSpan + lowerValue = clamp(raw, lo: bounds.lowerBound, hi: ceiling) + } + .onEnded { _ in isDraggingLower = false } + ) + } + .position( + x: lowerX + knobDiameter / 2, + y: (measuredLabelHeight + labelSpacing) / 2 + knobDiameter / 2 + ) + .zIndex(isDraggingLower ? 2 : 1) + + // ── Upper label + knob ─────────────────────────────────────── + VStack(spacing: labelSpacing) { + label(upperValue) + KnobView(isDragging: isDraggingUpper, diameter: knobDiameter) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .named("RangeSlider")) + .onChanged { drag in + isDraggingUpper = true + let raw = value(at: drag.location.x - knobDiameter / 2, trackWidth: trackWidth) + let floor = lowerValue + effectiveMinSpan + upperValue = clamp(raw, lo: floor, hi: bounds.upperBound) + } + .onEnded { _ in isDraggingUpper = false } + ) + } + .position( + x: upperX + knobDiameter / 2, + y: (measuredLabelHeight + labelSpacing) / 2 + knobDiameter / 2 + ) + .zIndex(isDraggingUpper ? 2 : 1) + } + .coordinateSpace(.named("RangeSlider")) + } + .frame(height: knobDiameter + measuredLabelHeight + labelSpacing) + .sensoryFeedback(.impact(flexibility: .solid, intensity: 0.8), trigger: lowerValue) { old, new in + guard let step else { return false } + return abs(new - old) >= step * 0.5 + } + .sensoryFeedback(.impact(flexibility: .solid, intensity: 0.8), trigger: upperValue) { old, new in + guard let step else { return false } + return abs(new - old) >= step * 0.5 + } + } + + // MARK: Value ↔ pixel helpers + + private func xPosition(for val: V, trackWidth: CGFloat) -> CGFloat { + let span = bounds.upperBound - bounds.lowerBound + guard span > 0 else { return 0 } + return CGFloat((val - bounds.lowerBound) / span) * trackWidth + } + + private func value(at x: CGFloat, trackWidth: CGFloat) -> V { + let span = bounds.upperBound - bounds.lowerBound + let ratio = V(max(0, min(CGFloat(1), x / max(trackWidth, 1)))) + let raw = ratio * span + bounds.lowerBound + guard let step, step > 0 else { return raw } + return (raw / step).rounded() * step + } + + private var effectiveMinSpan: V { + minimumSpan ?? ((bounds.upperBound - bounds.lowerBound) / 100) + } + + private func clamp(_ val: V, lo: V, hi: V) -> V { + max(lo, min(hi, val)) + } +} + +// MARK: - Inits + +extension RangeSlider { + /// Primary init — caller supplies a label view for each knob. + init( + lowerValue: Binding, + upperValue: Binding, + bounds: ClosedRange, + step: V? = nil, + minimumSpan: V? = nil, + labelSpacing: CGFloat = 8, + @ViewBuilder label: @escaping (V) -> Content + ) { + self._lowerValue = lowerValue + self._upperValue = upperValue + self.bounds = bounds + self.step = step + self.minimumSpan = minimumSpan + self.labelSpacing = labelSpacing + self.label = label + } +} + +extension RangeSlider where Content == EmptyView { + /// No-label init — Content pins to EmptyView, measuredLabelHeight stays 0. + init( + lowerValue: Binding, + upperValue: Binding, + bounds: ClosedRange, + step: V? = nil, + minimumSpan: V? = nil + ) { + self.init(lowerValue: lowerValue, upperValue: upperValue, + bounds: bounds, step: step, minimumSpan: minimumSpan, + label: { _ in EmptyView() }) + } +} + +extension RangeSlider where V == Double, Content == EmptyView { + /// Int-step sugar, no label. + init( + lowerValue: Binding, + upperValue: Binding, + bounds: ClosedRange, + step: Int, + minimumSpan: Double? = nil + ) { + self.init(lowerValue: lowerValue, upperValue: upperValue, + bounds: bounds, step: Double(step), minimumSpan: minimumSpan) + } +} + +// MARK: - KnobView + +private struct KnobView: View { + let isDragging: Bool + let diameter: CGFloat + + var body: some View { + ZStack { + if #available(iOS 26, *) { + Circle() + .fill(.white) + .glassEffect(isDragging ? .clear : .identity, in: Circle()) + } else { + Circle() + .fill(.white) + Circle() + .strokeBorder(.tint.opacity(0.3), lineWidth: 1) + } + } + .frame(width: diameter, height: diameter) + .contentShape(Circle()) + .shadow( + color: .black.opacity(0.18), + radius: isDragging ? 14 : 6, + y: isDragging ? 6 : 2 + ) + .scaleEffect(isDragging ? 1.3 : 1.0) + .animation(.spring(response: 0.25, dampingFraction: 0.6), value: isDragging) + } +} + +// MARK: - Preview + +#Preview("Range Slider") { + @Previewable @State var lo: Double = 20 + @Previewable @State var hi: Double = 75 + @Previewable @State var loF: Float = 0.3 + @Previewable @State var hiF: Float = 0.9 + + ScrollView { + VStack(alignment: .leading, spacing: 36) { + + // With labels + RangeSlider(lowerValue: $lo, upperValue: $hi, bounds: 0...100) { value in + Text("\(Int(value)) min") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.bottom) + } + + // No label + RangeSlider(lowerValue: $lo, upperValue: $hi, + bounds: 0...100, step: 5, minimumSpan: 10) + .tint(.orange) + + RangeSlider(lowerValue: $loF, upperValue: $hiF, bounds: 0...1) + .tint(.green) + + VStack(alignment: .leading, spacing: 4) { + Text("lo \(lo, format: .number.precision(.fractionLength(1))) – hi \(hi, format: .number.precision(.fractionLength(1)))") + .monospacedDigit() + .font(.caption) + .foregroundStyle(.secondary) + Text("loF \(loF, format: .number.precision(.fractionLength(2))) – hiF \(hiF, format: .number.precision(.fractionLength(2)))") + .monospacedDigit() + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(32) + } +} diff --git a/PennMobile/GSR-Booking/Views/Booking/RoomFinderSelectionPanel.swift b/PennMobile/GSR-Booking/Views/Booking/RoomFinderSelectionPanel.swift index d6458621..26d2b4c6 100644 --- a/PennMobile/GSR-Booking/Views/Booking/RoomFinderSelectionPanel.swift +++ b/PennMobile/GSR-Booking/Views/Booking/RoomFinderSelectionPanel.swift @@ -12,147 +12,212 @@ import SwiftUI struct RoomFinderSelectionPanel: View { @ObservedObject var vm: GSRViewModel @StateObject var quickBook: GSRQuickBook - @Binding var isEnabled: Bool + @Binding var status: QuickBookStatus @State var expectedWidth: CGFloat? @State var feedbackGenerator: UIImpactFeedbackGenerator? = nil - @State var durationOptions: [Int] = [] - @State var minTimeRequirement: Int? - @State var maxTimeRequirement: Int? - @State var earliestTimeRequirement: Date? - @State var latestTimeRequirement: Date? - - let formatter = DateFormatter() - - private func textWidth(_ text: String, font: UIFont = .preferredFont(forTextStyle: .body)) -> CGFloat { - let attributes: [NSAttributedString.Key: Any] = [.font: font] - let size = (text as NSString).size(withAttributes: attributes) - return ceil(size.width) - } - - private var panelWidth: CGFloat? { - textWidth("Find me a room") + 24 * 2 - } + @Namespace var namespace - init(vm: GSRViewModel, isEnabled: Binding) { - _quickBook = StateObject(wrappedValue: GSRQuickBook(vm: vm)) - self._vm = ObservedObject(initialValue: vm) - self._isEnabled = isEnabled + @Environment(\.colorScheme) var colorScheme + + let formatter: DateFormatter = { + let formatter = DateFormatter() formatter.dateFormat = "h:mm a" formatter.amSymbol = "AM" formatter.pmSymbol = "PM" + return formatter + }() + + + init(vm: GSRViewModel, status: Binding) { + _quickBook = StateObject(wrappedValue: GSRQuickBook(vm: vm, configuration: .init(defaultValueFor: vm.selectedLocation, with: vm.roomsAtSelectedLocation))) + self._vm = ObservedObject(initialValue: vm) + self._status = status } var body: some View { + let startBinding = Binding { + quickBook.configuration.startTime.timeIntervalSince1970 + } set: { new in + quickBook.configuration.startTime = Date(timeIntervalSince1970: new) + } + let endBinding = Binding { + quickBook.configuration.endTime.timeIntervalSince1970 + } set: { new in + quickBook.configuration.endTime = Date(timeIntervalSince1970: new) + } + VStack { Spacer() - if let panelWidth, isEnabled { - VStack { - Spacer() - Text("Duration") - .foregroundColor(Color("gsrBlue")) - Picker("", selection: $minTimeRequirement) { - ForEach(durationOptions, id: \.self) { option in - Text("\(String(option))m") - .tag(option) + Group { + switch status { + case .search: + VStack { + RangeSlider(lowerValue: startBinding, upperValue: endBinding, + bounds: quickBook.configuration.timeLower.timeIntervalSince1970...quickBook.configuration.timeUpper.timeIntervalSince1970, step: (30 * 60), minimumSpan: (30 * 60)) { time in + let str = formatter.string(from: Date(timeIntervalSince1970: time)) + Text(str) + .font(.callout) + .multilineTextAlignment(.center) + .foregroundStyle(.primary) + .padding(4) + .background { + ZStack { + RoundedRectangle(cornerRadius: 4) + .foregroundStyle(.background) + .shadow(radius: 2) + RoundedRectangle(cornerRadius: 4) + .foregroundStyle(Color("gsrAvailable")) + } + } + .padding(.bottom, 8) + } + .tint(Color("gsrBlue")) + .padding(.horizontal) + Divider() + .padding() + VStack { + let rectangles: Int = vm.selectedLocation?.kind.maxConsecutiveBookings ?? 3 + HStack { + ForEach(0.. 1 ? "s" : "")", systemImage: "magnifyingglass") + .contentTransition(.numericText()) + .foregroundStyle(Color.white) + .padding(12) + .background { + RoundedRectangle(cornerRadius: 12) + .foregroundStyle(Color("gsrBlue")) + .shadow(radius: 2) + } } - try await quickBook.quickBook(location: location, duration: duration, time: time) + case .explore: + Label("Book now", systemImage: "wand.and.sparkles") + .foregroundStyle(Color.white) + .padding(12) + .background { + RoundedRectangle(cornerRadius: 12) + .foregroundStyle(Color("gsrBlue")) + .shadow(radius: 2) + } + } } - } label: { - Label(isEnabled ? "Book now" : "Find me a room", systemImage: "wand.and.sparkles") - .font(.body) - .foregroundStyle(isEnabled ? Color.white : Color.black) - .padding(12) - .background { - RoundedRectangle(cornerRadius: 12) - .foregroundStyle(isEnabled ? Color("gsrBlue") : Color.white) - .shadow(radius: 2) - } - } - } - .onAppear { - self.feedbackGenerator = UIImpactFeedbackGenerator(style: .light) - - let slots = vm.getRelevantAvailability() - self.earliestTimeRequirement = slots.sorted(by: { $0.startTime < $1.startTime }).first?.startTime - self.latestTimeRequirement = slots.sorted(by: { $0.endTime > $1.endTime }).first?.endTime - - guard let loc = vm.selectedLocation else { return } - switch loc.kind { - case .libcal, .penngroups: - self.durationOptions = [30, 60, 90, 120] - self.minTimeRequirement = 30 - self.maxTimeRequirement = 120 - case .wharton: - self.durationOptions = [30, 60, 90] - self.minTimeRequirement = 30 - self.maxTimeRequirement = 90 - } - } - .alert(quickBook.activeAlert?.title ?? "Booking Error", isPresented: Binding( - get: { quickBook.activeAlert != nil }, - set: { newValue in - if !newValue { quickBook.activeAlert = nil } - } - ), presenting: quickBook.activeAlert) { alert in - Button("OK") { - alert.onAccept?() - quickBook.activeAlert = nil - } - Button("Cancel", role: .cancel) { - alert.onCancel?() - quickBook.activeAlert = nil } - } message: { alert in - Text(alert.message).font(.subheadline) + .disabled(status == .search && quickBook.numberOfOptions == 0) } } } diff --git a/PennMobileShared/Feature Flags/FeatureFlags.swift b/PennMobileShared/Feature Flags/FeatureFlags.swift index f2e863bd..2ba1fb00 100644 --- a/PennMobileShared/Feature Flags/FeatureFlags.swift +++ b/PennMobileShared/Feature Flags/FeatureFlags.swift @@ -18,8 +18,6 @@ public final class FeatureFlags: @unchecked Sendable { @FeatureFlagDefinition("ALWAYS_SHOW_FEATURE_FLAG_SETTINGS", channel: .testFlight) public var alwaysShowFeatureFlagSettings @FeatureFlagDefinition("TEST_FEATURE_FLAG", channel: .adHoc) public var testFeatureFlag - - @FeatureFlagDefinition("GSR_QUICK_BOOK", channel: .testFlight) public var gsrQuickBook @FeatureFlagDefinition("SHOW_AUTH_SETTINGS", channel: .experimental) public var showAuthSettings