Files
Mauro 2d486ece33 Use iOS 26 Observation for Unit and Preview tests. (#5797)
* use observation in unit and preview tests instead of the async stream

* use a parsing approach that is independent of the sourcery annotation

* Fix observation deferred fulfillments not receiving the initial value

Observation's observe only emits transitions, not the value present at subscription time. Drop the leading initial value from the affected transitionValues sequences and assert it directly with #expect instead.

* improved docs
2026-06-30 14:01:08 +02:00

210 lines
7.8 KiB
Swift

//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct SpaceAddRoomsScreen: View {
@Bindable var context: SpaceAddRoomsScreenViewModel.Context
@State private var formWidth = CGFloat.zero
var showTopSection: Bool {
!context.viewState.selectedRooms.isEmpty
}
var body: some View {
Form {
Section {
EmptyView()
} header: {
VStack(alignment: .leading, spacing: 24) {
Text(L10n.screenSpaceAddRoomsRoomAccessDescription)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
if showTopSection {
selectedRoomsSection
.textCase(.none)
.frame(width: formWidth)
.padding(.bottom, -8)
}
}
.listRowInsets(EdgeInsets())
}
if !context.viewState.roomsSection.rooms.isEmpty {
roomsSection
}
}
.compoundList()
.navigationTitle(L10n.actionAddExistingRooms)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.searchController(query: $context.searchQuery, showsCancelButton: false)
.compoundSearchField()
.disableAutocorrection(true)
.onChange(of: context.searchQuery) { context.send(viewAction: .searchQueryChanged) }
.readWidth($formWidth)
}
@ScaledMetric private var selectedRoomCellWidth: CGFloat = 80
private var selectedRoomsSection: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(context.viewState.selectedRooms, id: \.id) { room in
SpaceAddRoomsScreenSelectedItem(room: room, mediaProvider: context.mediaProvider) {
context.send(viewAction: .toggleRoom(room))
}
.frame(width: selectedRoomCellWidth)
}
}
.padding(.horizontal, 16)
.scrollTargetLayout()
}
.scrollPosition(id: $context.selectedRoomsPosition, anchor: .trailing)
}
private var roomsSection: some View {
Section {
ForEach(context.viewState.roomsSection.rooms) { room in
SpaceAddRoomsListRow(room: room,
isSelected: context.viewState.selectedRooms.contains { $0.id == room.id },
context: context)
}
// Replace these with ScrollView's `scrollPosition` when dropping iOS 16.
} header: {
switch context.viewState.roomsSection.type {
case .searchResults:
emptyRectangle
.onAppear {
context.send(viewAction: .reachedTop)
}
case .suggestions:
if let sectionTitle = context.viewState.roomsSection.title {
Text(sectionTitle)
}
}
} footer: {
if context.viewState.roomsSection.type == .searchResults {
emptyRectangle
.onAppear {
context.send(viewAction: .reachedBottom)
}
}
}
}
/// The greedy size of Rectangle can create an issue with the navigation bar when the search is highlighted, so is best to use a fixed frame instead of hidden() or EmptyView()
private var emptyRectangle: some View {
Rectangle()
.frame(width: 0, height: 0)
.accessibilityHidden(true)
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
ToolbarButton(role: .cancel) {
context.send(viewAction: .cancel)
}
.accessibilityIdentifier(A11yIdentifiers.spaceAddRoomsScreen.cancel)
}
ToolbarItem(placement: .confirmationAction) {
ToolbarButton(role: .save) {
context.send(viewAction: .save)
}
.disabled(context.viewState.selectedRooms.isEmpty)
}
}
}
private struct SpaceAddRoomsListRow: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
let room: SpaceAddRoomsScreenRoom
let isSelected: Bool
let context: SpaceAddRoomsScreenViewModel.Context
var body: some View {
ListRow(label: .avatar(title: room.title,
description: room.description,
icon: avatar),
kind: .multiSelection(isSelected: isSelected) {
context.send(viewAction: .toggleRoom(room))
})
}
@ViewBuilder
var avatar: some View {
if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .spaceAddRooms),
mediaProvider: context.mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
}
}
// MARK: - Previews
@available(iOS 26.0, *)
struct SpaceAddRoomsScreen_Previews: PreviewProvider, TestablePreview {
static let summaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))
static let viewModel = makeViewModel()
static let searchingViewModel = makeViewModel(searchQuery: "Foundation")
static let selectedViewModel = makeViewModel(searchQuery: "Foundation", hasSelection: true)
static var previews: some View {
ElementNavigationStack {
SpaceAddRoomsScreen(context: viewModel.context)
}
.previewDisplayName("Suggested")
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.roomsSection).map {
$0.type == .suggestions && !$0.rooms.isEmpty
})
ElementNavigationStack {
SpaceAddRoomsScreen(context: searchingViewModel.context)
}
.previewDisplayName("Searching")
ElementNavigationStack {
SpaceAddRoomsScreen(context: selectedViewModel.context)
}
.previewDisplayName("Selected")
}
static func makeViewModel(searchQuery: String? = nil, hasSelection: Bool = false) -> SpaceAddRoomsScreenViewModel {
let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceServiceRoom: SpaceServiceRoom.mock(isSpace: true)))
let clientProxy = ClientProxyMock(.init())
clientProxy.recentlyVisitedRoomsFilterReturnValue = .mockRooms
let viewModel = SpaceAddRoomsScreenViewModel(spaceRoomListProxy: spaceRoomListProxy,
userSession: UserSessionMock(.init(clientProxy: clientProxy)),
roomSummaryProvider: summaryProvider,
userIndicatorController: UserIndicatorControllerMock())
if let searchQuery {
viewModel.context.searchQuery = searchQuery
viewModel.context.send(viewAction: .searchQueryChanged)
}
if hasSelection {
viewModel.state.selectedRooms = Array(summaryProvider.roomListPublisher.value.prefix(2)).map(SpaceAddRoomsScreenRoom.init)
}
return viewModel
}
}