Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
2 changes: 1 addition & 1 deletion Examples/TicTacToe/Sources/Core/LoginCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Examples/TicTacToe/Sources/Core/TwoFactorCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public let twoFactorReducer = Reducer<TwoFactorState, TwoFactorAction, TwoFactor
.map(TwoFactorAction.twoFactorResponse)

case let .twoFactorResponse(.failure(error)):
state.alert = .init(title: error.localizedDescription)
state.alert = .init(title: .init(error.localizedDescription))
state.isTwoFactorRequestInFlight = false
return .none

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class LoginViewController: UIViewController {
guard let alert = alert else { return }

let alertController = UIAlertController(
title: alert.title, message: nil, preferredStyle: .alert)
title: alert.title.formatted(), message: nil, preferredStyle: .alert)
alertController.addAction(
UIAlertAction(
title: "Ok", style: .default,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public final class TwoFactorViewController: UIViewController {
guard let alert = alert else { return }

let alertController = UIAlertController(
title: alert.title, message: nil, preferredStyle: .alert)
title: alert.title.formatted(), message: nil, preferredStyle: .alert)
alertController.addAction(
UIAlertAction(
title: "Ok", style: .default,
Expand Down
3 changes: 1 addition & 2 deletions Examples/TicTacToe/Tests/LoginSwiftUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,7 @@ class LoginSwiftUITests: XCTestCase {
self.scheduler.advance()
},
.receive(.loginResponse(.failure(.invalidUserPassword))) {
$0.alert = .init(
title: AuthenticationError.invalidUserPassword.localizedDescription)
$0.alert = .init(title: .init(AuthenticationError.invalidUserPassword.localizedDescription))
$0.isActivityIndicatorVisible = false
$0.isFormDisabled = false
},
Expand Down
2 changes: 1 addition & 1 deletion Examples/TicTacToe/Tests/TwoFactorSwiftUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class TwoFactorSwiftUITests: XCTestCase {
self.scheduler.advance()
},
.receive(.twoFactorResponse(.failure(.invalidTwoFactor))) {
$0.alert = .init(title: AuthenticationError.invalidTwoFactor.localizedDescription)
$0.alert = .init(title: .init(AuthenticationError.invalidTwoFactor.localizedDescription))
$0.isActivityIndicatorVisible = false
$0.isFormDisabled = false
},
Expand Down
29 changes: 29 additions & 0 deletions Sources/ComposableArchitecture/Internal/LocalizedStringKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import SwiftUI

extension LocalizedStringKey: CustomDebugOutputConvertible {
// NB: `LocalizedStringKey` conforms to `Equatable` but returns false for equivalent format strings.
public func formatted(locale: Locale? = nil) -> 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
}
}
18 changes: 9 additions & 9 deletions Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,12 +107,12 @@ import SwiftUI
public struct ActionSheetState<Action> {
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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dunno if we want S: StringProtocol overloads for all these initializers, too...

buttons: [Button]
) {
self.buttons = buttons
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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)
}
}
Expand Down
72 changes: 52 additions & 20 deletions Sources/ComposableArchitecture/SwiftUI/Alert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -92,14 +92,14 @@ import SwiftUI
///
public struct AlertState<Action> {
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
Expand All @@ -108,8 +108,8 @@ public struct AlertState<Action> {
}

public init(
title: String,
message: String? = nil,
title: LocalizedStringKey,
message: LocalizedStringKey? = nil,
primaryButton: Button,
secondaryButton: Button
) {
Expand All @@ -124,7 +124,7 @@ public struct AlertState<Action> {
public var type: `Type`

public static func cancel(
_ label: String,
_ label: LocalizedStringKey,
send action: Action? = nil
) -> Self {
Self(action: action, type: .cancel(label: label))
Expand All @@ -137,23 +137,23 @@ public struct AlertState<Action> {
}

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)
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions Tests/ComposableArchitectureTests/LocalizedStringKeyTests.swift
Original file line number Diff line number Diff line change
@@ -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."
)
}
}