diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 12e9b9805..208f184d1 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -1055,6 +1055,7 @@ CEB8FB1269DE20536608B957 /* LoginMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41FABA2B0AEF4389986495 /* LoginMode.swift */; }; CF38B70D8C6DD42C00A56A27 /* LogViewerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */; }; CF4044A8EED5C41BC0ED6ABE /* SoftLogoutScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */; }; + CF638B8C6FDCE920AE061FAE /* StateStoreViewModelV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A106D76741914F82664959 /* StateStoreViewModelV2.swift */; }; CFEC53440C572CEEABC4A6A0 /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */; }; D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */; }; D02DEB36D32A72A1B365E452 /* SessionVerificationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */; }; @@ -1275,6 +1276,7 @@ FCD3F2B82CAB29A07887A127 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 2B43F2AF7456567FE37270A7 /* KeychainAccess */; }; FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; }; FD4C21F8DA1E273DE94FCD1A /* NotificationItemProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B927CF5EF7FCCDA5EDC474B /* NotificationItemProxyProtocol.swift */; }; + FD573B5D665824EB79EABF06 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5327E3B3C58BEB0E65F4CF98 /* Observable.swift */; }; FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */; }; FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */; }; FDC67E8C0EDCB00ABC66C859 /* landscape_test_video.mov in Resources */ = {isa = PBXBuildFile; fileRef = 78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */; }; @@ -1783,6 +1785,7 @@ 529513218340CC8419273165 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedString.swift; sourceTree = ""; }; 52F5EE5DE3B55D59299DB5BC /* AppLockSetupBiometricsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelTests.swift; sourceTree = ""; }; + 5327E3B3C58BEB0E65F4CF98 /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = ""; }; 53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewAdapter.swift; sourceTree = ""; }; 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineItem.swift; sourceTree = ""; }; 536C0E2178949B290776EA4E /* QRCodeLoginServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginServiceProtocol.swift; sourceTree = ""; }; @@ -1941,6 +1944,7 @@ 7893780A1FD6E3F38B3E9049 /* UserIndicatorControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerMock.swift; sourceTree = ""; }; 78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = landscape_test_video.mov; sourceTree = ""; }; 796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenCoordinator.swift; sourceTree = ""; }; + 79A106D76741914F82664959 /* StateStoreViewModelV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModelV2.swift; sourceTree = ""; }; 7A03E073077D92AA19C43DCF /* IdentityConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenCoordinator.swift; sourceTree = ""; }; 7AAD8C633AA57948B34EDCF7 /* RoomChangeRolesScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangeRolesScreenViewModelProtocol.swift; sourceTree = ""; }; 7AC0CD1CAFD3F8B057F9AEA5 /* ClientBuilderHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientBuilderHook.swift; sourceTree = ""; }; @@ -2868,6 +2872,7 @@ children = ( 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */, 6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */, + 79A106D76741914F82664959 /* StateStoreViewModelV2.swift */, ); path = ViewModel; sourceTree = ""; @@ -3681,6 +3686,7 @@ C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */, F72EFC8C634469F9262659C7 /* NSItemProvider.swift */, 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */, + 5327E3B3C58BEB0E65F4CF98 /* Observable.swift */, 62B07B296D7A9D2F09120853 /* OrderedSet.swift */, D26813CCE39221FE30BF22CD /* PlatformViewVersionPredicate.swift */, 077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */, @@ -7366,6 +7372,7 @@ 523C6800ED85D5810CF18C19 /* OIDCAccountSettingsPresenter.swift in Sources */, 9A4E3D5AA44B041DAC3A0D81 /* OIDCAuthenticationPresenter.swift in Sources */, E362924A42934C9F0F97A956 /* OIDCConfigurationProxy.swift in Sources */, + FD573B5D665824EB79EABF06 /* Observable.swift in Sources */, 11A6B8E3CBDBF0A4107FF4CE /* OnboardingFlowCoordinator.swift in Sources */, 3CE4C5071B6D2576E2473989 /* OrderedSet.swift in Sources */, AA5924D3B67F7ACD98BBEFDC /* OrientationManagerProtocol.swift in Sources */, @@ -7654,6 +7661,7 @@ CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */, 3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */, B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */, + CF638B8C6FDCE920AE061FAE /* StateStoreViewModelV2.swift in Sources */, B1069F361E604D5436AE9FFD /* StaticLocationScreen.swift in Sources */, 1AB3D8563AB12635250A6A6E /* StaticLocationScreenCoordinator.swift in Sources */, DFD5AA8688A34C72D48AF3B1 /* StaticLocationScreenViewModel.swift in Sources */, diff --git a/ElementX/Sources/Other/Extensions/AsyncSequence.swift b/ElementX/Sources/Other/Extensions/AsyncSequence.swift index 1a9173693..2555db379 100644 --- a/ElementX/Sources/Other/Extensions/AsyncSequence.swift +++ b/ElementX/Sources/Other/Extensions/AsyncSequence.swift @@ -9,4 +9,19 @@ extension AsyncSequence { func first() async rethrows -> Self.Element? { try await first { _ in true } } + + /// Type-erases the sequence into a newly constructed asynchronous stream. This is useful until + /// we drop support for iOS 17, at which point we can replace this with `any AsyncSequence`. + @available(iOS, deprecated: 18.0, message: "Use `any AsyncSequence` instead.") + func eraseToStream() -> AsyncStream { + var asyncIterator = makeAsyncIterator() + return AsyncStream { + do { + return try await asyncIterator.next() + } catch { + MXLog.warning("Stopping stream: \(error)") + return nil + } + } + } } diff --git a/ElementX/Sources/Other/Extensions/Observable.swift b/ElementX/Sources/Other/Extensions/Observable.swift new file mode 100644 index 000000000..248a09a02 --- /dev/null +++ b/ElementX/Sources/Other/Extensions/Observable.swift @@ -0,0 +1,37 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +extension Observable { + /// Creates an async stream for the specified property on this object. We probably won't need this once SE-0475 is available: + /// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0475-observed.md + /// + /// - Parameter property: The key path to the property you would like to observe. + func observe(_ property: KeyPath) -> AsyncStream { + AsyncStream { continuation in + var isActive = true + + @Sendable func observe() { + let value = withObservationTracking { + self[keyPath: property] + } onChange: { + // Dispatch the update as this is willSet not didSet. + DispatchQueue.main.async { + guard isActive else { return } + observe() + } + } + continuation.yield(value) + } + + continuation.onTermination = { _ in isActive = false } + + observe() + } + } +} diff --git a/ElementX/Sources/Other/Extensions/Snapshotting.swift b/ElementX/Sources/Other/Extensions/Snapshotting.swift index 5f106143d..220e93758 100644 --- a/ElementX/Sources/Other/Extensions/Snapshotting.swift +++ b/ElementX/Sources/Other/Extensions/Snapshotting.swift @@ -24,21 +24,26 @@ struct SnapshotPerceptualPrecisionPreferenceKey: PreferenceKey { } } -struct FulfillmentPublisherEquatableWrapper: Equatable { - let publisher: AnyPublisher? - - // Publisher equatability complicates things but, luckily, we're only interesting in them changing from nil - static func == (lhs: FulfillmentPublisherEquatableWrapper, rhs: FulfillmentPublisherEquatableWrapper) -> Bool { - lhs.publisher != nil && rhs.publisher != nil - } -} +struct SnapshotFulfillmentPreferenceKey: PreferenceKey { + static var defaultValue: Wrapper? -struct SnapshotFulfillmentPublisherPreferenceKey: PreferenceKey { - static var defaultValue: FulfillmentPublisherEquatableWrapper? - - static func reduce(value: inout FulfillmentPublisherEquatableWrapper?, nextValue: () -> FulfillmentPublisherEquatableWrapper?) { + static func reduce(value: inout Wrapper?, nextValue: () -> Wrapper?) { value = nextValue() } + + enum Source { + case publisher(AnyPublisher) + case stream(AsyncStream) + } + + struct Wrapper: Equatable { + let id = UUID() + let source: Source + + static func == (lhs: Wrapper, rhs: Wrapper) -> Bool { + lhs.id == rhs.id // Not ideal, but it's good enough for snapshots. + } + } } extension SwiftUI.View { @@ -47,7 +52,7 @@ extension SwiftUI.View { /// These preferences can then be retrieved and used elsewhere in your view hierarchy. /// /// - Parameters: - /// - delay: The delay time in seconds that you want to set as a preference to the View. + /// - expect: A publisher that indicates when the preview is ready for snapshotting. /// - precision: The percentage of pixels that must match. /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. 98-99% mimics the precision of the human eye. func snapshotPreferences(expect fulfillmentPublisher: (any Publisher)? = nil, @@ -55,6 +60,22 @@ extension SwiftUI.View { perceptualPrecision: Float = 0.98) -> some SwiftUI.View { preference(key: SnapshotPrecisionPreferenceKey.self, value: precision) .preference(key: SnapshotPerceptualPrecisionPreferenceKey.self, value: perceptualPrecision) - .preference(key: SnapshotFulfillmentPublisherPreferenceKey.self, value: FulfillmentPublisherEquatableWrapper(publisher: fulfillmentPublisher?.eraseToAnyPublisher())) + .preference(key: SnapshotFulfillmentPreferenceKey.self, value: fulfillmentPublisher.map { SnapshotFulfillmentPreferenceKey.Wrapper(source: .publisher($0.eraseToAnyPublisher())) }) + } + + /// Use this modifier when you want to apply snapshot-specific preferences, + /// like delay and precision, to the view. + /// These preferences can then be retrieved and used elsewhere in your view hierarchy. + /// + /// - Parameters: + /// - expect: A stream that indicates when the preview is ready for snapshotting. + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. 98-99% mimics the precision of the human eye. + func snapshotPreferences(expect fulfillmentStream: AsyncStream? = nil, + precision: Float = 1.0, + perceptualPrecision: Float = 0.98) -> some SwiftUI.View { + preference(key: SnapshotPrecisionPreferenceKey.self, value: precision) + .preference(key: SnapshotPerceptualPrecisionPreferenceKey.self, value: perceptualPrecision) + .preference(key: SnapshotFulfillmentPreferenceKey.self, value: fulfillmentStream.map { SnapshotFulfillmentPreferenceKey.Wrapper(source: .stream($0)) }) } } diff --git a/ElementX/Sources/Other/Extensions/XCTestCase.swift b/ElementX/Sources/Other/Extensions/XCTestCase.swift index d00d2f90e..7e97bf7cc 100644 --- a/ElementX/Sources/Other/Extensions/XCTestCase.swift +++ b/ElementX/Sources/Other/Extensions/XCTestCase.swift @@ -48,6 +48,39 @@ extension XCTestCase { } } + /// XCTest utility that assists in observing an async stream, deferring the fulfilment and results until some condition has been met. + /// - Parameters: + /// - asyncStream: The stream to wait on. + /// - timeout: A timeout after which we give up. + /// - message: An optional custom expectation message + /// - until: callback that evaluates outputs until some condition is reached + /// - Returns: The deferred fulfilment to be executed after some actions and that returns the result of the publisher. + func deferFulfillment(_ asyncStream: AsyncStream, + timeout: TimeInterval = 10, + message: String? = nil, + until condition: @escaping (Value) -> Bool) -> DeferredFulfillment { + var result: Result? + let expectation = expectation(description: message ?? "Awaiting stream") + var hasFulfilled = false + + let task = Task { + for await value in asyncStream { + if condition(value), !hasFulfilled { + result = .success(value) + expectation.fulfill() + hasFulfilled = true + } + } + } + + return DeferredFulfillment { + await self.fulfillment(of: [expectation], timeout: timeout) + task.cancel() + let unwrappedResult = try XCTUnwrap(result, "Awaited stream did not produce any output") + return try unwrappedResult.get() + } + } + /// XCTest utility that assists in subscribing to a publisher and deferring the fulfilment and results until some other actions have been performed. /// - Parameters: /// - publisher: The publisher to wait on. diff --git a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift index 4b586bb09..00b0ff198 100644 --- a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift +++ b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift @@ -8,7 +8,8 @@ import Combine import Foundation -/// A common ViewModel implementation for handling of `State` and `ViewAction`s +/// A common ViewModel implementation for handling of `State` and `ViewAction`s. This original version +/// is implemented using SwiftUI's original `ObservableObject` and `@Published` pattern. /// /// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to) /// a specific portion of state that can be safely bound to. diff --git a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift new file mode 100644 index 000000000..eca817e93 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift @@ -0,0 +1,89 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import Foundation +import Observation + +/// A common ViewModel implementation for handling of `State` and `ViewAction`s. Version 2 of this state +/// store has been re-written to use Swift's new Observation framework. +/// +/// +/// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to) +/// a specific portion of state that can be safely bound to. +/// If we decide to add more features to our state management (like doing state processing off the main thread) +/// we can do it in this centralised place. +@MainActor +class StateStoreViewModelV2 { + /// For storing subscription references. + /// + /// Left as public for `ViewModel` implementations convenience. + var cancellables = Set() + + /// Constrained interface for passing to Views. + var context: Context + + var state: State { + get { context.viewState } + set { context.viewState = newValue } + } + + init(initialViewState: State, mediaProvider: MediaProviderProtocol? = nil) { + context = Context(initialViewState: initialViewState, mediaProvider: mediaProvider) + context.viewModel = self + } + + /// Override to handles incoming `ViewAction`s from the `ViewModel`. + /// - Parameter viewAction: The `ViewAction` to be processed in `ViewModel` implementation. + func process(viewAction: ViewAction) { + // Default implementation, -no-op + } + + // MARK: - Context + + /// A constrained and concise interface for interacting with the ViewModel. + /// + /// This class is closely bound to`StateStoreViewModel`. It provides the exact interface the view should need to interact + /// ViewModel (as modelled on our previous template architecture with the addition of two-way binding): + /// - The ability read/observe view state + /// - The ability to send view events + /// - The ability to bind state to a specific portion of the view state safely. + /// This class was brought about a little bit by necessity. The most idiomatic way of interacting with SwiftUI is via `@Published` + /// properties which which are property wrappers and therefore can't be defined within protocols. + /// A similar approach is taken in libraries like [CombineFeedback](https://github.com/sergdort/CombineFeedback). + /// It provides a nice layer of consistency and also safety. As we are not passing the `ViewModel` to the view directly, shortcuts/hacks + /// can't be made into the `ViewModel`. + @dynamicMemberLookup + @MainActor + @Observable final class Context { + fileprivate weak var viewModel: StateStoreViewModelV2? + + /// Get-able property for the `ViewState` + fileprivate(set) var viewState: State + + /// An optional image loading service so that views can manage themselves + /// Intentionally non-generic so that it doesn't grow uncontrollably + let mediaProvider: MediaProviderProtocol? + + /// Set-able access to the bindable state. + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { viewState.bindings[keyPath: keyPath] } + set { viewState.bindings[keyPath: keyPath] = newValue } + } + + /// Send a `ViewAction` to the `ViewModel` for processing. + /// - Parameter viewAction: The `ViewAction` to send to the `ViewModel`. + func send(viewAction: ViewAction) { + viewModel?.process(viewAction: viewAction) + } + + fileprivate init(initialViewState: State, mediaProvider: MediaProviderProtocol?) { + self.viewState = initialViewState + self.mediaProvider = mediaProvider + } + } +} diff --git a/PreviewTests/Sources/PreviewTests.swift b/PreviewTests/Sources/PreviewTests.swift index a1e53a784..2b0153499 100644 --- a/PreviewTests/Sources/PreviewTests.swift +++ b/PreviewTests/Sources/PreviewTests.swift @@ -57,15 +57,21 @@ class PreviewTests: XCTestCase { let preferenceReadingView = preview.content .onPreferenceChange(SnapshotPrecisionPreferenceKey.self) { preferences.precision = $0 } .onPreferenceChange(SnapshotPerceptualPrecisionPreferenceKey.self) { preferences.perceptualPrecision = $0 } - .onPreferenceChange(SnapshotFulfillmentPublisherPreferenceKey.self) { preferences.fulfillmentPublisher = $0?.publisher } + .onPreferenceChange(SnapshotFulfillmentPreferenceKey.self) { preferences.fulfillmentSource = $0?.source } // Render an image of the view in order to trigger the preference updates to occur. let imageRenderer = ImageRenderer(content: preferenceReadingView) _ = imageRenderer.uiImage - if let fulfillmentPublisher = preferences.fulfillmentPublisher { - let deferred = deferFulfillment(fulfillmentPublisher) { $0 == true } + switch preferences.fulfillmentSource { + case .publisher(let publisher): + let deferred = deferFulfillment(publisher) { $0 == true } try await deferred.fulfill() + case .stream(let stream): + let deferred = deferFulfillment(stream) { $0 == true } + try await deferred.fulfill() + case .none: + break } var sanitizedSuiteName = String(testName.suffix(testName.count - "test".count).dropLast(2)) @@ -152,7 +158,7 @@ class PreviewTests: XCTestCase { private class SnapshotPreferences: @unchecked Sendable { var precision: Float = 1 var perceptualPrecision: Float = 1 - var fulfillmentPublisher: AnyPublisher? + var fulfillmentSource: SnapshotFulfillmentPreferenceKey.Source? } // MARK: - SnapshotTesting + Extensions diff --git a/Tools/Scripts/Templates/SimpleScreenExample/ElementX/TemplateScreenModels.swift b/Tools/Scripts/Templates/SimpleScreenExample/ElementX/TemplateScreenModels.swift index 0ce613a99..4bb392396 100644 --- a/Tools/Scripts/Templates/SimpleScreenExample/ElementX/TemplateScreenModels.swift +++ b/Tools/Scripts/Templates/SimpleScreenExample/ElementX/TemplateScreenModels.swift @@ -16,6 +16,8 @@ enum TemplateScreenViewModelAction { struct TemplateScreenViewState: BindableState { var title: String var placeholder: String + var counter = 0 + var bindings: TemplateScreenViewStateBindings } @@ -27,5 +29,8 @@ enum TemplateScreenViewAction { case done case textChanged + case incrementCounter + case decrementCounter + // Consider adding CustomStringConvertible conformance if the actions contain PII } diff --git a/Tools/Scripts/Templates/SimpleScreenExample/ElementX/TemplateScreenViewModel.swift b/Tools/Scripts/Templates/SimpleScreenExample/ElementX/TemplateScreenViewModel.swift index b0efbcf4b..3e7c8ccb3 100644 --- a/Tools/Scripts/Templates/SimpleScreenExample/ElementX/TemplateScreenViewModel.swift +++ b/Tools/Scripts/Templates/SimpleScreenExample/ElementX/TemplateScreenViewModel.swift @@ -8,7 +8,7 @@ import Combine import SwiftUI -typealias TemplateScreenViewModelType = StateStoreViewModel +typealias TemplateScreenViewModelType = StateStoreViewModelV2 class TemplateScreenViewModel: TemplateScreenViewModelType, TemplateScreenViewModelProtocol { private let actionsSubject: PassthroughSubject = .init() @@ -32,6 +32,16 @@ class TemplateScreenViewModel: TemplateScreenViewModelType, TemplateScreenViewMo actionsSubject.send(.done) case .textChanged: MXLog.info("View model: composer text changed to: \(state.bindings.composerText)") + case .incrementCounter: + Task { + try await Task.sleep(for: .seconds(.random(in: 1.0...2.0))) + state.counter += 1 + } + case .decrementCounter: + Task { + try await Task.sleep(for: .seconds(.random(in: 1.0...2.0))) + state.counter -= 1 + } } } } diff --git a/Tools/Scripts/Templates/SimpleScreenExample/ElementX/View/TemplateScreen.swift b/Tools/Scripts/Templates/SimpleScreenExample/ElementX/View/TemplateScreen.swift index d70e1c4c6..f97178a9d 100644 --- a/Tools/Scripts/Templates/SimpleScreenExample/ElementX/View/TemplateScreen.swift +++ b/Tools/Scripts/Templates/SimpleScreenExample/ElementX/View/TemplateScreen.swift @@ -9,7 +9,7 @@ import Compound import SwiftUI struct TemplateScreen: View { - @ObservedObject var context: TemplateScreenViewModel.Context + @Bindable var context: TemplateScreenViewModel.Context var body: some View { Form { @@ -18,9 +18,19 @@ struct TemplateScreen: View { kind: .textField(text: $context.composerText)) ListRow(label: .centeredAction(title: L10n.actionDone, - systemIcon: .doorLeftHandClosed), + icon: \.leave), kind: .button { context.send(viewAction: .done) }) } + + Section { + ListRow(label: .default(title: "Counter", icon: \.chart), + details: .counter(context.viewState.counter), + kind: .label) + ListRow(label: .default(title: "Increment", icon: \.plus), + kind: .button { context.send(viewAction: .incrementCounter) }) + ListRow(label: .default(title: "Decrement", icon: \.minus), + kind: .button { context.send(viewAction: .decrementCounter) }) + } } .compoundList() .navigationTitle(context.viewState.title) @@ -33,10 +43,29 @@ struct TemplateScreen: View { // MARK: - Previews struct TemplateScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = TemplateScreenViewModel() + static let viewModel = makeViewModel() + static let incrementedViewModel = makeViewModel(counterValue: 1) + static var previews: some View { NavigationStack { TemplateScreen(context: viewModel.context) } + .previewDisplayName("Initial") + + NavigationStack { + TemplateScreen(context: incrementedViewModel.context) + } + .previewDisplayName("Incremented") + .snapshotPreferences(expect: incrementedViewModel.context.observe(\.viewState.counter).map { $0 == 1 }.eraseToStream()) + } + + static func makeViewModel(counterValue: Int = 0) -> TemplateScreenViewModel { + let viewModel = TemplateScreenViewModel() + + for _ in 0..