diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift index 09c40c1274ad..46b9775e106a 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift @@ -78,7 +78,7 @@ extension Reducer { .cancellable(id: FavoriteCancelId(id: state.id), cancelInFlight: true) case let .response(.failure(error)): - state.alert = .init(title: error.localizedDescription) + state.alert = .init(title: .init(error.localizedDescription)) return .none case let .response(.success(isFavorite)): diff --git a/Examples/TicTacToe/Sources/Core/LoginCore.swift b/Examples/TicTacToe/Sources/Core/LoginCore.swift index 8eb57518051f..4cb5107ff700 100644 --- a/Examples/TicTacToe/Sources/Core/LoginCore.swift +++ b/Examples/TicTacToe/Sources/Core/LoginCore.swift @@ -72,7 +72,7 @@ public let loginReducer = return .none case let .loginResponse(.failure(error)): - state.alert = .init(title: error.localizedDescription) + state.alert = .init(title: .init(error.localizedDescription)) state.isLoginRequestInFlight = false return .none diff --git a/Examples/TicTacToe/Sources/Core/TwoFactorCore.swift b/Examples/TicTacToe/Sources/Core/TwoFactorCore.swift index 0f157dee9dd9..c2430489e4a9 100644 --- a/Examples/TicTacToe/Sources/Core/TwoFactorCore.swift +++ b/Examples/TicTacToe/Sources/Core/TwoFactorCore.swift @@ -58,7 +58,7 @@ public let twoFactorReducer = Reducer String { + let children = Array(Mirror(reflecting: self).children) + let key = children[0].value as! String + let arguments: [CVarArg] = Array(Mirror(reflecting: children[2].value).children) + .compactMap { + let children = Array(Mirror(reflecting: $0.value).children) + let value: Any + let formatter: Formatter? + // `LocalizedStringKey.FormatArgument` differs depending on OS/platform. + if children[0].label == "storage" { + (value, formatter) = Array(Mirror(reflecting: children[0].value).children)[0].value as! (Any, Formatter?) + } else { + value = children[0].value + formatter = children[1].value as? Formatter + } + return formatter?.string(for: value) ?? value as! CVarArg + } + + return String(format: key, locale: nil, arguments: arguments) + } + + public var debugOutput: String { + self.formatted().debugDescription + } +} diff --git a/Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift b/Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift index 3233083f9b88..b6a4392349f9 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift @@ -4,7 +4,7 @@ import SwiftUI /// `Action` generic is the type of actions that can be sent from tapping on a button in the sheet. /// /// This type can be used in your application's state in order to control the presentation or -/// dismissal of action sheets. It is preferrable to use this API instead of the default SwiftUI API +/// dismissal of action sheets. It is preferable to use this API instead of the default SwiftUI API /// for action sheets because SwiftUI uses 2-way bindings in order to control the showing and /// dismissal of sheets, and that does not play nicely with the Composable Architecture. The library /// requires that all state mutations happen by sending an action so that a reducer can handle that @@ -107,12 +107,12 @@ import SwiftUI public struct ActionSheetState { public let id = UUID() public var buttons: [Button] - public var message: String? - public var title: String + public var message: LocalizedStringKey? + public var title: LocalizedStringKey public init( - title: String, - message: String? = nil, + title: LocalizedStringKey, + message: LocalizedStringKey? = nil, buttons: [Button] ) { self.buttons = buttons @@ -146,8 +146,8 @@ extension ActionSheetState: CustomDebugOutputConvertible { @available(watchOS 6, *) extension ActionSheetState: Equatable where Action: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.title == rhs.title - && lhs.message == rhs.message + lhs.title.formatted() == rhs.title.formatted() + && lhs.message?.formatted() == rhs.message?.formatted() && lhs.buttons == rhs.buttons } } @@ -159,8 +159,8 @@ extension ActionSheetState: Equatable where Action: Equatable { @available(watchOS 6, *) extension ActionSheetState: Hashable where Action: Hashable { public func hash(into hasher: inout Hasher) { - hasher.combine(self.title) - hasher.combine(self.message) + hasher.combine(self.title.formatted()) + hasher.combine(self.message?.formatted()) hasher.combine(self.buttons) } } diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift index 12a60609b7f8..769e2297c75b 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Alert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -4,7 +4,7 @@ import SwiftUI /// generic is the type of actions that can be sent from tapping on a button in the alert. /// /// This type can be used in your application's state in order to control the presentation or -/// dismissal of alerts. It is preferrable to use this API instead of the default SwiftUI API +/// dismissal of alerts. It is preferable to use this API instead of the default SwiftUI API /// for alerts because SwiftUI uses 2-way bindings in order to control the showing and dismissal /// of alerts, and that does not play nicely with the Composable Architecture. The library requires /// that all state mutations happen by sending an action so that a reducer can handle that logic, @@ -92,14 +92,14 @@ import SwiftUI /// public struct AlertState { public let id = UUID() - public var message: String? + public var message: LocalizedStringKey? public var primaryButton: Button? public var secondaryButton: Button? - public var title: String + public var title: LocalizedStringKey public init( - title: String, - message: String? = nil, + title: LocalizedStringKey, + message: LocalizedStringKey? = nil, dismissButton: Button? = nil ) { self.title = title @@ -108,8 +108,8 @@ public struct AlertState { } public init( - title: String, - message: String? = nil, + title: LocalizedStringKey, + message: LocalizedStringKey? = nil, primaryButton: Button, secondaryButton: Button ) { @@ -124,7 +124,7 @@ public struct AlertState { public var type: `Type` public static func cancel( - _ label: String, + _ label: LocalizedStringKey, send action: Action? = nil ) -> Self { Self(action: action, type: .cancel(label: label)) @@ -137,23 +137,23 @@ public struct AlertState { } public static func `default`( - _ label: String, + _ label: LocalizedStringKey, send action: Action? = nil ) -> Self { Self(action: action, type: .default(label: label)) } public static func destructive( - _ label: String, + _ label: LocalizedStringKey, send action: Action? = nil ) -> Self { Self(action: action, type: .destructive(label: label)) } - public enum `Type`: Hashable { - case cancel(label: String?) - case `default`(label: String) - case destructive(label: String) + public enum `Type` { + case cancel(label: LocalizedStringKey?) + case `default`(label: LocalizedStringKey) + case destructive(label: LocalizedStringKey) } } } @@ -194,24 +194,56 @@ extension AlertState: CustomDebugOutputConvertible { extension AlertState: Equatable where Action: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.title == rhs.title - && lhs.message == rhs.message + lhs.title.formatted() == rhs.title.formatted() + && lhs.message?.formatted() == rhs.message?.formatted() && lhs.primaryButton == rhs.primaryButton && lhs.secondaryButton == rhs.secondaryButton } } extension AlertState: Hashable where Action: Hashable { public func hash(into hasher: inout Hasher) { - hasher.combine(self.title) - hasher.combine(self.message) + hasher.combine(self.title.formatted()) + hasher.combine(self.message?.formatted()) hasher.combine(self.primaryButton) hasher.combine(self.secondaryButton) } } extension AlertState: Identifiable {} -extension AlertState.Button: Equatable where Action: Equatable {} -extension AlertState.Button: Hashable where Action: Hashable {} +extension AlertState.Button.`Type`: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.cancel(lhs), .cancel(rhs)): + return lhs?.formatted() == rhs?.formatted() + case let (.default(lhs), .default(rhs)), let (.destructive(lhs), .destructive(rhs)): + return lhs.formatted() == rhs.formatted() + default: + return false + } + } +} +extension AlertState.Button: Equatable where Action: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.action == rhs.action && lhs.type == rhs.type + } +} + +extension AlertState.Button.`Type`: Hashable { + public func hash(into hasher: inout Hasher) { + switch self { + case let .cancel(label): + hasher.combine(label?.formatted()) + case let .default(label), let .destructive(label): + hasher.combine(label.formatted()) + } + } +} +extension AlertState.Button: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.action) + hasher.combine(self.type) + } +} extension AlertState.Button { func toSwiftUI(send: @escaping (Action) -> Void) -> SwiftUI.Alert.Button { diff --git a/Tests/ComposableArchitectureTests/LocalizedStringKeyTests.swift b/Tests/ComposableArchitectureTests/LocalizedStringKeyTests.swift new file mode 100644 index 000000000000..f9b2ec33d576 --- /dev/null +++ b/Tests/ComposableArchitectureTests/LocalizedStringKeyTests.swift @@ -0,0 +1,20 @@ +import SwiftUI +import XCTest + +@testable import ComposableArchitecture + +class LocalizedStringKeyTests: XCTestCase { + func testFormatting() { + XCTAssertEqual( + LocalizedStringKey("Hello, #\(42)!").formatted(), + "Hello, #42!" + ) + + let formatter = NumberFormatter() + formatter.numberStyle = .ordinal + XCTAssertEqual( + LocalizedStringKey("You are \(1_000 as NSNumber, formatter: formatter) in line.").formatted(), + "You are 1,000th in line." + ) + } +}