Skip to content

Commit

Permalink
Merge pull request #39 from Ryu0118/feature/swiftui-alert
Browse files Browse the repository at this point in the history
Support AlertState and ConfirmationDialogState
  • Loading branch information
Ryu0118 authored Oct 3, 2023
2 parents 5240406 + 8ec24d5 commit b732e8a
Show file tree
Hide file tree
Showing 18 changed files with 302 additions and 17 deletions.
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@
"version" : "509.0.0"
}
},
{
"identity" : "swiftui-navigation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swiftui-navigation.git",
"state" : {
"revision" : "6eb293c49505d86e9e24232cb6af6be7fff93bd5",
"version" : "1.0.2"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
Expand Down
4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ let package = Package(
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", exact: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", exact: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies.git", exact: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-macro-testing", exact: "0.1.0"),
.package(url: "https://github.com/pointfreeco/swiftui-navigation.git", exact: "1.0.2"),
.package(url: "https://github.com/pointfreeco/swift-macro-testing.git", exact: "0.1.0"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
Expand All @@ -36,6 +37,7 @@ let package = Package(
.product(name: "CasePaths", package: "swift-case-paths"),
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "SwiftUINavigation", package: "swiftui-navigation"),
]
),
.macro(
Expand Down
29 changes: 29 additions & 0 deletions Sources/SimplexArchitecture/ActionSendable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ public extension ActionSendable {
}
}

@discardableResult
func send(_ action: consuming Reducer.Action, animation: Animation?) -> SendTask {
threadCheck()
return if store.container == nil {
withAnimation(animation) {
store.sendAction(action, target: self)
}
} else {
withAnimation(animation) {
store.sendIfNeeded(action)
}
}
}

/// Send an action to the store with transaction
@discardableResult
func send(_ action: consuming Reducer.Action, transaction: Transaction) -> SendTask {
threadCheck()
return if store.container == nil {
withTransaction(transaction) {
store.sendAction(action, target: self)
}
} else {
withTransaction(transaction) {
store.sendIfNeeded(action)
}
}
}

@inline(__always)
func threadCheck() {
#if DEBUG
Expand Down
17 changes: 15 additions & 2 deletions Sources/SimplexArchitecture/Effect/SideEffect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,23 @@ public extension SideEffect {
@inlinable
static func run(
priority: TaskPriority? = nil,
_ operation: @Sendable @escaping (_ send: Send<Reducer>) async throws -> Void,
_ operation: @Sendable @escaping (_ send: Send<Reducer>) async throws -> Void,
catch: (@Sendable (_ error: any Error, _ send: Send<Reducer>) async -> Void)? = nil
) -> Self {
.init(effectKind: .run(priority: priority, operation: operation, catch: `catch`))
// If the .dependency modifier is used, the dependency must be conveyed to the escape context.
withEscapedDependencies { continuation in
.init(
effectKind: .run(
priority: priority,
operation: { send in
try await continuation.yield {
try await operation(send)
}
},
catch: `catch`
)
)
}
}

@inlinable
Expand Down
13 changes: 13 additions & 0 deletions Sources/SimplexArchitecture/Internal/Dependencies+isTesting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

enum IsTestingKey: DependencyKey {
static let liveValue: Bool = _XCTIsTesting
static let testValue: Bool = _XCTIsTesting
}

extension DependencyValues {
var isTesting: Bool {
get { self[IsTestingKey.self] }
set { self[IsTestingKey.self] = newValue }
}
}
2 changes: 2 additions & 0 deletions Sources/SimplexArchitecture/Internal/Exported.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@_exported import SwiftUINavigation
@_exported import Dependencies
10 changes: 8 additions & 2 deletions Sources/SimplexArchitecture/Internal/TestOnly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@ import XCTestDynamicOverlay
struct TestOnly<T> {
private var _value: T

@Dependency(\.isTesting) var isTesting

var wrappedValue: T {
_read {
if !_XCTIsTesting {
#if DEBUG
if !isTesting {
runtimeWarning("\(Self.self) is accessible only during Unit tests")
}
#endif
yield _value
}
set {
if _XCTIsTesting {
#if DEBUG
if isTesting {
_value = newValue
}
#endif
}
}

Expand Down
9 changes: 7 additions & 2 deletions Sources/SimplexArchitecture/StateContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public final class StateContainer<Target: ActionSendable> {
var _reducerState: Target.Reducer.ReducerState?
var entity: Target
@TestOnly var viewState: Target.ViewState?
@Dependency(\.isTesting) private var isTesting

init(
_ entity: consuming Target,
Expand All @@ -25,21 +26,25 @@ public final class StateContainer<Target: ActionSendable> {

public subscript<U>(dynamicMember keyPath: WritableKeyPath<Target.ViewState, U>) -> U {
_read {
guard !_XCTIsTesting else {
#if DEBUG
guard !isTesting else {
yield viewState![keyPath: keyPath]
return
}
#endif
if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> {
yield entity[keyPath: viewKeyPath]
} else {
fatalError()
}
}
_modify {
guard !_XCTIsTesting else {
#if DEBUG
guard !isTesting else {
yield &viewState![keyPath: keyPath]
return
}
#endif
if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath<Target, U> {
yield &entity[keyPath: viewKeyPath]
} else {
Expand Down
8 changes: 7 additions & 1 deletion Sources/SimplexArchitecture/Store/Store+send.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,12 @@ extension Store {
}
}


let sideEffect: SideEffect<Reducer>
// If Unit Testing is in progress and an action is sent from SideEffect
if _XCTIsTesting, let effectContext = EffectContext.id {
#if DEBUG
@Dependency(\.isTesting) var isTesting
if let effectContext = EffectContext.id, isTesting {
let before = container.copy()
sideEffect = reduce(container, action)
sentFromEffectActions.append(
Expand All @@ -86,6 +89,9 @@ extension Store {
} else {
sideEffect = reduce(container, action)
}
#else
sideEffect = reduce(container, action)
#endif

if case .none = sideEffect.kind {
return .never
Expand Down
66 changes: 66 additions & 0 deletions Sources/SimplexArchitecture/SwiftUI/Alert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import SwiftUI

@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public extension View {
func alert<Target: ActionSendable>(
target: Target,
unwrapping value: Binding<AlertState<Target.Reducer.Action>?>
) -> some View {
self.alert(
(value.wrappedValue?.title).map(Text.init) ?? Text(""),
isPresented: value.isPresent(),
presenting: value.wrappedValue,
actions: { alertState in
ForEach(alertState.buttons) { button in
Button(role: button.role.map(ButtonRole.init)) {
switch button.action.type {
case let .send(action):
if let action = action {
target.send(action)
}
case let .animatedSend(action, animation):
if let action = action {
target.send(action, animation: animation)
}
}
} label: {
Text(button.label)
}
}
},
message: { $0.message.map { Text($0) } }
)
}

func alert<Target: ActionSendable>(
target: Target,
unwrapping value: Binding<AlertState<Target.Reducer.ReducerAction>?>
) -> some View {
self.alert(
(value.wrappedValue?.title).map(Text.init) ?? Text(""),
isPresented: value.isPresent(),
presenting: value.wrappedValue,
actions: { alertState in
ForEach(alertState.buttons) { button in
Button(role: button.role.map(ButtonRole.init)) {
switch button.action.type {
case let .send(action):
if let action = action {
_ = target.store.sendAction(action, target: target)
}
case let .animatedSend(action, animation):
if let action = action {
_ = withAnimation(animation) {
target.store.sendAction(action, target: target)
}
}
}
} label: {
Text(button.label)
}
}
},
message: { $0.message.map { Text($0) } }
)
}
}
68 changes: 68 additions & 0 deletions Sources/SimplexArchitecture/SwiftUI/ConfirmationDialog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import SwiftUI

@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public extension View {
func confirmationDialog<Target: ActionSendable>(
target: Target,
unwrapping value: Binding<ConfirmationDialogState<Target.Reducer.Action>?>
) -> some View {
self.confirmationDialog(
value.wrappedValue.flatMap { Text($0.title) } ?? Text(""),
isPresented: value.isPresent(),
titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic,
presenting: value.wrappedValue,
actions: { confirmationDialogState in
ForEach(confirmationDialogState.buttons) { button in
Button(role: button.role.map(ButtonRole.init)) {
switch button.action.type {
case let .send(action):
if let action = action {
target.send(action)
}
case let .animatedSend(action, animation):
if let action = action {
target.send(action, animation: animation)
}
}
} label: {
Text(button.label)
}
}
},
message: { $0.message.map { Text($0) } }
)
}

func confirmationDialog<Target: ActionSendable>(
target: Target,
unwrapping value: Binding<ConfirmationDialogState<Target.Reducer.ReducerAction>?>
) -> some View {
self.confirmationDialog(
value.wrappedValue.flatMap { Text($0.title) } ?? Text(""),
isPresented: value.isPresent(),
titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic,
presenting: value.wrappedValue,
actions: { confirmationDialogState in
ForEach(confirmationDialogState.buttons) { button in
Button(role: button.role.map(ButtonRole.init)) {
switch button.action.type {
case let .send(action):
if let action = action {
_ = target.store.sendAction(action, target: target)
}
case let .animatedSend(action, animation):
if let action = action {
_ = withAnimation(animation) {
target.store.sendAction(action, target: target)
}
}
}
} label: {
Text(button.label)
}
}
},
message: { $0.message.map { Text($0) } }
)
}
}
3 changes: 2 additions & 1 deletion Sources/SimplexArchitecture/TaskResult+VoidSuccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public struct VoidSuccess: Codable, Sendable, Hashable {
}

/// An extension on `TaskResult` where the success type is `VoidSuccess`.
extension TaskResult where Success == VoidSuccess {
public extension TaskResult where Success == VoidSuccess {
/// Creates a new task result by evaluating an async throwing closure.
///
/// This initializer is used to handle asynchronous operations that produce a result of type `Void`.
Expand All @@ -15,6 +15,7 @@ extension TaskResult where Success == VoidSuccess {
/// This initializer is often used within an async effect that's returned from a reducer.
///
/// - Parameter body: An async, throwing closure.
@_disfavoredOverload
init(catching body: @Sendable () async throws -> Void) async {
do {
try await body()
Expand Down
2 changes: 1 addition & 1 deletion Sources/SimplexArchitecture/TaskResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public enum TaskResult<Success: Sendable>: Sendable {
case failure(any Error)

@inlinable
public init(catching body: () async throws -> Success) async {
public init(catching body: @Sendable () async throws -> Success) async {
do {
self = try .success(await body())
} catch {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
@testable import SimplexArchitecture
import SwiftUI
import Dependencies
import XCTest

@MainActor
final class DependenciesOverrideModifierTests: XCTestCase {
func testModifier() async {
let isCalled = LockIsolated(false)
let base = BaseView(
store: Store(
reducer: _DependenciesOverrideModifier(base: BaseReducer()) {
$0.test = .init(asyncThrows: {})
$0.test = .init(asyncThrows: {
isCalled.setValue(true)
})
}
)
)
let container = base.store.setContainerIfNeeded(for: base, viewState: .init())
await base.send(.test).wait()
XCTAssertEqual(container.count, 1)
XCTAssertTrue(isCalled.value)
}
}

Expand Down
Loading

0 comments on commit b732e8a

Please sign in to comment.