diff --git a/ios/.swiftlint.yml b/ios/.swiftlint.yml index 331ba705..806ea059 100644 --- a/ios/.swiftlint.yml +++ b/ios/.swiftlint.yml @@ -27,28 +27,28 @@ custom_rules: name: "Writing log messages directly to standard out is disallowed" regex: "(\\bprint|\\bdebugPrint|\\bdump|Swift\\.print|Swift\\.debugPrint|Swift\\.dump|_printChanges)\\s*\\(" match_kinds: - - identifier + - identifier message: "Don't commit `print(…)`, `debugPrint(…)`, `dump(…)`, or `_printChanges()` as they write to standard out in release. Either log to a dedicated logging system or silence this warning in debug-only scenarios explicitly using `// swiftlint:disable:next no_direct_standard_out_logs`" severity: error no_file_literal: name: "#file is disallowed" regex: "(\\b#file\\b)" match_kinds: - - identifier + - identifier message: "Instead of #file, use #fileID" severity: error no_filepath_literal: name: "#filePath is disallowed" regex: "(\\b#filePath\\b)" match_kinds: - - identifier + - identifier message: "Instead of #filePath, use #fileID." severity: error no_unchecked_sendable: name: "`@unchecked Sendable` is discouraged." regex: "@unchecked Sendable" match_kinds: - - attribute.builtin - - typeidentifier + - attribute.builtin + - typeidentifier message: "Instead of using `@unchecked Sendable`, consider a safe alternative like a standard `Sendable` conformance or using `@preconcurrency import`. If you really must use `@unchecked Sendable`, you can add a `// swiftlint:disable:next no_unchecked_sendable` annotation with an explanation for how we know the type is thread-safe, and why we have to use @unchecked Sendable instead of Sendable. More explanation and suggested safe alternatives are available at https://github.com/airbnb/swift#unchecked-sendable." - severity: error \ No newline at end of file + severity: error diff --git a/ios/Buildings/Package.resolved b/ios/Buildings/Package.resolved index fd4f84ab..39b47a2e 100644 --- a/ios/Buildings/Package.resolved +++ b/ios/Buildings/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e3a4e0e2698fbe6672e0ded7860ebef15af35732794cfd07f0ee0026070ddaae", + "originHash" : "4a8bbd90f86c1b7526be73a992b1e6d75591cf29321bee4531ad8793e3495921", "pins" : [ { "identity" : "bottomsheet", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/avdn-dev/VISOR.git", "state" : { - "revision" : "8281ca20b434afdb5302e272677f62a2ab0f6806", - "version" : "4.0.0" + "revision" : "4ba6dd8d9845c431fca0edeecff2d5efbc105045", + "version" : "8.0.1" } } ], diff --git a/ios/Buildings/Package.swift b/ios/Buildings/Package.swift index 241e2328..de13e806 100644 --- a/ios/Buildings/Package.swift +++ b/ios/Buildings/Package.swift @@ -36,7 +36,12 @@ let package = Package( swiftSettings: .defaultSettings), .target( name: "BuildingViewModels", - dependencies: ["BuildingInteractors", "BuildingModels", "CommonUI", .product(name: "BottomSheet", package: "BottomSheet")], + dependencies: [ + "BuildingInteractors", + "BuildingModels", + .product(name: "RoomModels", package: "Rooms"), + .product(name: "BottomSheet", package: "BottomSheet"), + ], swiftSettings: .defaultSettings), .target( name: "BuildingInteractors", diff --git a/ios/Buildings/Sources/BuildingViewModels/BuildingViewModel.swift b/ios/Buildings/Sources/BuildingViewModels/BuildingViewModel.swift index daee5fc0..d7a226c9 100644 --- a/ios/Buildings/Sources/BuildingViewModels/BuildingViewModel.swift +++ b/ios/Buildings/Sources/BuildingViewModels/BuildingViewModel.swift @@ -8,10 +8,10 @@ import BuildingInteractors import BuildingModels import BuildingServices -import CommonUI import Foundation import Location import Observation +import RoomModels // MARK: - BuildingViewModel diff --git a/ios/Buildings/Sources/BuildingViews/BuildingsTabView.swift b/ios/Buildings/Sources/BuildingViews/BuildingsTabView.swift index fe55477a..b5258129 100644 --- a/ios/Buildings/Sources/BuildingViews/BuildingsTabView.swift +++ b/ios/Buildings/Sources/BuildingViews/BuildingsTabView.swift @@ -19,14 +19,12 @@ public struct BuildingsTabView public init( path: Binding, - viewModel: BuildingViewModel, selectedView: Binding, _ roomsDestinationBuilderView: @escaping (Building) -> BuildingDestination, _ roomDestinationBuilderView: @escaping (Room) -> RoomDestination) { _path = path _selectedView = selectedView - self.viewModel = viewModel self.roomsDestinationBuilderView = roomsDestinationBuilderView self.roomDestinationBuilderView = roomDestinationBuilderView } @@ -35,58 +33,9 @@ public struct BuildingsTabView public var body: some View { NavigationStack(path: $path) { - buildingsView - .refreshable { - Task { - await viewModel.reloadBuildings() - } - } - .redacted(reason: viewModel.isLoading ? .placeholder : []) - .toolbar { - // Buttons on the right - ToolbarItemGroup(placement: .navigationBarTrailing) { - HStack { - Button { - viewModel.getBuildingsInOrder() - } label: { - Image(systemName: "arrow.up.arrow.down") - .resizable() - .frame(width: 25, height: 20) - } - - Button { - if selectedView == ViewOrientation.Card { - selectedView = ViewOrientation.List - } else { - selectedView = ViewOrientation.Card - } - } label: { - Image(systemName: selectedView == ViewOrientation.List ? "square.grid.2x2" : "list.bullet") - .resizable() - .frame(width: 22, height: 20) - } - } - .padding(5) - .foregroundStyle(theme.label.tertiary) - } - } - .navigationDestination(for: Building.self) { building in - roomsDestinationBuilderView(building) - .navigationTitle(building.name) - .navigationBarTitleDisplayMode(.inline) - } - .navigationDestination(for: Room.self) { room in // Renders the view for displaying a room that has been clicked on - roomDestinationBuilderView(room) - } - .onAppear { - if !viewModel.hasLoaded { - viewModel.onAppear() - } - } - .navigationTitle("Buildings") - .searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "K17") + mainContent } - .alert(item: $viewModel.loadBuildingErrorMessage) { error in + .alert(item: loadBuildingErrorBinding) { error in Alert( title: Text(error.title), message: Text(error.message), @@ -100,7 +49,6 @@ public struct BuildingsTabView // MARK: Internal - @State var viewModel: BuildingViewModel @Binding var path: NavigationPath @State var rowHeight: CGFloat? @Binding var selectedView: ViewOrientation @@ -119,15 +67,13 @@ public struct BuildingsTabView cardWidth: $cardWidth, building: building, buildings: buildings, - isLoading: viewModel.isLoading, + isLoading: buildingViewModel.isLoading, imageProvider: { roomID in BuildingImage[roomID] }) } } .padding(.horizontal, 16) - // .listRowSeparator(.hidden) - // .listRowBackground(Color.clear) } header: { HStack { Text(campus) @@ -152,7 +98,7 @@ public struct BuildingsTabView rowHeight: $rowHeight, building: building, buildings: buildings, - isLoading: viewModel.isLoading, + isLoading: buildingViewModel.isLoading, imageProvider: { buildingID in BuildingImage[buildingID] }) @@ -168,6 +114,8 @@ public struct BuildingsTabView // MARK: Private + @Environment(LiveBuildingViewModel.self) private var buildingViewModel + @Environment(Theme.self) private var theme private let columns = [ @@ -175,29 +123,97 @@ public struct BuildingsTabView GridItem(.flexible()), ] + private var searchTextBinding: Binding { + Binding( + get: { buildingViewModel.searchText }, + set: { buildingViewModel.searchText = $0 }) + } + + private var loadBuildingErrorBinding: Binding { + Binding( + get: { buildingViewModel.loadBuildingErrorMessage }, + set: { buildingViewModel.loadBuildingErrorMessage = $0 }) + } + + @ViewBuilder + private var mainContent: some View { + buildingsView + .refreshable { + Task { + buildingViewModel.reloadBuildings() + } + } + .redacted(reason: buildingViewModel.isLoading ? .placeholder : []) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + toolbarButtons + } + } + .navigationDestination(for: Building.self) { building in + roomsDestinationBuilderView(building) + .navigationTitle(building.name) + .navigationBarTitleDisplayMode(.inline) + } + .navigationDestination(for: Room.self) { room in + roomDestinationBuilderView(room) + } + .task { + if !buildingViewModel.hasLoaded { + buildingViewModel.onAppear() + } + } + .navigationTitle("Buildings") + .searchable(text: searchTextBinding, placement: .navigationBarDrawer(displayMode: .always), prompt: "K17") + } + @ViewBuilder private var buildingsView: some View { if selectedView == ViewOrientation.List { List { - buildingsListSegment(for: "Upper campus", from: viewModel.displayedBuildings.upper) - buildingsListSegment(for: "Middle campus", from: viewModel.displayedBuildings.middle) - buildingsListSegment(for: "Lower campus", from: viewModel.displayedBuildings.lower) + buildingsListSegment(for: "Upper campus", from: buildingViewModel.displayedBuildings.upper) + buildingsListSegment(for: "Middle campus", from: buildingViewModel.displayedBuildings.middle) + buildingsListSegment(for: "Lower campus", from: buildingViewModel.displayedBuildings.lower) } .listRowInsets(EdgeInsets()) .scrollContentBackground(.hidden) .background(Color.gray.opacity(0.1)) } else { ScrollView { - buildingsCardSegment(for: "Upper campus", from: viewModel.displayedBuildings.upper) - buildingsCardSegment(for: "Middle campus", from: viewModel.displayedBuildings.middle) - buildingsCardSegment(for: "Lower campus", from: viewModel.displayedBuildings.lower) + buildingsCardSegment(for: "Upper campus", from: buildingViewModel.displayedBuildings.upper) + buildingsCardSegment(for: "Middle campus", from: buildingViewModel.displayedBuildings.middle) + buildingsCardSegment(for: "Lower campus", from: buildingViewModel.displayedBuildings.lower) } - // .padding(.horizontal) .background(Color.gray.opacity(0.1)) .shadow(color: theme.label.primary.opacity(0.2), radius: 5) } } + private var toolbarButtons: some View { + HStack { + Button { + buildingViewModel.getBuildingsInOrder() + } label: { + Image(systemName: "arrow.up.arrow.down") + .resizable() + .frame(width: 25, height: 20) + } + + Button { + if selectedView == ViewOrientation.Card { + selectedView = ViewOrientation.List + } else { + selectedView = ViewOrientation.Card + } + } label: { + Image(systemName: selectedView == ViewOrientation.List ? "square.grid.2x2" : "list.bullet") + .resizable() + .frame(width: 22, height: 20) + } + } + .padding(5) + .foregroundStyle(theme.label.tertiary) + } + } // MARK: - PreviewWrapper @@ -207,15 +223,16 @@ struct PreviewWrapper: View { @State var selectedView = ViewOrientation.List var body: some View { + let viewModel: LiveBuildingViewModel = PreviewBuildingViewModel() BuildingsTabView( path: $path, - viewModel: PreviewBuildingViewModel(), selectedView: $selectedView) { _ in EmptyView() // Buildings destination } _: { _ in EmptyView() // Rooms destination } + .environment(viewModel) .defaultTheme() } } diff --git a/ios/CommonUI/Package.swift b/ios/CommonUI/Package.swift index efa3c0a0..5ef765ee 100644 --- a/ios/CommonUI/Package.swift +++ b/ios/CommonUI/Package.swift @@ -22,8 +22,10 @@ let package = Package( .target( name: "CommonUI", dependencies: [ - .product(name: "RoomModels", package: "Rooms"), .product(name: "BuildingModels", package: "Buildings"), +// .product(name: "BuildingViewModels", package: "Buildings"), + .product(name: "RoomModels", package: "Rooms"), + .product(name: "RoomViewModels", package: "Rooms"), ], resources: [.process("Resources")], swiftSettings: .defaultSettings), diff --git a/ios/CommonUI/Sources/CommonUI/AvailabilityStatusColours.swift b/ios/CommonUI/Sources/CommonUI/AvailabilityStatusColours.swift index 6c83b9af..f24884b5 100644 --- a/ios/CommonUI/Sources/CommonUI/AvailabilityStatusColours.swift +++ b/ios/CommonUI/Sources/CommonUI/AvailabilityStatusColours.swift @@ -37,4 +37,33 @@ extension Room { Theme.light.list.grayBackground.opacity(0.20) } } + + /// Status text color derived from bookings when a custom filter is active. + /// Falls back to the live `statusTextColour` when inactive or bookings are unavailable. + public func contextualStatusTextColour( + referenceInstant: Date, + isCustomFilterActive: Bool, + bookings: [RoomBooking]?) + -> Color + { + if let isFree = isFreeFromBookings(at: referenceInstant, isCustomFilterActive: isCustomFilterActive, bookings: bookings) { + return isFree ? Theme.light.list.green : Theme.light.list.red + } + return statusTextColour + } + + /// Status background color derived from bookings when a custom filter is active. + public func contextualStatusBackgroundColor( + referenceInstant: Date, + isCustomFilterActive: Bool, + bookings: [RoomBooking]?) + -> Color + { + if let isFree = isFreeFromBookings(at: referenceInstant, isCustomFilterActive: isCustomFilterActive, bookings: bookings) { + return isFree + ? Theme.light.list.greenBackground.opacity(0.15) + : Theme.light.list.redBackground.opacity(0.54) + } + return statusBackgroundColor + } } diff --git a/ios/CommonUI/Sources/CommonUI/Button/ClearButton.swift b/ios/CommonUI/Sources/CommonUI/Button/ClearButton.swift new file mode 100644 index 00000000..125c9fb8 --- /dev/null +++ b/ios/CommonUI/Sources/CommonUI/Button/ClearButton.swift @@ -0,0 +1,48 @@ +// +// SwiftUIView.swift +// CommonUI +// +// Created by Yanlin Li on 4/4/2026. +// + +import SwiftUI + +public struct ClearButton: View { + + // MARK: Lifecycle + + public init(filterName: String, clearFilter: @escaping () -> Void, onSelect: @escaping () -> Void) { + self.filterName = filterName + self.clearFilter = clearFilter + self.onSelect = onSelect + } + + // MARK: Public + + public var body: some View { + Button(role: .destructive) { + clearFilter() + onSelect() + } label: { + Text("Clear \(filterName)") + .font(.body) + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .frame(height: 35) + } + .buttonStyle(.bordered) + .tint(.red) + .buttonBorderShape(.roundedRectangle(radius: 20)) + } + + // MARK: Internal + + let clearFilter: () -> Void + let onSelect: () -> Void + let filterName: String +} + +#Preview { + ClearButton(filterName: "Duration", clearFilter: { }, onSelect: { }) + .defaultTheme() +} diff --git a/ios/CommonUI/Sources/CommonUI/Button/SelectButton.swift b/ios/CommonUI/Sources/CommonUI/Button/SelectButton.swift new file mode 100644 index 00000000..f7c114d9 --- /dev/null +++ b/ios/CommonUI/Sources/CommonUI/Button/SelectButton.swift @@ -0,0 +1,46 @@ +// +// SelectButton.swift +// CommonUI +// +// Created by Yanlin Li on 21/11/2025. +// + +import SwiftUI + +public struct SelectButton: View { + + // MARK: Lifecycle + + public init(onSelect: @escaping () -> Void) { + self.onSelect = onSelect + } + + // MARK: Public + + public var body: some View { + Button { + onSelect() + } label: { + Text("Select") + .font(.headline) + .frame(maxWidth: .infinity) + .frame(height: 35) + } + .buttonStyle(.borderedProminent) + .tint(theme.accent.primary) + .buttonBorderShape(.roundedRectangle(radius: 20)) + } + + // MARK: Internal + + let onSelect: () -> Void + + // MARK: Private + + @Environment(Theme.self) private var theme +} + +#Preview { + SelectButton { } + .defaultTheme() +} diff --git a/ios/CommonUI/Sources/CommonUI/FilteringViews/CampusLocationFilterView.swift b/ios/CommonUI/Sources/CommonUI/FilteringViews/CampusLocationFilterView.swift new file mode 100644 index 00000000..31033d3e --- /dev/null +++ b/ios/CommonUI/Sources/CommonUI/FilteringViews/CampusLocationFilterView.swift @@ -0,0 +1,112 @@ +// +// CampusLocationFilterView.swift +// Rooms +// +// Created by Muqueet Mohsen Chowdhury on 13/10/2025. +// + +import RoomModels +import RoomViewModels +import SwiftUI + +// MARK: - CampusLocationFilterView + +public struct CampusLocationFilterView: View { + + // MARK: Lifecycle + + public init( + selectedCampusLocation: Binding, + onSelect: @escaping () -> Void) + { + _selectedCampusLocation = selectedCampusLocation + self.onSelect = onSelect + } + + // MARK: Public + + public var body: some View { + VStack(spacing: 15) { + // Title + Text("Campus Location") + .font(.title2) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + // Campus location options + VStack(spacing: 12) { + ForEach(CampusLocation.allCases) { location in + CampusLocationButton( + location: location, + isSelected: selectedCampusLocation == location) + { + selectCampusLocation(location) + } + } + } + + ClearButton(filterName: "Campus", clearFilter: roomViewModel.clearCampusLocationFilter, onSelect: onSelect) + + SelectButton(onSelect: onSelect) + } + .padding(.horizontal, 20) + .padding(.top, FilterSheetLayout.contentTopPadding) + .padding(.bottom, FilterSheetLayout.contentBottomPadding) + .cornerRadius(20, corners: [.topLeft, .topRight]) + } + + // MARK: Private + + @Binding private var selectedCampusLocation: CampusLocation? + + @Environment(LiveRoomViewModel.self) private var roomViewModel + @Environment(Theme.self) private var theme + + private let onSelect: () -> Void + + private func selectCampusLocation(_ location: CampusLocation) { + selectedCampusLocation = location + } + +} + +// MARK: - CampusLocationButton + +private struct CampusLocationButton: View { + + // MARK: Internal + + let location: CampusLocation + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(location.displayName) + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(isSelected ? theme.accent.primary.opacity(0.1) : Color.gray.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.orange : Color.clear, lineWidth: 1)) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + } + + // MARK: Private + + @Environment(Theme.self) private var theme +} + +// MARK: - Preview + +#Preview { + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return CampusLocationFilterView(selectedCampusLocation: .constant(.lower), onSelect: { }) + .defaultTheme() + .environment(viewModel) +} diff --git a/ios/CommonUI/Sources/CommonUI/FilteringViews/CapacityFilterView.swift b/ios/CommonUI/Sources/CommonUI/FilteringViews/CapacityFilterView.swift new file mode 100644 index 00000000..6b2ae01f --- /dev/null +++ b/ios/CommonUI/Sources/CommonUI/FilteringViews/CapacityFilterView.swift @@ -0,0 +1,112 @@ +// +// CapacityFilterView.swift +// Rooms +// +// Created by Muqueet Mohsen Chowdhury on 13/10/2025. +// + +import RoomViewModels +import SwiftUI + +// MARK: - CapacityFilterView + +public struct CapacityFilterView: View { + + // MARK: Lifecycle + + public init( + selectedCapacity: Binding, + onSelect: @escaping () -> Void) + { + _selectedCapacity = selectedCapacity + self.onSelect = onSelect + } + + // MARK: Public + + public var body: some View { + VStack(spacing: 15) { + // Title + Text("Capacity") + .font(.title2) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + // Capacity input + VStack(alignment: .leading, spacing: 16) { + Text("Minimum capacity") + .font(.body) + .foregroundColor(.secondary) + + // Quick picks so users don't need to type a value. + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 2), + spacing: 12) + { + ForEach(Self.quickCapacityOptions, id: \.self) { option in + capacityPresetButton(for: option) + } + } + + HStack { + TextField("Enter capacity", value: $selectedCapacity, format: .number) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .keyboardType(.numberPad) + + Text("people") + .font(.body) + .foregroundColor(.secondary) + } + } + + ClearButton(filterName: "Minimum Capacity", clearFilter: roomViewModel.clearCapacityFilter, onSelect: onSelect) + + SelectButton(onSelect: onSelect) + } + .padding(.horizontal, 20) + .padding(.top, FilterSheetLayout.contentTopPadding) + .padding(.bottom, FilterSheetLayout.contentBottomPadding) + } + + // MARK: Private + + private static let quickCapacityOptions: [Int] = [10, 25, 50, 100] + + @Binding private var selectedCapacity: Int? + @Environment(LiveRoomViewModel.self) private var roomViewModel + @Environment(Theme.self) private var theme + + private let onSelect: () -> Void + + @ViewBuilder + private func capacityPresetButton(for option: Int) -> some View { + Button { + selectedCapacity = option + onSelect() + } label: { + Text("\(option)") + .font(.body) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(selectedCapacity == option + ? theme.accent.primary.opacity(0.25) + : Color.gray.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(selectedCapacity == option ? theme.accent.primary : Color.clear, lineWidth: 1)) + .cornerRadius(10) + } + .buttonStyle(.plain) + } + +} + +// MARK: - Preview + +#Preview { + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return CapacityFilterView(selectedCapacity: .constant(50), onSelect: { }) + .defaultTheme() + .environment(viewModel) +} diff --git a/ios/CommonUI/Sources/CommonUI/FilteringViews/DateFilterView.swift b/ios/CommonUI/Sources/CommonUI/FilteringViews/DateFilterView.swift new file mode 100644 index 00000000..a81d0671 --- /dev/null +++ b/ios/CommonUI/Sources/CommonUI/FilteringViews/DateFilterView.swift @@ -0,0 +1,108 @@ +// +// DateFilterView.swift +// Rooms +// +// Created by Muqueet Mohsen Chowdhury on 13/10/2025. +// + +import RoomViewModels +import SwiftUI + +// MARK: - DateFilterView + +public struct DateFilterView: View { + + // MARK: Lifecycle + + public init( + selectedDate: Binding, + onSelect: @escaping () -> Void) + { + _selectedDate = selectedDate + self.onSelect = onSelect + } + + // MARK: Public + + public var body: some View { + VStack(spacing: 15) { + // Title + Text("Date") + .font(.title2) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + // Calendar + DatePicker( + "Select a date", + selection: $selectedDate, + displayedComponents: .date) + .datePickerStyle(.graphical) + .tint(theme.accent.primary) + + // Divider + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 2) + + // Time selection + HStack { + Text("Start") + .font(.headline) + .bold() + + Spacer() + + DatePicker("Please enter a time", selection: $selectedDate, displayedComponents: .hourAndMinute) + .labelsHidden() + } + + ClearButton(filterName: "Date & Time", clearFilter: roomViewModel.clearDateFilter, onSelect: onSelect) + + SelectButton(onSelect: onSelect) + } + .padding(.horizontal, 20) + .padding(.top, FilterSheetLayout.contentTopPadding) + .padding(.bottom, FilterSheetLayout.contentBottomPadding) + } + + // MARK: Private + + @Binding private var selectedDate: Date + @Environment(LiveRoomViewModel.self) private var roomViewModel + @Environment(Theme.self) private var theme + + private let onSelect: () -> Void +} + +// MARK: - View Extensions + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} + +// MARK: - RoundedCorner + +struct RoundedCorner: Shape { + var radius = CGFloat.infinity + var corners = UIRectCorner.allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +// MARK: - Preview + +#Preview { + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return DateFilterView(selectedDate: .constant(Date()), onSelect: { }) + .defaultTheme() + .environment(viewModel) +} diff --git a/ios/CommonUI/Sources/CommonUI/FilteringViews/DurationFilterView.swift b/ios/CommonUI/Sources/CommonUI/FilteringViews/DurationFilterView.swift new file mode 100644 index 00000000..0b0e6dbc --- /dev/null +++ b/ios/CommonUI/Sources/CommonUI/FilteringViews/DurationFilterView.swift @@ -0,0 +1,67 @@ +// +// DurationFilterView.swift +// CommonUI +// +// Created by Yanlin on 8/11/2025. +// + +import RoomModels +import RoomViewModels +import SwiftUI + +// MARK: - DurationFilterView + +public struct DurationFilterView: View { + + // MARK: Lifecycle + + public init(onSelect: @escaping () -> Void) { + self.onSelect = onSelect + } + + // MARK: Public + + public var body: some View { + VStack(spacing: 15) { + Text("Duration") + .font(.title2) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + Picker("Duration", selection: $selectedDuration) { + ForEach(Duration.allCases, id: \.self) { duration in + Text(duration.displayName) + } + } + .pickerStyle(.segmented) + .frame(height: 44 * 1.4) + + ClearButton(filterName: "Duration", clearFilter: roomViewModel.clearDurationFilter, onSelect: onSelect) + + SelectButton(onSelect: onSelect) + } + .padding(.horizontal, 20) + .padding(.top, FilterSheetLayout.contentTopPadding) + .padding(.bottom, FilterSheetLayout.contentBottomPadding) + .onAppear { + // Initialize with current value or default + selectedDuration = roomViewModel.selectedDuration ?? .oneHour + } + } + + // MARK: Private + + @Environment(LiveRoomViewModel.self) private var roomViewModel + @State private var selectedDuration = Duration.oneHour + + private let onSelect: () -> Void +} + +// MARK: - Preview + +#Preview { + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return DurationFilterView(onSelect: { }) + .defaultTheme() + .environment(viewModel) +} diff --git a/ios/CommonUI/Sources/CommonUI/FilteringViews/FilterSheetLayout.swift b/ios/CommonUI/Sources/CommonUI/FilteringViews/FilterSheetLayout.swift new file mode 100644 index 00000000..e015aeb6 --- /dev/null +++ b/ios/CommonUI/Sources/CommonUI/FilteringViews/FilterSheetLayout.swift @@ -0,0 +1,14 @@ +// +// FilterSheetLayout.swift +// CommonUI +// + +import SwiftUI + +public enum FilterSheetLayout { + /// Inset below the sheet grabber so titles are not clipped by the top corner radius. + public static let contentTopPadding: CGFloat = 44 + + /// Inset above the sheet bottom corner radius/border so bottom content isn't clipped/overlapped. + public static let contentBottomPadding: CGFloat = 24 +} diff --git a/ios/CommonUI/Sources/CommonUI/FilteringViews/RoomTypeFilterView.swift b/ios/CommonUI/Sources/CommonUI/FilteringViews/RoomTypeFilterView.swift new file mode 100644 index 00000000..0f798105 --- /dev/null +++ b/ios/CommonUI/Sources/CommonUI/FilteringViews/RoomTypeFilterView.swift @@ -0,0 +1,113 @@ +// +// RoomTypeFilterView.swift +// Rooms +// +// Created by Muqueet Mohsen Chowdhury on 13/10/2025. +// + +import RoomModels +import RoomViewModels +import SwiftUI + +// MARK: - RoomTypeFilterView + +public struct RoomTypeFilterView: View { + + // MARK: Lifecycle + + public init( + selectedRoomTypes: Binding>, + onSelect: @escaping () -> Void) + { + _selectedRoomTypes = selectedRoomTypes + self.onSelect = onSelect + } + + // MARK: Public + + public var body: some View { + VStack(spacing: 15) { + // Title + Text("Room Types") + .font(.title2) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + // Room type grid + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 2), spacing: 12) { + ForEach(RoomType.allCases) { roomType in + RoomTypeButton( + roomType: roomType, + isSelected: selectedRoomTypes.contains(roomType)) + { + toggleRoomType(roomType) + } + } + } + + ClearButton(filterName: "All Room Types", clearFilter: roomViewModel.clearRoomTypeFilter, onSelect: onSelect) + + SelectButton(onSelect: onSelect) + } + .padding(.horizontal, 20) + .padding(.top, FilterSheetLayout.contentTopPadding) + .padding(.bottom, FilterSheetLayout.contentBottomPadding) + } + + // MARK: Private + + @Binding private var selectedRoomTypes: Set + + @Environment(LiveRoomViewModel.self) private var roomViewModel + + private let onSelect: () -> Void + + private func toggleRoomType(_ roomType: RoomType) { + if selectedRoomTypes.contains(roomType) { + selectedRoomTypes.remove(roomType) + } else { + selectedRoomTypes.insert(roomType) + } + } +} + +// MARK: - RoomTypeButton + +private struct RoomTypeButton: View { + + // MARK: Internal + + let roomType: RoomType + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(roomType.displayName) + .font(.body) + .fontWeight(.medium) + .foregroundColor(isSelected ? .primary : .primary) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(isSelected ? theme.accent.primary.opacity(0.3) : Color.gray.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.gray.opacity(0.3) : Color.clear, lineWidth: 1)) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + } + + // MARK: Private + + @Environment(Theme.self) private var theme +} + +// MARK: - Preview + +#Preview { + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return RoomTypeFilterView(selectedRoomTypes: .constant([.computerLab]), onSelect: { }) + .defaultTheme() + .environment(viewModel) +} diff --git a/ios/CommonUI/Sources/CommonUI/GenericCardView.swift b/ios/CommonUI/Sources/CommonUI/GenericCardView.swift index ab4a227c..637f99b4 100644 --- a/ios/CommonUI/Sources/CommonUI/GenericCardView.swift +++ b/ios/CommonUI/Sources/CommonUI/GenericCardView.swift @@ -8,6 +8,7 @@ import BuildingModels import Combine import RoomModels +import RoomViewModels import SwiftUI // MARK: - GenericCardView @@ -157,6 +158,8 @@ private let columns = [ ] #Preview { - CardPreviewWrapper() + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return CardPreviewWrapper() .defaultTheme() + .environment(viewModel) } diff --git a/ios/CommonUI/Sources/CommonUI/GenericCardViewItem.swift b/ios/CommonUI/Sources/CommonUI/GenericCardViewItem.swift index 8398de31..ff296112 100644 --- a/ios/CommonUI/Sources/CommonUI/GenericCardViewItem.swift +++ b/ios/CommonUI/Sources/CommonUI/GenericCardViewItem.swift @@ -7,6 +7,7 @@ import BuildingModels import RoomModels +import RoomViewModels import SwiftUI // MARK: - WidthPreferenceKey @@ -88,15 +89,26 @@ public struct GenericCardViewItem Result<[Room], FetchRoomError> + roomBookings: [String: [RoomBooking]], + rooms: [Room], + now: Date = Date()) + -> [Room] { - switch await roomService.getRooms() { - case .success(let rooms): - var result = [Room]() - for room in rooms { - let currentTime = Date() - - // Sort classes by start time, then end time. - let classBookings: [RoomBooking] = - roomBookings[room.id] - ?? [] - .sorted { $0.start < $1.start } - .sorted { $0.end < $1.end } - - // Find the first class that *ends* after the current time - // Current time should be changing every 15 or 30 min now and then - let firstClassEndsAfterCurrentTime: RoomBooking? = classBookings.first { - $0.end >= currentTime - } - if firstClassEndsAfterCurrentTime == nil { - // class is free indefinitely meaning it is free the whole day from current time onwards - result.append(room) - continue - } - - /// Check if from current time to the next first class duration satisfy minDuration - let start = firstClassEndsAfterCurrentTime!.start - if currentTime < start { - let duration = start.timeIntervalSince(currentTime) // in seconds - let durationInMinutes = Int(duration / 60) - - if durationInMinutes >= minDuration { - result.append(room) - } - } - } - - return .success(result) - - case .failure(let error): - return .failure(error) - } + roomsFreeForMinimumDuration( + minDurationMinutes: minDuration, + now: now, + rooms: rooms, + bookingsForRoom: { room in roomBookings[room.id] ?? [] }) } public func getRoomsFilteredByAllBuildingId() async -> Result<[String: [Room]], FetchRoomError> { @@ -167,8 +134,73 @@ public class RoomInteractor { } } + /// - Parameter roomBookingsByRoomId: Bookings keyed by `Room.id` for duration filtering. Rooms with no entry are treated as having no known bookings (they pass the duration filter until loaded). + public func applyFilters(rooms: [Room], filter: RoomFilter, roomBookingsByRoomId: [String: [RoomBooking]]) -> [Room] { + var filteredRooms = rooms + + // Filter by room type (usage) + if !filter.selectedRoomTypes.isEmpty { + filteredRooms = filteredRooms.filter { room in + let roomType = RoomType(rawValue: room.usage) + return roomType.map { filter.selectedRoomTypes.contains($0) } ?? false + } + } + + // Filter by capacity + if let capacity = filter.selectedCapacity { + filteredRooms = filteredRooms.filter { $0.capacity >= capacity } + } + + // Filter by campus location + if let campusLocation = filter.selectedCampusLocation { + filteredRooms = filteredRooms.filter { room in + let gridReference = GridReference.fromBuildingID(buildingID: room.buildingId) + switch campusLocation { + case .upper: + return gridReference.campusSection == .upper + case .middle: + return gridReference.campusSection == .middle + case .lower: + return gridReference.campusSection == .lower + } + } + } + + // Date/time chosen in the filter sheet (when not default) is the start of the duration window; see `RoomFilter.filteringReferenceInstant`. + let referenceInstant = filter.filteringReferenceInstant() + + if let duration = filter.selectedDuration { + filteredRooms = getRoomsFilteredByDuration( + for: duration.rawValue, + roomBookings: roomBookingsByRoomId, + rooms: filteredRooms, + now: referenceInstant) + } + + return filteredRooms + } + // MARK: Private private let roomService: RoomService private let locationService: LocationService + + /// A booking blocks the room if it overlaps the interval from `now` for `minDurationMinutes`. + private func roomsFreeForMinimumDuration( + minDurationMinutes: Int, + now: Date, + rooms: [Room], + bookingsForRoom: (Room) -> [RoomBooking]) + -> [Room] + { + let windowEnd = now.addingTimeInterval(TimeInterval(minDurationMinutes * 60)) + return rooms.filter { room in + let bookings = bookingsForRoom(room) + let hasBlockingBooking = bookings.contains { booking in + booking.start < windowEnd && booking.end > now + } + return !hasBlockingBooking + } + } + } diff --git a/ios/Rooms/Sources/RoomModels/AlertError.swift b/ios/Rooms/Sources/RoomModels/AlertError.swift new file mode 100644 index 00000000..d992a8f3 --- /dev/null +++ b/ios/Rooms/Sources/RoomModels/AlertError.swift @@ -0,0 +1,19 @@ +// +// AlertError.swift +// RoomModels +// +// Created by Dicko Evaldo on 25/10/2025. +// +import Foundation + +nonisolated +public struct AlertError: Identifiable { + public let id = UUID() + public let title: String + public let message: String + + public init(title: String = "Error", message: String) { + self.title = title + self.message = message + } +} diff --git a/ios/Rooms/Sources/RoomModels/Room.swift b/ios/Rooms/Sources/RoomModels/Room.swift index 87167471..8dad2eaa 100644 --- a/ios/Rooms/Sources/RoomModels/Room.swift +++ b/ios/Rooms/Sources/RoomModels/Room.swift @@ -199,6 +199,32 @@ public struct Room: Equatable, Identifiable, Hashable { return splitID[2] } + /// When a custom date/time filter is active and bookings have been loaded, derive + /// the room's status from the booking schedule instead of the stale live `status`/`endTime`. + /// Falls back to `statusText` when the filter is inactive or bookings haven't loaded. + public func statusTextWhenFiltering( + referenceInstant: Date, + isCustomFilterActive: Bool, + bookings: [RoomBooking]? = nil) + -> String + { + guard isCustomFilterActive else { return statusText } + guard let bookings else { return statusText } + return Room.statusFromBookings(at: referenceInstant, bookings: bookings) + } + + /// Whether the room is free at `referenceInstant` based on its bookings. + /// Returns `nil` when bookings haven't been loaded or the filter is inactive. + public func isFreeFromBookings( + at referenceInstant: Date, + isCustomFilterActive: Bool, + bookings: [RoomBooking]?) + -> Bool? + { + guard isCustomFilterActive, let bookings else { return nil } + return !bookings.contains { $0.start <= referenceInstant && $0.end > referenceInstant } + } + // MARK: Private private static let timeFormatter: DateFormatter = { @@ -207,4 +233,16 @@ public struct Room: Equatable, Identifiable, Hashable { return formatter }() + // MARK: - Booking-derived status + + private static func statusFromBookings(at instant: Date, bookings: [RoomBooking]) -> String { + if let active = bookings.first(where: { $0.start <= instant && $0.end > instant }) { + return "Unavailable till \(timeFormatter.string(from: active.end))" + } + if let next = bookings.filter({ $0.start > instant }).min(by: { $0.start < $1.start }) { + return "Available till \(timeFormatter.string(from: next.start))" + } + return "Available till end of day" + } + } diff --git a/ios/Rooms/Sources/RoomModels/RoomFilter.swift b/ios/Rooms/Sources/RoomModels/RoomFilter.swift new file mode 100644 index 00000000..748acd2d --- /dev/null +++ b/ios/Rooms/Sources/RoomModels/RoomFilter.swift @@ -0,0 +1,137 @@ +// +// RoomFilter.swift +// Rooms +// +// Created by Muqueet Mohsen Chowdhury on 13/10/2025. +// + +import Foundation + +// MARK: - RoomFilter + +/// Represents the current filter state for rooms +public struct RoomFilter: Equatable { + + // MARK: Lifecycle + + public init( + selectedDate: Date = Date(), + selectedRoomTypes: Set = [], + selectedDuration: Duration? = nil, + selectedCampusLocation: CampusLocation? = nil, + selectedCapacity: Int? = nil) + { + self.selectedDate = selectedDate + self.selectedRoomTypes = selectedRoomTypes + self.selectedDuration = selectedDuration + self.selectedCampusLocation = selectedCampusLocation + self.selectedCapacity = selectedCapacity + } + + // MARK: Public + + public var selectedDate: Date + public var selectedRoomTypes: Set + public var selectedDuration: Duration? + public var selectedCampusLocation: CampusLocation? + public var selectedCapacity: Int? +} + +extension RoomFilter { + /// Start instant for duration overlap: the user’s chosen date/time if they changed the date filter from `DateDefaults.selectedDate`, otherwise `clockNow` (typically “right now”). + public func filteringReferenceInstant(clockNow: Date = Date()) -> Date { + selectedDate != DateDefaults.selectedDate ? selectedDate : clockNow + } +} + +// MARK: - RoomType + +/// Represents different types of rooms with their display names and usage codes +public enum RoomType: String, CaseIterable, Identifiable { + case auditorium = "AUD" + case computerLab = "CMLB" + case laboratory = "LAB" + case lectureHall = "LCTR" + case meetingRoom = "MEET" + case studio = "SDIO" + case tutorialRoom = "TUSM" + case libraryStudyRoom = "LIB" + + // MARK: Public + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .auditorium: + "Auditorium" + case .computerLab: + "Computer Lab" + case .laboratory: + "Laboratory" + case .lectureHall: + "Lecture Hall" + case .meetingRoom: + "Meeting Room" + case .studio: + "Studio" + case .tutorialRoom: + "Tutorial Room" + case .libraryStudyRoom: + "Library Study Room" + } + } +} + +// MARK: - Duration + +/// Represents available duration options for room filtering +public enum Duration: Int, CaseIterable, Identifiable { + case thirtyMinutes = 30 + case oneHour = 60 + case twoHours = 120 + case threeHours = 180 + + public var id: Int { rawValue } + + public var displayName: String { + switch self { + case .thirtyMinutes: + "30m" + case .oneHour: + "1h" + case .twoHours: + "2h" + case .threeHours: + "3h" + } + } +} + +// MARK: - CampusLocation + +/// Represents campus location sections +public enum CampusLocation: String, CaseIterable, Identifiable { + case upper + case middle + case lower + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .upper: + "Upper Campus" + case .middle: + "Middle Campus" + case .lower: + "Lower Campus" + } + } +} + +// MARK: - DateDefaults + +public enum DateDefaults { + public static var selectedDate = Date() +} diff --git a/ios/Rooms/Sources/RoomViewModels/RoomViewModel.swift b/ios/Rooms/Sources/RoomViewModels/RoomViewModel.swift index 1cf5311c..eb0c8ba9 100644 --- a/ios/Rooms/Sources/RoomViewModels/RoomViewModel.swift +++ b/ios/Rooms/Sources/RoomViewModels/RoomViewModel.swift @@ -6,7 +6,6 @@ // import BuildingModels -import CommonUI import Foundation import Location import Observation @@ -35,6 +34,13 @@ public protocol RoomViewModel { var getBookingsIsLoading: Bool { get } + // Filter properties + var selectedDate: Date { get set } + var selectedRoomTypes: Set { get set } + var selectedDuration: Duration? { get set } + var selectedCampusLocation: CampusLocation? { get set } + var selectedCapacity: Int? { get set } + var hasActiveFilters: Bool { get } var searchText: String { get set } var loadRoomErrorMessage: AlertError? { get set } @@ -60,6 +66,16 @@ public protocol RoomViewModel { func fetchRoomRating(roomID: String) async func clearRoomRating() + + func applyFilters() + func loadBookingsForFilteredRooms() async + func clearAllFilters() + + func clearDurationFilter() + func clearDateFilter() + func clearCampusLocationFilter() + func clearCapacityFilter() + func clearRoomTypeFilter() } // MARK: - LiveRoomViewModel @@ -82,10 +98,14 @@ public class LiveRoomViewModel: RoomViewModel { public var getRatingIsLoading = false + /// Bookings for the **last** room that `getRoomBookings(roomId:)` loaded (e.g. detail / list UI). public var currentRoomBookings = [RoomBooking]() public var currentRoomRating: RoomRating? + /// Cached bookings per `Room.id` for duration filtering. Rooms without an entry are not filtered out by duration until their bookings are loaded. + public var bookingsByRoomId = [String: [RoomBooking]]() + public var hasLoaded = false public var roomsByBuildingId = [String: [RoomModels.Room]]() @@ -98,13 +118,35 @@ public class LiveRoomViewModel: RoomViewModel { public var searchText = "" + public var selectedDate: Date = DateDefaults.selectedDate + public var selectedRoomTypes: Set = [] + public var selectedDuration: Duration? + public var selectedCampusLocation: CampusLocation? + public var selectedCapacity: Int? + + public var hasActiveFilters: Bool { + selectedDate != DateDefaults.selectedDate || + !selectedRoomTypes.isEmpty || + selectedDuration != nil || + selectedCampusLocation != nil || + selectedCapacity != nil + } + public var filteredRoomsByBuildingId: [String: [Room]] { var result = [String: [Room]]() - for (key, value) in roomsByBuildingId { - let sorted = interactor.getRoomsSortedAlphabetically( - rooms: value, + for (buildingId, rooms) in roomsByBuildingId { + var filteredRooms = rooms + if hasActiveFilters { + filteredRooms = interactor.applyFilters(rooms: rooms, filter: currentFilter, roomBookingsByRoomId: bookingsByRoomId) + } + let sortedRooms = interactor.getRoomsSortedAlphabetically( + rooms: filteredRooms, inAscendingOrder: roomsInAscendingOrder) - result[key] = interactor.filterRoomsByQueryString(sorted, by: searchText) + let searchedRooms = interactor.filterRoomsByQueryString(sortedRooms, by: searchText) + + if !searchedRooms.isEmpty { + result[buildingId] = searchedRooms + } } return result } @@ -210,6 +252,8 @@ public class LiveRoomViewModel: RoomViewModel { switch await interactor.getRoomBookings(roomID: roomId) { case .success(let bookings): currentRoomBookings = bookings + bookingsByRoomId[roomId] = bookings + case .failure(let error): loadRoomErrorMessage = AlertError(message: error.clientMessage) } @@ -239,9 +283,75 @@ public class LiveRoomViewModel: RoomViewModel { await loadRooms() } + public func applyFilters() { + // Trigger UI update by accessing filteredRoomsByBuildingId + _ = filteredRoomsByBuildingId + } + + public func loadBookingsForFilteredRooms() async { + let roomIds = roomsByBuildingId.values + .flatMap { $0 } + .map(\.id) + .filter { bookingsByRoomId[$0] == nil } + + guard !roomIds.isEmpty else { return } + + isLoading = true + for roomId in roomIds { + switch await interactor.getRoomBookings(roomID: roomId) { + case .success(let bookings): + bookingsByRoomId[roomId] = bookings + case .failure: + break + } + } + isLoading = false + } + + public func clearAllFilters() { + DateDefaults.selectedDate = Date() + selectedDate = DateDefaults.selectedDate + selectedRoomTypes.removeAll() + selectedDuration = nil + selectedCampusLocation = nil + selectedCapacity = nil + bookingsByRoomId = [:] + currentRoomBookings = [] + } + + public func clearDurationFilter() { + selectedDuration = nil + } + + public func clearDateFilter() { + DateDefaults.selectedDate = Date() + selectedDate = DateDefaults.selectedDate + } + + public func clearCampusLocationFilter() { + selectedCampusLocation = nil + } + + public func clearCapacityFilter() { + selectedCapacity = nil + } + + public func clearRoomTypeFilter() { + selectedRoomTypes.removeAll() + } + // MARK: Private private let interactor: RoomInteractor + + private var currentFilter: RoomFilter { + RoomFilter( + selectedDate: selectedDate, + selectedRoomTypes: selectedRoomTypes, + selectedDuration: selectedDuration, + selectedCampusLocation: selectedCampusLocation, + selectedCapacity: selectedCapacity) + } } // MARK: - PreviewRoomViewModel @@ -253,7 +363,5 @@ public class PreviewRoomViewModel: LiveRoomViewModel { super.init(interactor: RoomInteractor( roomService: PreviewRoomService(), locationService: LiveLocationService(locationManager: LiveLocationManager()))) - - currentRoomBookings = [RoomBooking.exampleOne, RoomBooking.exampleTwo, RoomBooking.exampleFour] } } diff --git a/ios/Rooms/Sources/RoomViews/FloatingFilterMenuView.swift b/ios/Rooms/Sources/RoomViews/FloatingFilterMenuView.swift new file mode 100644 index 00000000..8aa93d03 --- /dev/null +++ b/ios/Rooms/Sources/RoomViews/FloatingFilterMenuView.swift @@ -0,0 +1,103 @@ +// +// SwiftUIView.swift +// Rooms +// +// Created by Yanlin Li on 24/3/2026. +// + +import CommonUI +import RoomViewModels +import SwiftUI + +struct FloatingFilterMenuView: View { + + // MARK: Internal + + @Binding var showingDateFilter: Bool + @Binding var showingRoomTypeFilter: Bool + @Binding var showingDurationFilter: Bool + @Binding var showingCampusLocationFilter: Bool + @Binding var showingCapacityFilter: Bool + @Binding var showingFilterMenu: Bool + + var body: some View { + VStack(alignment: .trailing, spacing: 10) { + if showingFilterMenu { + filterMenuAction("Date", systemImage: "calendar") { + showingDateFilter = true + } + filterMenuAction("Duration", systemImage: "clock") { + showingDurationFilter = true + } + filterMenuAction("Room Type", systemImage: "square.grid.2x2") { + showingRoomTypeFilter = true + } + filterMenuAction("Campus", systemImage: "building.2") { + showingCampusLocationFilter = true + } + filterMenuAction("Capacity", systemImage: "person.2") { + showingCapacityFilter = true + } + filterMenuAction("Reset all", systemImage: "arrow.counterclockwise", role: .destructive) { + roomViewModel.clearAllFilters() + roomViewModel.applyFilters() + } + } + + Button { + withAnimation(.spring(duration: 0.25)) { + showingFilterMenu.toggle() + } + } label: { + Image(systemName: showingFilterMenu ? "xmark" : "line.3.horizontal.decrease") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(theme.accent.primary) + .frame(width: 56, height: 56) + .background(.regularMaterial) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.15), radius: 8, y: 3) + } + .accessibilityLabel(showingFilterMenu ? "Close filters" : "Open filters") + .accessibilityHint("Shows filter options near the tab bar") + } + .animation(.spring(duration: 0.25), value: showingFilterMenu) + } + + // MARK: Private + + @Environment(LiveRoomViewModel.self) private var roomViewModel + @Environment(Theme.self) private var theme + + private func filterMenuAction( + _ title: String, + systemImage: String, + role: ButtonRole? = nil, + action: @escaping () -> Void) + -> some View + { + Button { + action() + } label: { + Label(title, systemImage: systemImage) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(role == .destructive ? theme.list.red : theme.label.primary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.regularMaterial) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.12), radius: 6, y: 2) + } + .buttonStyle(.plain) + } + +} + +#Preview { + FloatingFilterMenuView( + showingDateFilter: .constant(false), + showingRoomTypeFilter: .constant(false), + showingDurationFilter: .constant(false), + showingCampusLocationFilter: .constant(false), + showingCapacityFilter: .constant(false), + showingFilterMenu: .constant(false)) +} diff --git a/ios/Rooms/Sources/RoomViews/RoomBookingsListView.swift b/ios/Rooms/Sources/RoomViews/RoomBookingsListView.swift index 2f2c7f0c..55a7216b 100644 --- a/ios/Rooms/Sources/RoomViews/RoomBookingsListView.swift +++ b/ios/Rooms/Sources/RoomViews/RoomBookingsListView.swift @@ -14,9 +14,8 @@ struct RoomBookingsListView: View { // MARK: Lifecycle - public init(room: Room, roomViewModel: RoomViewModel, dateSelect: Binding) { + public init(room: Room, dateSelect: Binding) { self.room = room - self.roomViewModel = roomViewModel _dateSelect = dateSelect } @@ -81,15 +80,17 @@ struct RoomBookingsListView: View { // MARK: Private + @Environment(LiveRoomViewModel.self) private var roomViewModel + private let room: Room - private var roomViewModel: RoomViewModel } #Preview { - RoomBookingsListView( + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return RoomBookingsListView( room: Room.exampleOne, - roomViewModel: PreviewRoomViewModel(), dateSelect: .constant(Date())) + .environment(viewModel) .defaultTheme() } diff --git a/ios/Rooms/Sources/RoomViews/RoomDetailsSheetView.swift b/ios/Rooms/Sources/RoomViews/RoomDetailsSheetView.swift index f79ad4e4..c8bf2984 100644 --- a/ios/Rooms/Sources/RoomViews/RoomDetailsSheetView.swift +++ b/ios/Rooms/Sources/RoomViews/RoomDetailsSheetView.swift @@ -14,22 +14,14 @@ struct RoomDetailsSheetView: View { // MARK: Lifecycle - public init(dateSelect: Date = Date(), room: Room, roomViewModel: RoomViewModel, onDismiss: (() -> Void)? = nil) { - self.dateSelect = dateSelect + public init(room: Room) { self.room = room - self.roomViewModel = roomViewModel - self.onDismiss = onDismiss } // MARK: Internal - @State var dateSelect = Date() - - let room: Room - var body: some View { VStack(alignment: .leading, spacing: 10) { - // List { // Booking informations RoomBookingInformationView(room: room, currentRoomRating: roomViewModel.currentRoomRating) @@ -42,7 +34,7 @@ struct RoomDetailsSheetView: View { Spacer() - DatePicker("Please Select a Date", selection: $dateSelect, displayedComponents: .date) + DatePicker("Please Select a Date", selection: selectedDateBinding, displayedComponents: .date) .labelsHidden() .tint(theme.accent.primary) } @@ -51,8 +43,7 @@ struct RoomDetailsSheetView: View { ScrollView { RoomBookingsListView( room: room, - roomViewModel: roomViewModel, - dateSelect: $dateSelect) + dateSelect: selectedDateBinding) } } .padding() @@ -71,26 +62,27 @@ struct RoomDetailsSheetView: View { .task { await roomViewModel.fetchRoomRating(roomID: room.id) } - .gesture( - DragGesture(minimumDistance: 20, coordinateSpace: .local) - .onEnded { value in - if value.translation.width > 50, value.translation.width > abs(value.translation.height) { - onDismiss?() - } - }) } // MARK: Private @Environment(Theme.self) private var theme + @Environment(LiveRoomViewModel.self) private var roomViewModel - private let onDismiss: (() -> Void)? - - private var roomViewModel: RoomViewModel + private let room: Room + private var selectedDateBinding: Binding { + Binding( + get: { roomViewModel.selectedDate }, + set: { newValue in + roomViewModel.selectedDate = newValue + }) + } } #Preview { - RoomDetailsSheetView(room: Room.exampleOne, roomViewModel: PreviewRoomViewModel()) + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return RoomDetailsSheetView(room: Room.exampleOne) + .environment(viewModel) .defaultTheme() } diff --git a/ios/Rooms/Sources/RoomViews/RoomDetailsView.swift b/ios/Rooms/Sources/RoomViews/RoomDetailsView.swift index cdc81117..008f068d 100644 --- a/ios/Rooms/Sources/RoomViews/RoomDetailsView.swift +++ b/ios/Rooms/Sources/RoomViews/RoomDetailsView.swift @@ -16,9 +16,8 @@ public struct RoomDetailsView: View { // MARK: Lifecycle - public init(room: Room, roomViewModel: RoomViewModel) { + public init(room: Room) { self.room = room - self.roomViewModel = roomViewModel } // MARK: Public @@ -34,14 +33,12 @@ public struct RoomDetailsView: View { Spacer() } .sheet(isPresented: $showDetails) { - RoomDetailsSheetView(room: room, roomViewModel: roomViewModel) { - showDetails = false - dismiss() - } - .presentationDetents([.fraction(0.65), .fraction(0.75), .large], selection: $detent) - .presentationBackgroundInteraction(.enabled) - .presentationCornerRadius(30) - .interactiveDismissDisabled() + RoomDetailsSheetView(room: room) + .environment(roomViewModel) + .presentationDetents([.fraction(0.65), .fraction(0.75), .large], selection: $detent) + .presentationBackgroundInteraction(.enabled(upThrough: .large)) + .presentationCornerRadius(30) + .interactiveDismissDisabled(true) } .background( NavigationPopObserver { @@ -93,23 +90,22 @@ public struct RoomDetailsView: View { // MARK: Internal - @Environment(\.dismiss) var dismiss - // MARK: Private + @Environment(\.dismiss) private var dismiss @Environment(Theme.self) private var theme + @Environment(LiveRoomViewModel.self) private var roomViewModel @State private var detent = PresentationDetent.fraction(0.75) @State private var showDetails = true private let screenHeight = UIScreen.main.bounds.height private let room: Room - private var roomViewModel: RoomViewModel } #Preview { NavigationStack { - RoomDetailsView(room: Room.exampleOne, roomViewModel: PreviewRoomViewModel()) + RoomDetailsView(room: Room.exampleOne) .defaultTheme() } } diff --git a/ios/Rooms/Sources/RoomViews/RoomsListView.swift b/ios/Rooms/Sources/RoomViews/RoomsListView.swift index a5803e51..359dc14a 100644 --- a/ios/Rooms/Sources/RoomViews/RoomsListView.swift +++ b/ios/Rooms/Sources/RoomViews/RoomsListView.swift @@ -17,12 +17,10 @@ public struct RoomsListView: View { // MARK: Lifecycle public init( - roomViewModel: RoomViewModel, building: Building, path: Binding, imageProvider: @escaping (String) -> CachedImage) { - self.roomViewModel = roomViewModel self.building = building _path = path self.imageProvider = imageProvider @@ -88,8 +86,8 @@ public struct RoomsListView: View { // MARK: Private @Environment(Theme.self) private var theme + @Environment(LiveRoomViewModel.self) private var roomViewModel - private var roomViewModel: RoomViewModel private var building: Building } @@ -100,12 +98,13 @@ private struct PreviewWrapper: View { @State var path = NavigationPath() var body: some View { - RoomsListView( - roomViewModel: PreviewRoomViewModel(), + let viewModel: LiveRoomViewModel = PreviewRoomViewModel() + return RoomsListView( building: Building(name: "AGSM", id: "K-B16", latitude: 0, longitude: 0, aliases: [], numberOfAvailableRooms: 1), path: $path, imageProvider: { RoomImage[$0] // This closure captures BuildingImage }) + .environment(viewModel) .defaultTheme() } } diff --git a/ios/Rooms/Sources/RoomViews/RoomsTabView.swift b/ios/Rooms/Sources/RoomViews/RoomsTabView.swift index f5b88f38..7fd150ad 100644 --- a/ios/Rooms/Sources/RoomViews/RoomsTabView.swift +++ b/ios/Rooms/Sources/RoomViews/RoomsTabView.swift @@ -1,5 +1,6 @@ // // RoomsTabView.swift +// RoomsTabView.swift // Buildings // // Created by Yanlin Li on 3/7/2025. @@ -21,15 +22,11 @@ public struct RoomsTabView: View { /// init some viewModel to depend on public init( path: Binding, - roomViewModel: RoomViewModel, - buildingViewModel: BuildingViewModel, selectedTab: Binding, selectedView: Binding, _ roomDestinationBuilderView: @escaping (Room) -> Destination) { _path = path - self.roomViewModel = roomViewModel - self.buildingViewModel = buildingViewModel _selectedTab = selectedTab _selectedView = selectedView self.roomDestinationBuilderView = roomDestinationBuilderView @@ -39,57 +36,7 @@ public struct RoomsTabView: View { public var body: some View { NavigationStack(path: $path) { - roomView - .refreshable { - Task { - await roomViewModel.reloadRooms() - } - } - .redacted(reason: roomViewModel.isLoading ? .placeholder : []) - .toolbar { - // Buttons on the right - ToolbarItemGroup(placement: .navigationBarTrailing) { - HStack { - Button { - roomViewModel.getRoomsInOrder() - } label: { - Image(systemName: "arrow.up.arrow.down") - .resizable() - .frame(width: 25, height: 20) - } - - Button { - if selectedView == ViewOrientation.Card { - selectedView = ViewOrientation.List - } else { - selectedView = ViewOrientation.Card - } - } label: { - Image(systemName: selectedView == ViewOrientation.List ? "square.grid.2x2" : "list.bullet") - .resizable() - .frame(width: 22, height: 20) - } - } - .padding(5) - .foregroundStyle(theme.label.tertiary) - } - } - .navigationDestination(for: Room.self) { room in - roomDestinationBuilderView(room) - } - .task { - if !roomViewModel.hasLoaded { - await roomViewModel.onAppear() - } - } - .alert(item: $roomViewModel.loadRoomErrorMessage) { error in - Alert( - title: Text(error.title), - message: Text(error.message), - dismissButton: .default(Text("OK"))) - } - .navigationTitle("Rooms") - .searchable(text: $roomViewModel.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "CSE") + mainContent } .tabItem { Label("Rooms", systemImage: selectedTab == "Rooms" ? "door.left.hand.open" : "door.left.hand.closed") @@ -99,19 +46,13 @@ public struct RoomsTabView: View { // MARK: Internal - @State var buildingViewModel: BuildingViewModel @Binding var selectedTab: String - @Binding var selectedView: ViewOrientation @State var cardWidth: CGFloat? @State var searchText = "" @Binding var path: NavigationPath @State var rowHeight: CGFloat? - @State var roomViewModel: RoomViewModel - - // search text is owned by the view model - func roomsCardView( _ buildings: [Building]) -> some View @@ -138,8 +79,6 @@ public struct RoomsTabView: View { } } .padding(.horizontal, 16) - // .listRowSeparator(.hidden) - // .listRowBackground(Color.clear) } header: { HStack { Text(buildingName) @@ -190,7 +129,17 @@ public struct RoomsTabView: View { // MARK: Private + // Filter sheet states + @State private var showingDateFilter = false + @State private var showingRoomTypeFilter = false + @State private var showingDurationFilter = false + @State private var showingCampusLocationFilter = false + @State private var showingCapacityFilter = false + @State private var showingFilterMenu = false + @Environment(Theme.self) private var theme + @Environment(LiveBuildingViewModel.self) private var buildingViewModel + @Environment(LiveRoomViewModel.self) private var roomViewModel private let columns = [ GridItem(.flexible()), @@ -199,6 +148,148 @@ public struct RoomsTabView: View { private let roomDestinationBuilderView: (Room) -> Destination + private var searchTextBinding: Binding { + Binding( + get: { roomViewModel.searchText }, + set: { roomViewModel.searchText = $0 }) + } + + private var selectedDateBinding: Binding { + Binding( + get: { roomViewModel.selectedDate }, + set: { roomViewModel.selectedDate = $0 }) + } + + private var selectedRoomTypesBinding: Binding> { + Binding( + get: { roomViewModel.selectedRoomTypes }, + set: { roomViewModel.selectedRoomTypes = $0 }) + } + + private var selectedCampusLocationBinding: Binding { + Binding( + get: { roomViewModel.selectedCampusLocation }, + set: { roomViewModel.selectedCampusLocation = $0 }) + } + + private var selectedCapacityBinding: Binding { + Binding( + get: { roomViewModel.selectedCapacity }, + set: { roomViewModel.selectedCapacity = $0 }) + } + + @ViewBuilder + private var mainContent: some View { + roomView + .refreshable { + Task { + await roomViewModel.reloadRooms() + } + } + .redacted(reason: roomViewModel.isLoading ? .placeholder : []) + .overlay { + if roomViewModel.isLoading { + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text(roomViewModel.isLoading ? "Loading rooms..." : "Applying filters...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) + } + } + .overlay(alignment: .bottomTrailing) { + FloatingFilterMenuView( + showingDateFilter: $showingDateFilter, + showingRoomTypeFilter: $showingRoomTypeFilter, + showingDurationFilter: $showingDurationFilter, + showingCampusLocationFilter: $showingCampusLocationFilter, + showingCapacityFilter: $showingCapacityFilter, + showingFilterMenu: $showingFilterMenu) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + .toolbar { + toolbarButtons + } + .background(Color.gray.opacity(0.1)) + .listRowInsets(EdgeInsets()) + .scrollContentBackground(.hidden) + .navigationDestination(for: Room.self) { room in + roomDestinationBuilderView(room) + } + .task { + if !roomViewModel.hasLoaded { + await roomViewModel.onAppear() + } + } + .alert(item: Binding( + get: { roomViewModel.loadRoomErrorMessage }, + set: { roomViewModel.loadRoomErrorMessage = $0 })) + { error in + Alert( + title: Text(error.title), + message: Text(error.message), + dismissButton: .default(Text("OK"))) + } + .navigationTitle("Rooms") + .searchable(text: searchTextBinding, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search...") + .sheet(isPresented: $showingDateFilter) { + DateFilterView(selectedDate: selectedDateBinding) { + showingDateFilter = false + roomViewModel.applyFilters() + let vm = roomViewModel + Task { await vm.loadBookingsForFilteredRooms() } + } + .environment(roomViewModel) + .presentationDetents([.fraction(0.8)]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + } + .sheet(isPresented: $showingRoomTypeFilter) { + RoomTypeFilterView(selectedRoomTypes: selectedRoomTypesBinding) { + showingRoomTypeFilter = false + roomViewModel.applyFilters() + } + .environment(roomViewModel) + .presentationDetents([.fraction(0.52)]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + } + .sheet(isPresented: $showingDurationFilter) { + DurationFilterView(onSelect: { + showingDurationFilter = false + roomViewModel.applyFilters() + }) + .environment(roomViewModel) + .presentationDetents([.fraction(0.32)]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + } + .sheet(isPresented: $showingCampusLocationFilter) { + CampusLocationFilterView(selectedCampusLocation: selectedCampusLocationBinding) { + showingCampusLocationFilter = false + roomViewModel.applyFilters() + } + .environment(roomViewModel) + .presentationDetents([.fraction(0.44)]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + } + .sheet(isPresented: $showingCapacityFilter) { + CapacityFilterView(selectedCapacity: selectedCapacityBinding) { + showingCapacityFilter = false + roomViewModel.applyFilters() + } + .environment(roomViewModel) + .presentationDetents([.fraction(0.47)]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(.systemBackground)) + } + } + @ViewBuilder private var roomView: some View { if selectedView == ViewOrientation.List { @@ -217,6 +308,31 @@ public struct RoomsTabView: View { } } + private var toolbarButtons: some View { + HStack { + Button { + roomViewModel.getRoomsInOrder() + } label: { + Image(systemName: "arrow.up.arrow.down") + .resizable() + .frame(width: 25, height: 20) + } + + Button { + if selectedView == ViewOrientation.Card { + selectedView = ViewOrientation.List + } else { + selectedView = ViewOrientation.Card + } + } label: { + Image(systemName: selectedView == ViewOrientation.List ? "square.grid.2x2" : "list.bullet") + .resizable() + .frame(width: 22, height: 20) + } + } + .padding(5) + .foregroundStyle(theme.label.tertiary) + } } // MARK: - PreviewWrapper @@ -228,8 +344,6 @@ private struct PreviewWrapper: View { var body: some View { RoomsTabView( path: $path, - roomViewModel: PreviewRoomViewModel(), - buildingViewModel: PreviewBuildingViewModel(), selectedTab: .constant("Rooms"), selectedView: $selectedView) { _ in diff --git a/ios/Rooms/Tests/RoomsTests/RoomInteractorTests.swift b/ios/Rooms/Tests/RoomsTests/RoomInteractorTests.swift index 755d5cbb..a6bcd4c4 100644 --- a/ios/Rooms/Tests/RoomsTests/RoomInteractorTests.swift +++ b/ios/Rooms/Tests/RoomsTests/RoomInteractorTests.swift @@ -247,99 +247,165 @@ enum RoomInteractorTests { @Test("Returns rooms filtered by minimum 30 minutes") func returnsRoomsFilteredBy30MinutesOrMore() async { // Given + let now = Date() let expectedRooms = createDifferentRooms() let roomBookings = createRoomBookingsFromStartToEnd( 1, - from: Date().addingTimeInterval(40 * 60), - to: Date().addingTimeInterval(60 * 60)) + from: now.addingTimeInterval(40 * 60), + to: now.addingTimeInterval(60 * 60)) let roomBookingsMapped: [String: [RoomBooking]] = ["K-G6-105": roomBookings] let sut = makeRoomSUT(expect: expectedRooms) // When - let result = await sut.getRoomsFilteredByDuration(for: 30, roomBookings: roomBookingsMapped) + let result = sut.getRoomsFilteredByDuration( + for: 30, + roomBookings: roomBookingsMapped, + rooms: expectedRooms, + now: now) // Then - switch result { - case .success(let actualResult): - #expect(actualResult == expectedRooms) - // swiftlint:disable:next no_direct_standard_out_logs - print("Booking loader result: \(actualResult)") - - case .failure: - Issue.record("Expected success, got failure") - } + #expect(result == expectedRooms) } @Test("Returns rooms filtered by minimum 30 minutes with one room booked during current time") func returnsRoomsFilteredBy30MinutesOrMorDuringCurrentTime() async { // Given + let now = Date() let expectedRooms = createDifferentRooms() let roomBookings = createRoomBookingsFromStartToEnd( 1, - from: Date(), - to: Date().addingTimeInterval(30 * 60)) + from: now, + to: now.addingTimeInterval(30 * 60)) let roomBookingsMapped: [String: [RoomBooking]] = ["K-G6-105": roomBookings] let sut = makeRoomSUT(expect: expectedRooms) // When - let result = await sut.getRoomsFilteredByDuration(for: 30, roomBookings: roomBookingsMapped) + let result = sut.getRoomsFilteredByDuration( + for: 30, + roomBookings: roomBookingsMapped, + rooms: expectedRooms, + now: now) // Then - switch result { - case .success(let actualResult): - #expect(actualResult == expectedRooms.filter { $0.id != "K-G6-105" }) - // swiftlint:disable:next no_direct_standard_out_logs - print("Booking loader result: \(actualResult)") - - case .failure: - Issue.record("Expected success, got failure") - } + #expect(result == expectedRooms.filter { $0.id != "K-G6-105" }) } @Test("Returns rooms filtered by minimum 1 hour") func returnsRoomsFilteredByOneHourOrMore() async { // Given + let now = Date() let expectedRooms = createDifferentRooms() let roomBookings = createRoomBookingsFromStartToEnd( 1, - from: Date().addingTimeInterval(70 * 60), - to: Date().addingTimeInterval(90 * 60)) + from: now.addingTimeInterval(70 * 60), + to: now.addingTimeInterval(90 * 60)) let roomBookingsMapped: [String: [RoomBooking]] = ["K-G6-105": roomBookings] let sut = makeRoomSUT(expect: expectedRooms) // When - let result = await sut.getRoomsFilteredByDuration(for: 60, roomBookings: roomBookingsMapped) + let result = sut.getRoomsFilteredByDuration( + for: 60, + roomBookings: roomBookingsMapped, + rooms: expectedRooms, + now: now) // Then - switch result { - case .success(let actualResult): - #expect(actualResult == expectedRooms) - case .failure: - Issue.record("Expected success, got failure") - } + #expect(result == expectedRooms) } @Test("Returns rooms filtered by minimum 2 hour") func returnsRoomsFilteredByTwoHoursOrMore() async { // Given + let now = Date() let expectedRooms = createDifferentRooms() let roomBookings = createRoomBookingsFromStartToEnd( 1, - from: Date().addingTimeInterval(130 * 60), - to: Date().addingTimeInterval(150 * 60)) + from: now.addingTimeInterval(130 * 60), + to: now.addingTimeInterval(150 * 60)) let roomBookingsMapped: [String: [RoomBooking]] = ["K-G6-105": roomBookings] let sut = makeRoomSUT(expect: expectedRooms) // When - let result = await sut.getRoomsFilteredByDuration(for: 120, roomBookings: roomBookingsMapped) + let result = sut.getRoomsFilteredByDuration( + for: 120, + roomBookings: roomBookingsMapped, + rooms: expectedRooms, + now: now) // Then - switch result { - case .success(let actualResult): - #expect(actualResult == expectedRooms) - case .failure: - Issue.record("Expected success, got failure") - } + #expect(result == expectedRooms) + } + + @Test("8am with 9am booking: 30 min duration keeps all rooms available") + func eightAmWithNineAmBookingThirtyMinIncludesAllRooms() async { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let eightAM = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15, hour: 8, minute: 0))! + let nineAM = calendar.date(byAdding: .hour, value: 1, to: eightAM)! + let tenAM = calendar.date(byAdding: .hour, value: 2, to: eightAM)! + + let expectedRooms = createDifferentRooms() + let roomBookings = createRoomBookingsFromStartToEnd(1, from: nineAM, to: tenAM) + let roomBookingsMapped: [String: [RoomBooking]] = ["K-G6-105": roomBookings] + let sut = makeRoomSUT(expect: expectedRooms) + + let result = sut.getRoomsFilteredByDuration( + for: 30, + roomBookings: roomBookingsMapped, + rooms: expectedRooms, + now: eightAM) + + #expect(result == expectedRooms) + } + + @Test("8am with 9am booking: 90 min duration excludes booked room") + func eightAmWithNineAmBookingNinetyMinExcludesBookedRoom() async { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let eightAM = calendar.date(from: DateComponents(year: 2026, month: 6, day: 15, hour: 8, minute: 0))! + let nineAM = calendar.date(byAdding: .hour, value: 1, to: eightAM)! + let tenAM = calendar.date(byAdding: .hour, value: 2, to: eightAM)! + + let expectedRooms = createDifferentRooms() + let roomBookings = createRoomBookingsFromStartToEnd(1, from: nineAM, to: tenAM) + let roomBookingsMapped: [String: [RoomBooking]] = ["K-G6-105": roomBookings] + let sut = makeRoomSUT(expect: expectedRooms) + + let result = sut.getRoomsFilteredByDuration( + for: 90, + roomBookings: roomBookingsMapped, + rooms: expectedRooms, + now: eightAM) + + #expect(result == expectedRooms.filter { $0.id != "K-G6-105" }) + } + } + + struct RoomFilterReferenceInstant { + @Test("filteringReferenceInstant uses selectedDate when it differs from DateDefaults") + func filteringReferenceInstantUsesCustomSelectedDate() { + let savedDefault = DateDefaults.selectedDate + defer { DateDefaults.selectedDate = savedDefault } + + let baseline = Date(timeIntervalSince1970: 1_700_000_000) + DateDefaults.selectedDate = baseline + let custom = baseline.addingTimeInterval(3_600) + let filter = RoomFilter(selectedDate: custom) + + #expect(filter.filteringReferenceInstant(clockNow: Date()) == custom) + } + + @Test("filteringReferenceInstant uses clock when selectedDate matches DateDefaults") + func filteringReferenceInstantUsesClockWhenDateIsDefault() { + let savedDefault = DateDefaults.selectedDate + defer { DateDefaults.selectedDate = savedDefault } + + let baseline = Date(timeIntervalSince1970: 1_700_000_000) + DateDefaults.selectedDate = baseline + let clock = baseline.addingTimeInterval(123) + let filter = RoomFilter(selectedDate: baseline) + + #expect(filter.filteringReferenceInstant(clockNow: clock) == clock) } } }