From a905fbf5ec5fb6afe1a5ac71bf8f3dd44eb9ca18 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Tue, 30 Jun 2020 09:48:36 -0500 Subject: [PATCH] Generic alerts and action sheets (#201) * alerts * wip * wip * wip * clean up * wip * wip * wip * wip * format * clean up * clean up * docs * wip * tests * API tweaks * Fix * More API changes * More API changes * More * Fix * Fix docs * Generic alerts optionality (#202) * Use Optional to model generic alerts * Xcode 12 * Refinement * update docs * Fix * Fix * doc fixes * rename * fixes * fixes Co-authored-by: Stephen Celis --- .github/workflows/format.yml | 2 +- .../xcschemes/ComposableArchitecture.xcscheme | 2 +- .../xcschemes/ComposableCoreLocation.xcscheme | 2 +- .../xcschemes/ComposableCoreMotion.xcscheme | 2 +- .../CaseStudies.xcodeproj/project.pbxproj | 18 +- .../xcschemes/CaseStudies (SwiftUI).xcscheme | 2 +- .../xcschemes/CaseStudies (UIKit).xcscheme | 2 +- .../SwiftUICaseStudies/00-RootView.swift | 11 + ...GettingStarted-AlertsAndActionSheets.swift | 124 ++++++++++ .../01-GettingStarted-SharedState.swift | 25 +- .../02-Effects-WebSocket.swift | 21 +- .../03-Effects-SystemEnvironment.swift | 22 +- .../DownloadComponent.swift | 75 ++---- .../ReusableComponents-Download.swift | 2 +- ...gherOrderReducers-ReusableFavoriting.swift | 30 ++- ...ngStarted-AlertsAndActionSheetsTests.swift | 57 +++++ .../02-Effects-WebSocketTests.swift | 2 +- ...rderReducers-ReusableFavoritingTests.swift | 8 +- ...ucers-ReusableOfflineDownloadsTests.swift} | 41 +--- Examples/LocationManager/Common/AppCore.swift | 16 +- .../CommonTests/CommonTests.swift | 4 +- .../Desktop/LocationManagerView.swift | 9 +- .../xcschemes/LocationManagerDesktop.xcscheme | 2 +- .../xcschemes/LocationManagerMobile.xcscheme | 2 +- .../Mobile/LocationManagerView.swift | 9 +- .../xcschemes/MotionManager.xcscheme | 2 +- .../MotionManager/MotionManagerView.swift | 21 +- .../MotionManagerTests/MotionTests.swift | 2 +- .../xcshareddata/xcschemes/Search.xcscheme | 2 +- .../xcschemes/SpeechRecognition.xcscheme | 2 +- .../SpeechRecognition/SpeechRecognition.swift | 25 +- .../SpeechRecognitionTests.swift | 13 +- .../TicTacToe/Sources/Common/AlertData.swift | 9 - Examples/TicTacToe/Sources/Core/AppCore.swift | 2 +- .../TicTacToe/Sources/Core/GameCore.swift | 4 +- .../TicTacToe/Sources/Core/LoginCore.swift | 8 +- .../TicTacToe/Sources/Core/NewGameCore.swift | 2 +- .../Sources/Core/TwoFactorCore.swift | 6 +- .../Views-SwiftUI/LoginSwiftView.swift | 16 +- .../Views-SwiftUI/TwoFactorSwiftView.swift | 14 +- .../Views-UIKit/LoginViewController.swift | 12 +- .../Views-UIKit/TwoFactorViewController.swift | 12 +- .../TicTacToe/Tests/LoginSwiftUITests.swift | 4 +- .../Tests/TwoFactorSwiftUITests.swift | 4 +- .../TicTacToe.xcodeproj/project.pbxproj | 4 - .../xcshareddata/xcschemes/AppCore.xcscheme | 2 +- .../xcschemes/AppSwiftUI.xcscheme | 2 +- .../xcschemes/AuthenticationClient.xcscheme | 2 +- .../xcshareddata/xcschemes/GameCore.xcscheme | 2 +- .../xcschemes/GameSwiftUI.xcscheme | 2 +- .../LiveAuthenticationClient.xcscheme | 2 +- .../xcshareddata/xcschemes/LoginCore.xcscheme | 2 +- .../xcschemes/LoginSwiftUI.xcscheme | 2 +- .../xcschemes/NewGameCore.xcscheme | 2 +- .../xcschemes/NewGameSwiftUI.xcscheme | 2 +- .../xcshareddata/xcschemes/TicTacToe.xcscheme | 2 +- .../xcschemes/TicTacToeCommon.xcscheme | 2 +- .../xcschemes/TwoFactorCore.xcscheme | 2 +- .../xcschemes/TwoFactorSwiftUI.xcscheme | 2 +- .../xcshareddata/xcschemes/Todos.xcscheme | 2 +- .../AudioPlayerClient/AudioPlayerClient.swift | 2 +- .../AudioRecorderClient.swift | 2 +- Examples/VoiceMemos/VoiceMemos/Helpers.swift | 5 - .../VoiceMemos/VoiceMemos/VoiceMemos.swift | 24 +- .../VoiceMemosTests/VoiceMemosTests.swift | 8 +- Makefile | 3 +- .../SwiftUI/ActionSheet.swift | 194 +++++++++++++++ .../SwiftUI/Alert.swift | 231 ++++++++++++++++++ 68 files changed, 814 insertions(+), 339 deletions(-) create mode 100644 Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndActionSheets.swift create mode 100644 Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AlertsAndActionSheetsTests.swift rename Examples/CaseStudies/SwiftUICaseStudiesTests/{04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift => 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift} (83%) delete mode 100644 Examples/TicTacToe/Sources/Common/AlertData.swift create mode 100644 Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift create mode 100644 Sources/ComposableArchitecture/SwiftUI/Alert.swift diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 80e808bc6cfe..487f11fb35a9 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -18,6 +18,6 @@ jobs: - uses: stefanzweifel/git-auto-commit-action@v4 with: commit_message: Run swift-format - branch: 'master' + branch: 'main' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme index 66b18f63a331..cc236a07ea96 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -1,6 +1,6 @@ ? + var alert: AlertState? + var count = 0 +} + +enum AlertAndSheetAction: Equatable { + case actionSheetButtonTapped + case actionSheetCancelTapped + case alertButtonTapped + case alertCancelTapped + case decrementButtonTapped + case incrementButtonTapped +} + +struct AlertAndSheetEnvironment {} + +let alertAndSheetReducer = Reducer< + AlertAndSheetState, AlertAndSheetAction, AlertAndSheetEnvironment +> { state, action, _ in + + switch action { + case .actionSheetButtonTapped: + state.actionSheet = .init( + title: "Action sheet", + message: "This is an action sheet.", + buttons: [ + .cancel(), + .default("Increment", send: .incrementButtonTapped), + .default("Decrement", send: .decrementButtonTapped), + ] + ) + return .none + + case .actionSheetCancelTapped: + state.actionSheet = nil + return .none + + case .alertButtonTapped: + state.alert = .init( + title: "Alert!", + message: "This is an alert", + primaryButton: .cancel(), + secondaryButton: .default("Increment", send: .incrementButtonTapped) + ) + return .none + + case .alertCancelTapped: + state.alert = nil + return .none + + case .decrementButtonTapped: + state.actionSheet = nil + state.count -= 1 + return .none + + case .incrementButtonTapped: + state.actionSheet = nil + state.alert = nil + state.count += 1 + return .none + } +} + +struct AlertAndSheetView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section(header: Text(template: readMe, .caption)) { + Text("Count: \(viewStore.count)") + + Button("Alert") { viewStore.send(.alertButtonTapped) } + .alert( + self.store.scope(state: { $0.alert }), + dismiss: .alertCancelTapped + ) + + Button("Action sheet") { viewStore.send(.actionSheetButtonTapped) } + .actionSheet( + self.store.scope(state: { $0.actionSheet }), + dismiss: .actionSheetCancelTapped + ) + } + } + } + .navigationBarTitle("Alerts & Action Sheets") + } +} + +struct AlertAndSheet_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + AlertAndSheetView( + store: .init( + initialState: .init(), + reducer: alertAndSheetReducer, + environment: .init() + ) + ) + } + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift index 1717547f024a..b93cb27b4dee 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift @@ -21,7 +21,7 @@ struct SharedState: Equatable { enum Tab { case counter, profile } struct CounterState: Equatable { - var alert: String? + var alert: AlertState? var count = 0 var maxCount = 0 var minCount = 0 @@ -105,10 +105,11 @@ let sharedStateCounterReducer = Reducer< return .none case .isPrimeButtonTapped: - state.alert = - isPrime(state.count) - ? "👍 The number \(state.count) is prime!" - : "👎 The number \(state.count) is not prime :(" + state.alert = .init( + title: isPrime(state.count) + ? "👍 The number \(state.count) is prime!" + : "👎 The number \(state.count) is not prime :(" + ) return .none } } @@ -204,14 +205,7 @@ struct SharedStateCounterView: View { .padding(16) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top) .navigationBarTitle("Shared State Demo") - .alert( - item: viewStore.binding( - get: { $0.alert.map(PrimeAlert.init(title:)) }, - send: .alertDismissed - ) - ) { alert in - SwiftUI.Alert(title: Text(alert.title)) - } + .alert(self.store.scope(state: { $0.alert }), dismiss: .alertDismissed) } } } @@ -249,11 +243,6 @@ struct SharedStateProfileView: View { } } -private struct PrimeAlert: Equatable, Identifiable { - let title: String - var id: String { self.title } -} - // MARK: - SwiftUI previews struct SharedState_Previews: PreviewProvider { diff --git a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift index 445ab78c6889..9be4a72cd38a 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift @@ -11,7 +11,7 @@ private let readMe = """ """ struct WebSocketState: Equatable { - var alert: String? + var alert: AlertState? var connectivityState = ConnectivityState.disconnected var messageToSend = "" var receivedMessages: [String] = [] @@ -109,7 +109,7 @@ let webSocketReducer = Reducer? var dateString: String? var fetchedNumberString: String? var isFetchInFlight = false var uuidString: String? } -enum MultipleDependenciesAction { +enum MultipleDependenciesAction: Equatable { case alertButtonTapped case alertDelayReceived case alertDismissed @@ -50,11 +50,11 @@ let multipleDependenciesReducer = Reducer< .eraseToEffect() case .alertDelayReceived: - state.alertTitle = "Here's an alert after a delay!" + state.alert = .init(title: "Here's an alert after a delay!") return .none case .alertDismissed: - state.alertTitle = nil + state.alert = nil return .none case .dateButtonTapped: @@ -106,14 +106,7 @@ struct MultipleDependenciesView: View { } Button("Delayed Alert") { viewStore.send(.alertButtonTapped) } - .alert( - item: viewStore.binding( - get: { $0.alertTitle.map(Alert.init(title:)) }, - send: { _ in .alertDismissed } - ) - ) { - SwiftUI.Alert(title: Text($0.title)) - } + .alert(self.store.scope(state: { $0.alert }), dismiss: .alertDismissed) } Section( @@ -139,11 +132,6 @@ struct MultipleDependenciesView: View { } .navigationBarTitle("System Environment") } - - struct Alert: Identifiable { - var title: String - var id: String { self.title } - } } struct MultipleDependenciesView_Previews: PreviewProvider { diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift index 3f3f87519e47..e8cdd76ea546 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift @@ -2,43 +2,12 @@ import ComposableArchitecture import SwiftUI struct DownloadComponentState: Equatable { - var alert: DownloadAlert? + var alert: AlertState? let id: ID var mode: Mode let url: URL } -struct DownloadAlert: Equatable, Identifiable { - var primaryButton: Button - var secondaryButton: Button - var title: String - - var id: String { self.title } - - struct Button: Equatable { - var action: DownloadComponentAction - var label: String - var type: `Type` - - enum `Type` { - case cancel - case `default` - case destructive - } - - func toSwiftUI(action: @escaping (DownloadComponentAction) -> Void) -> Alert.Button { - switch self.type { - case .cancel: - return .cancel(Text(self.label)) { action(self.action) } - case .default: - return .default(Text(self.label)) { action(self.action) } - case .destructive: - return .destructive(Text(self.label)) { action(self.action) } - } - } - } -} - enum Mode: Equatable { case downloaded case downloading(progress: Double) @@ -148,31 +117,20 @@ extension Reducer { } } -private let deleteAlert = DownloadAlert( - primaryButton: .init( - action: .alert(.deleteButtonTapped), - label: "Delete", - type: .destructive - ), - secondaryButton: nevermindButton, - title: "Do you want to delete this map from your offline storage?" +private let deleteAlert = AlertState( + title: "Do you want to delete this map from your offline storage?", + primaryButton: .destructive("Delete", send: .deleteButtonTapped), + secondaryButton: nevermindButton ) -private let cancelAlert = DownloadAlert( - primaryButton: .init( - action: .alert(.cancelButtonTapped), - label: "Cancel", - type: .destructive - ), - secondaryButton: nevermindButton, - title: "Do you want to cancel downloading this map?" +private let cancelAlert = AlertState( + title: "Do you want to cancel downloading this map?", + primaryButton: .cancel(send: .cancelButtonTapped), + secondaryButton: nevermindButton ) -let nevermindButton = DownloadAlert.Button( - action: .alert(.nevermindButtonTapped), - label: "Nevermind", - type: .default -) +let nevermindButton = AlertState.Button + .default("Nevermind", send: .nevermindButtonTapped) struct DownloadComponent: View { let store: Store, DownloadComponentAction> @@ -206,14 +164,9 @@ struct DownloadComponent: View { } } .alert( - item: viewStore.binding(get: { $0.alert }, send: .alert(.dismiss)) - ) { alert in - Alert( - title: Text(alert.title), - primaryButton: alert.primaryButton.toSwiftUI(action: viewStore.send), - secondaryButton: alert.secondaryButton.toSwiftUI(action: viewStore.send) - ) - } + self.store.scope(state: { $0.alert }, action: DownloadComponentAction.alert), + dismiss: .dismiss + ) } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift index dbeb01a19fa2..ffb2a9a10d0c 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift @@ -23,7 +23,7 @@ struct CityMap: Equatable, Identifiable { } struct CityMapState: Equatable, Identifiable { - var downloadAlert: DownloadAlert? + var downloadAlert: AlertState? var downloadMode: Mode var cityMap: CityMap diff --git a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift index 52f41272ec9c..09c40c1274ad 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift @@ -21,14 +21,14 @@ private let readMe = """ // MARK: - Favorite domain struct FavoriteState: Equatable, Identifiable where ID: Hashable { + var alert: AlertState? let id: ID var isFavorite: Bool - var error: FavoriteError? } enum FavoriteAction: Equatable { + case alertDismissed case buttonTapped - case errorDismissed case response(Result) } @@ -44,7 +44,7 @@ struct FavoriteCancelId: Hashable where ID: Hashable { /// A wrapper for errors that occur when favoriting. struct FavoriteError: Equatable, Error, Identifiable { - let error: Error + let error: NSError var localizedDescription: String { self.error.localizedDescription } var id: String { self.error.localizedDescription } static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } @@ -62,23 +62,23 @@ extension Reducer { Reducer, FavoriteAction, FavoriteEnvironment> { state, action, environment in switch action { + case .alertDismissed: + state.alert = nil + state.isFavorite.toggle() + return .none + case .buttonTapped: state.isFavorite.toggle() return environment.request(state.id, state.isFavorite) .receive(on: environment.mainQueue) - .mapError(FavoriteError.init(error:)) + .mapError { FavoriteError(error: $0 as NSError) } .catchToEffect() .map(FavoriteAction.response) .cancellable(id: FavoriteCancelId(id: state.id), cancelInFlight: true) - case .errorDismissed: - state.error = nil - state.isFavorite.toggle() - return .none - case let .response(.failure(error)): - state.error = error + state.alert = .init(title: error.localizedDescription) return .none case let .response(.success(isFavorite)): @@ -99,9 +99,7 @@ struct FavoriteButton: View where ID: Hashable { Button(action: { viewStore.send(.buttonTapped) }) { Image(systemName: viewStore.isFavorite ? "heart.fill" : "heart") } - .alert(item: viewStore.binding(get: { $0.error }, send: .errorDismissed)) { - Alert(title: Text($0.localizedDescription)) - } + .alert(self.store.scope(state: { $0.alert }), dismiss: .alertDismissed) } } } @@ -109,14 +107,14 @@ struct FavoriteButton: View where ID: Hashable { // MARK: Feature domain - struct EpisodeState: Equatable, Identifiable { - var error: FavoriteError? + var alert: AlertState? let id: UUID var isFavorite: Bool let title: String var favorite: FavoriteState { - get { .init(id: self.id, isFavorite: self.isFavorite, error: self.error) } - set { (self.isFavorite, self.error) = (newValue.isFavorite, newValue.error) } + get { .init(alert: self.alert, id: self.id, isFavorite: self.isFavorite) } + set { (self.alert, self.isFavorite) = (newValue.alert, newValue.isFavorite) } } } diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AlertsAndActionSheetsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AlertsAndActionSheetsTests.swift new file mode 100644 index 000000000000..2ebbea9fcb83 --- /dev/null +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/01-GettingStarted-AlertsAndActionSheetsTests.swift @@ -0,0 +1,57 @@ +import Combine +import ComposableArchitecture +import SwiftUI +import XCTest + +@testable import SwiftUICaseStudies + +class AlertsAndActionSheetsTests: XCTestCase { + func testAlert() { + let store = TestStore( + initialState: AlertAndSheetState(), + reducer: alertAndSheetReducer, + environment: AlertAndSheetEnvironment() + ) + + store.assert( + .send(.alertButtonTapped) { + $0.alert = .init( + title: "Alert!", + message: "This is an alert", + primaryButton: .cancel(), + secondaryButton: .default("Increment", send: .incrementButtonTapped) + ) + }, + .send(.incrementButtonTapped) { + $0.alert = nil + $0.count = 1 + } + ) + } + + func testActionSheet() { + let store = TestStore( + initialState: AlertAndSheetState(), + reducer: alertAndSheetReducer, + environment: AlertAndSheetEnvironment() + ) + + store.assert( + .send(.actionSheetButtonTapped) { + $0.actionSheet = .init( + title: "Action sheet", + message: "This is an action sheet.", + buttons: [ + .cancel(), + .default("Increment", send: .incrementButtonTapped), + .default("Decrement", send: .decrementButtonTapped), + ] + ) + }, + .send(.incrementButtonTapped) { + $0.actionSheet = nil + $0.count = 1 + } + ) + } +} diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift index 7ef3b38ca1e5..a10b015d8eaa 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/02-Effects-WebSocketTests.swift @@ -96,7 +96,7 @@ class WebSocketTests: XCTestCase { $0.messageToSend = "" }, .receive(.sendResponse(NSError(domain: "", code: 1))) { - $0.alert = "Could not send socket message. Try again." + $0.alert = .init(title: "Could not send socket message. Try again.") }, // Disconnect from the socket diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift index be6dc6144c2f..2a819462a2d2 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableFavoritingTests.swift @@ -65,11 +65,13 @@ class ReusableComponentsFavoritingTests: XCTestCase { .receive( .episode(index: 2, action: .favorite(.response(.failure(FavoriteError(error: error))))) ) { - $0.episodes[2].error = FavoriteError(error: error) + $0.episodes[2].alert = .init( + title: "The operation couldn’t be completed. (co.pointfree error -1.)" + ) }, - .send(.episode(index: 2, action: .favorite(.errorDismissed))) { - $0.episodes[2].error = nil + .send(.episode(index: 2, action: .favorite(.alertDismissed))) { + $0.episodes[2].alert = nil $0.episodes[2].isFavorite = false } ) diff --git a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift similarity index 83% rename from Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift rename to Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift index d881479b4d05..87b8f18416a4 100644 --- a/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ResuableOfflineDownloadsTests.swift +++ b/Examples/CaseStudies/SwiftUICaseStudiesTests/04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift @@ -20,7 +20,6 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { func testDownloadFlow() { let store = TestStore( initialState: DownloadComponentState( - alert: nil, id: 1, mode: .notDownloaded, url: URL(string: "https://www.pointfree.co")! @@ -57,7 +56,6 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { func testDownloadThrottling() { let store = TestStore( initialState: DownloadComponentState( - alert: nil, id: 1, mode: .notDownloaded, url: URL(string: "https://www.pointfree.co")! @@ -99,7 +97,6 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { func testCancelDownloadFlow() { let store = TestStore( initialState: DownloadComponentState( - alert: nil, id: 1, mode: .notDownloaded, url: URL(string: "https://www.pointfree.co")! @@ -120,14 +117,10 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { }, .send(.buttonTapped) { - $0.alert = DownloadAlert( - primaryButton: .init( - action: .alert(.cancelButtonTapped), label: "Cancel", type: .destructive - ), - secondaryButton: .init( - action: .alert(.nevermindButtonTapped), label: "Nevermind", type: .default - ), - title: "Do you want to cancel downloading this map?" + $0.alert = .init( + title: "Do you want to cancel downloading this map?", + primaryButton: .cancel(send: .cancelButtonTapped), + secondaryButton: .default("Nevermind", send: .nevermindButtonTapped) ) }, @@ -143,7 +136,6 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { func testDownloadFinishesWhileTryingToCancel() { let store = TestStore( initialState: DownloadComponentState( - alert: nil, id: 1, mode: .notDownloaded, url: URL(string: "https://www.pointfree.co")! @@ -164,14 +156,10 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { }, .send(.buttonTapped) { - $0.alert = DownloadAlert( - primaryButton: .init( - action: .alert(.cancelButtonTapped), label: "Cancel", type: .destructive - ), - secondaryButton: .init( - action: .alert(.nevermindButtonTapped), label: "Nevermind", type: .default - ), - title: "Do you want to cancel downloading this map?" + $0.alert = .init( + title: "Do you want to cancel downloading this map?", + primaryButton: .cancel(send: .cancelButtonTapped), + secondaryButton: .default("Nevermind", send: .nevermindButtonTapped) ) }, @@ -188,7 +176,6 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { func testDeleteDownloadFlow() { let store = TestStore( initialState: DownloadComponentState( - alert: nil, id: 1, mode: .downloaded, url: URL(string: "https://www.pointfree.co")! @@ -205,14 +192,10 @@ class ReusableComponentsDownloadComponentTests: XCTestCase { store.assert( .send(.buttonTapped) { - $0.alert = DownloadAlert( - primaryButton: .init( - action: .alert(.deleteButtonTapped), label: "Delete", type: .destructive - ), - secondaryButton: .init( - action: .alert(.nevermindButtonTapped), label: "Nevermind", type: .default - ), - title: "Do you want to delete this map from your offline storage?" + $0.alert = .init( + title: "Do you want to delete this map from your offline storage?", + primaryButton: .destructive("Delete", send: .deleteButtonTapped), + secondaryButton: .default("Nevermind", send: .nevermindButtonTapped) ) }, diff --git a/Examples/LocationManager/Common/AppCore.swift b/Examples/LocationManager/Common/AppCore.swift index 08082767d353..0230ea69c5f4 100644 --- a/Examples/LocationManager/Common/AppCore.swift +++ b/Examples/LocationManager/Common/AppCore.swift @@ -19,14 +19,14 @@ public struct PointOfInterest: Equatable, Hashable { } public struct AppState: Equatable { - public var alert: String? + public var alert: AlertState? public var isRequestingCurrentLocation = false public var pointOfInterestCategory: MKPointOfInterestCategory? public var pointsOfInterest: [PointOfInterest] = [] public var region: CoordinateRegion? public init( - alert: String? = nil, + alert: AlertState? = nil, isRequestingCurrentLocation: Bool = false, pointOfInterestCategory: MKPointOfInterestCategory? = nil, pointsOfInterest: [PointOfInterest] = [], @@ -99,7 +99,7 @@ public let appReducer = Reducer { state, ac case .currentLocationButtonTapped: guard environment.locationManager.locationServicesEnabled() else { - state.alert = "Location services are turned off." + state.alert = .init(title: "Location services are turned off.") return .none } @@ -117,11 +117,11 @@ public let appReducer = Reducer { state, ac #endif case .restricted: - state.alert = "Please give us access to your location in settings." + state.alert = .init(title: "Please give us access to your location in settings.") return .none case .denied: - state.alert = "Please give us access to your location in settings." + state.alert = .init(title: "Please give us access to your location in settings.") return .none case .authorizedAlways, .authorizedWhenInUse: @@ -148,7 +148,7 @@ public let appReducer = Reducer { state, ac return .none case .localSearchResponse(.failure): - state.alert = "Could not perform search. Please try again." + state.alert = .init(title: "Could not perform search. Please try again.") return .none case .locationManager: @@ -204,7 +204,9 @@ private let locationManagerReducer = Reducer ? var facingDirection: Direction? var initialAttitude: Attitude? var isRecording = false @@ -45,14 +45,14 @@ let appReducer = Reducer { state, action, e switch action { case .alertDismissed: - state.alertTitle = nil + state.alert = nil return .none case .motionUpdate(.failure): - state.alertTitle = """ + state.alert = .init(title: """ We encountered a problem with the motion manager. Make sure you run this demo on a real \ device, not the simulator. - """ + """) state.isRecording = false return .none @@ -143,14 +143,7 @@ struct AppView: View { } .padding() .background(viewStore.facingDirection == .backward ? Color.green : Color.clear) - .alert( - item: viewStore.binding( - get: { $0.alertTitle.map(AppAlert.init(title:)) }, - send: .alertDismissed - ) - ) { alert in - Alert(title: Text(alert.title)) - } + .alert(self.store.scope(state: { $0.alert }), dismiss: .alertDismissed) } } } @@ -178,8 +171,8 @@ struct AppView_Previews: PreviewProvider { // sends a bunch of data on some sine curves. var isStarted = false let mockMotionManager = MotionManager.mock( - create: { _ in .fireAndForget { } }, - destroy: { _ in .fireAndForget { } }, + create: { _ in .fireAndForget {} }, + destroy: { _ in .fireAndForget {} }, deviceMotion: { _ in nil }, startDeviceMotionUpdates: { _, _, _ in isStarted = true diff --git a/Examples/MotionManager/MotionManagerTests/MotionTests.swift b/Examples/MotionManager/MotionManagerTests/MotionTests.swift index b37d7a176859..c3d81dc668ed 100644 --- a/Examples/MotionManager/MotionManagerTests/MotionTests.swift +++ b/Examples/MotionManager/MotionManagerTests/MotionTests.swift @@ -106,7 +106,7 @@ class MotionTests: XCTestCase { $0.z = [0, 0] $0.facingDirection = .backward }, - + .send(.recordingButtonTapped) { $0.facingDirection = nil $0.initialAttitude = nil diff --git a/Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme b/Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme index c0c81c8deef5..9b28e4d35cba 100644 --- a/Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme +++ b/Examples/Search/Search.xcodeproj/xcshareddata/xcschemes/Search.xcscheme @@ -1,6 +1,6 @@ ? var isRecording = false var speechRecognizerAuthorizationStatus = SFSpeechRecognizerAuthorizationStatus.notDetermined var transcribedText = "" @@ -33,12 +33,12 @@ let appReducer = Reducer { state, action, e switch action { case .dismissAuthorizationStateAlert: - state.authorizationStateAlert = nil + state.alert = nil return .none case .speech(.failure(.couldntConfigureAudioSession)), .speech(.failure(.couldntStartAudioEngine)): - state.authorizationStateAlert = "Problem with audio device. Please try again." + state.alert = .init(title: "Problem with audio device. Please try again.") return .none case .recordButtonTapped: @@ -66,7 +66,7 @@ let appReducer = Reducer { state, action, e } case let .speech(.failure(error)): - state.authorizationStateAlert = "An error occured while transcribing. Please try again." + state.alert = .init(title: "An error occured while transcribing. Please try again.") return environment.speechClient.finishTask(SpeechRecognitionId()) .fireAndForget() @@ -76,17 +76,17 @@ let appReducer = Reducer { state, action, e switch status { case .notDetermined: - state.authorizationStateAlert = "Try again." + state.alert = .init(title: "Try again.") return .none case .denied: - state.authorizationStateAlert = """ + state.alert = .init(title: """ You denied access to speech recognition. This app needs access to transcribe your speech. - """ + """) return .none case .restricted: - state.authorizationStateAlert = "Your device does not allow speech recognition." + state.alert = .init(title: "Your device does not allow speech recognition.") return .none case .authorized: @@ -144,14 +144,7 @@ struct SpeechRecognitionView: View { } } .padding() - .alert( - item: viewStore.binding( - get: { $0.authorizationStateAlert.map(AuthorizationStateAlert.init(title:)) }, - send: .dismissAuthorizationStateAlert - ) - ) { alert in - Alert(title: Text(alert.title)) - } + .alert(self.store.scope(state: { $0.alert }), dismiss: .dismissAuthorizationStateAlert) } } } diff --git a/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift b/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift index 320f39f03fac..20c45be18d2c 100644 --- a/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift +++ b/Examples/SpeechRecognition/SpeechRecognitionTests/SpeechRecognitionTests.swift @@ -26,8 +26,11 @@ class SpeechRecognitionTests: XCTestCase { }, .do { self.scheduler.advance() }, .receive(.speechRecognizerAuthorizationStatusResponse(.denied)) { - $0.authorizationStateAlert = - "You denied access to speech recognition. This app needs access to transcribe your speech." + $0.alert = .init( + title: """ + You denied access to speech recognition. This app needs access to transcribe your speech. + """ + ) $0.isRecording = false $0.speechRecognizerAuthorizationStatus = .denied } @@ -52,7 +55,7 @@ class SpeechRecognitionTests: XCTestCase { }, .do { self.scheduler.advance() }, .receive(.speechRecognizerAuthorizationStatusResponse(.restricted)) { - $0.authorizationStateAlert = "Your device does not allow speech recognition." + $0.alert = .init(title: "Your device does not allow speech recognition.") $0.isRecording = false $0.speechRecognizerAuthorizationStatus = .restricted } @@ -136,7 +139,7 @@ class SpeechRecognitionTests: XCTestCase { .do { self.recognitionTaskSubject.send(completion: .failure(.couldntConfigureAudioSession)) }, .receive(.speech(.failure(.couldntConfigureAudioSession))) { - $0.authorizationStateAlert = "Problem with audio device. Please try again." + $0.alert = .init(title: "Problem with audio device. Please try again.") }, .do { self.recognitionTaskSubject.send(completion: .finished) } @@ -168,7 +171,7 @@ class SpeechRecognitionTests: XCTestCase { .do { self.recognitionTaskSubject.send(completion: .failure(.couldntStartAudioEngine)) }, .receive(.speech(.failure(.couldntStartAudioEngine))) { - $0.authorizationStateAlert = "Problem with audio device. Please try again." + $0.alert = .init(title: "Problem with audio device. Please try again.") }, .do { self.recognitionTaskSubject.send(completion: .finished) } diff --git a/Examples/TicTacToe/Sources/Common/AlertData.swift b/Examples/TicTacToe/Sources/Common/AlertData.swift deleted file mode 100644 index c77639c2508d..000000000000 --- a/Examples/TicTacToe/Sources/Common/AlertData.swift +++ /dev/null @@ -1,9 +0,0 @@ -public struct AlertData: Equatable, Identifiable { - public let title: String - - public init(title: String) { - self.title = title - } - - public var id: String { self.title } -} diff --git a/Examples/TicTacToe/Sources/Core/AppCore.swift b/Examples/TicTacToe/Sources/Core/AppCore.swift index 6920316c17a0..42c1651f449d 100644 --- a/Examples/TicTacToe/Sources/Core/AppCore.swift +++ b/Examples/TicTacToe/Sources/Core/AppCore.swift @@ -6,7 +6,7 @@ import NewGameCore public struct AppState: Equatable { public var login: LoginState? = LoginState() - public var newGame: NewGameState? = nil + public var newGame: NewGameState? public init() {} } diff --git a/Examples/TicTacToe/Sources/Core/GameCore.swift b/Examples/TicTacToe/Sources/Core/GameCore.swift index dfe8344f5293..06c384d7040b 100644 --- a/Examples/TicTacToe/Sources/Core/GameCore.swift +++ b/Examples/TicTacToe/Sources/Core/GameCore.swift @@ -93,9 +93,9 @@ extension Array where Element == [Player?] { ] for condition in winConditions { - let matchCount = - condition + let matches = condition .map { self[$0 % 3][$0 / 3] } + let matchCount = matches .filter { $0 == player } .count diff --git a/Examples/TicTacToe/Sources/Core/LoginCore.swift b/Examples/TicTacToe/Sources/Core/LoginCore.swift index 0fb58b6dc203..4cad4db43eee 100644 --- a/Examples/TicTacToe/Sources/Core/LoginCore.swift +++ b/Examples/TicTacToe/Sources/Core/LoginCore.swift @@ -5,12 +5,12 @@ import TicTacToeCommon import TwoFactorCore public struct LoginState: Equatable { - public var alertData: AlertData? = nil + public var alert: AlertState? public var email = "" public var isFormValid = false public var isLoginRequestInFlight = false public var password = "" - public var twoFactor: TwoFactorState? = nil + public var twoFactor: TwoFactorState? public init() {} } @@ -55,7 +55,7 @@ public let loginReducer = twoFactorReducer state, action, environment in switch action { case .alertDismissed: - state.alertData = nil + state.alert = nil return .none case let .emailChanged(email): @@ -71,7 +71,7 @@ public let loginReducer = twoFactorReducer return .none case let .loginResponse(.failure(error)): - state.alertData = AlertData(title: error.localizedDescription) + state.alert = .init(title: error.localizedDescription) state.isLoginRequestInFlight = false return .none diff --git a/Examples/TicTacToe/Sources/Core/NewGameCore.swift b/Examples/TicTacToe/Sources/Core/NewGameCore.swift index bacf5f5b3e7e..f791f526b8ce 100644 --- a/Examples/TicTacToe/Sources/Core/NewGameCore.swift +++ b/Examples/TicTacToe/Sources/Core/NewGameCore.swift @@ -3,7 +3,7 @@ import GameCore import TicTacToeCommon public struct NewGameState: Equatable { - public var game: GameState? = nil + public var game: GameState? public var oPlayerName = "" public var xPlayerName = "" diff --git a/Examples/TicTacToe/Sources/Core/TwoFactorCore.swift b/Examples/TicTacToe/Sources/Core/TwoFactorCore.swift index 2343a6b5da5d..0f157dee9dd9 100644 --- a/Examples/TicTacToe/Sources/Core/TwoFactorCore.swift +++ b/Examples/TicTacToe/Sources/Core/TwoFactorCore.swift @@ -5,7 +5,7 @@ import Dispatch import TicTacToeCommon public struct TwoFactorState: Equatable { - public var alertData: AlertData? = nil + public var alert: AlertState? public var code = "" public var isFormValid = false public var isTwoFactorRequestInFlight = false @@ -41,7 +41,7 @@ public let twoFactorReducer = Reducer? var email: String var isActivityIndicatorVisible: Bool var isFormDisabled: Bool @@ -82,22 +82,18 @@ public struct LoginView: View { } .disabled(viewStore.isFormDisabled) } - .navigationBarTitle("Login") - // NB: This is necessary to clear the bar items from the game. - .navigationBarItems(trailing: EmptyView()) - .alert( - item: viewStore.binding(get: { $0.alertData }, send: .alertDismissed) - ) { alertData in - Alert(title: Text(alertData.title)) - } + .alert(self.store.scope(state: { $0.alert }), dismiss: .alertDismissed) } + .navigationBarTitle("Login") + // NB: This is necessary to clear the bar items from the game. + .navigationBarItems(trailing: EmptyView()) } } extension LoginState { var view: LoginView.ViewState { LoginView.ViewState( - alertData: self.alertData, + alert: self.alert, email: self.email, isActivityIndicatorVisible: self.isLoginRequestInFlight, isFormDisabled: self.isLoginRequestInFlight, diff --git a/Examples/TicTacToe/Sources/Views-SwiftUI/TwoFactorSwiftView.swift b/Examples/TicTacToe/Sources/Views-SwiftUI/TwoFactorSwiftView.swift index e737750c7ceb..c147370941c6 100644 --- a/Examples/TicTacToe/Sources/Views-SwiftUI/TwoFactorSwiftView.swift +++ b/Examples/TicTacToe/Sources/Views-SwiftUI/TwoFactorSwiftView.swift @@ -6,14 +6,14 @@ import TwoFactorCore public struct TwoFactorView: View { struct ViewState: Equatable { - var alertData: AlertData? + var alert: AlertState? var code: String var isActivityIndicatorVisible: Bool var isFormDisabled: Bool var isSubmitButtonDisabled: Bool } - enum ViewAction { + enum ViewAction: Equatable { case alertDismissed case codeChanged(String) case submitButtonTapped @@ -56,20 +56,16 @@ public struct TwoFactorView: View { } } .disabled(viewStore.isFormDisabled) - .navigationBarTitle("Two Factor Confirmation") - .alert( - item: viewStore.binding(get: { $0.alertData }, send: .alertDismissed) - ) { alertData in - Alert(title: Text(alertData.title)) - } + .alert(self.store.scope(state: { $0.alert }), dismiss: .alertDismissed) } + .navigationBarTitle("Two Factor Confirmation") } } extension TwoFactorState { var view: TwoFactorView.ViewState { TwoFactorView.ViewState( - alertData: self.alertData, + alert: self.alert, code: self.code, isActivityIndicatorVisible: self.isTwoFactorRequestInFlight, isFormDisabled: self.isTwoFactorRequestInFlight, diff --git a/Examples/TicTacToe/Sources/Views-UIKit/LoginViewController.swift b/Examples/TicTacToe/Sources/Views-UIKit/LoginViewController.swift index 7fd314eedb39..b46e4df48a80 100644 --- a/Examples/TicTacToe/Sources/Views-UIKit/LoginViewController.swift +++ b/Examples/TicTacToe/Sources/Views-UIKit/LoginViewController.swift @@ -7,7 +7,7 @@ import UIKit class LoginViewController: UIViewController { struct ViewState: Equatable { - let alertData: AlertData? + let alert: AlertState? let email: String? let isActivityIndicatorHidden: Bool let isEmailTextFieldEnabled: Bool @@ -131,13 +131,13 @@ class LoginViewController: UIViewController { .assign(to: \.isHidden, on: activityIndicator) .store(in: &self.cancellables) - self.viewStore.publisher.alertData - .sink { [weak self] alertData in + self.viewStore.publisher.alert + .sink { [weak self] alert in guard let self = self else { return } - guard let alertData = alertData else { return } + guard let alert = alert else { return } let alertController = UIAlertController( - title: alertData.title, message: nil, preferredStyle: .alert) + title: alert.title, message: nil, preferredStyle: .alert) alertController.addAction( UIAlertAction( title: "Ok", style: .default, @@ -189,7 +189,7 @@ class LoginViewController: UIViewController { extension LoginState { var view: LoginViewController.ViewState { .init( - alertData: self.alertData, + alert: self.alert, email: self.email, isActivityIndicatorHidden: !self.isLoginRequestInFlight, isEmailTextFieldEnabled: !self.isLoginRequestInFlight, diff --git a/Examples/TicTacToe/Sources/Views-UIKit/TwoFactorViewController.swift b/Examples/TicTacToe/Sources/Views-UIKit/TwoFactorViewController.swift index 82a36be76edf..97269a523050 100644 --- a/Examples/TicTacToe/Sources/Views-UIKit/TwoFactorViewController.swift +++ b/Examples/TicTacToe/Sources/Views-UIKit/TwoFactorViewController.swift @@ -6,7 +6,7 @@ import UIKit public final class TwoFactorViewController: UIViewController { struct ViewState: Equatable { - let alertData: AlertData? + let alert: AlertState? let code: String? let isActivityIndicatorHidden: Bool let isLoginButtonEnabled: Bool @@ -87,13 +87,13 @@ public final class TwoFactorViewController: UIViewController { .assign(to: \.isEnabled, on: loginButton) .store(in: &self.cancellables) - self.viewStore.publisher.alertData - .sink { [weak self] alertData in + self.viewStore.publisher.alert + .sink { [weak self] alert in guard let self = self else { return } - guard let alertData = alertData else { return } + guard let alert = alert else { return } let alertController = UIAlertController( - title: alertData.title, message: nil, preferredStyle: .alert) + title: alert.title, message: nil, preferredStyle: .alert) alertController.addAction( UIAlertAction( title: "Ok", style: .default, @@ -117,7 +117,7 @@ public final class TwoFactorViewController: UIViewController { extension TwoFactorState { var view: TwoFactorViewController.ViewState { .init( - alertData: self.alertData, + alert: self.alert, code: self.code, isActivityIndicatorHidden: !self.isTwoFactorRequestInFlight, isLoginButtonEnabled: self.isFormValid && !self.isTwoFactorRequestInFlight diff --git a/Examples/TicTacToe/Tests/LoginSwiftUITests.swift b/Examples/TicTacToe/Tests/LoginSwiftUITests.swift index 0c8cdb1acf1c..39acf88d919a 100644 --- a/Examples/TicTacToe/Tests/LoginSwiftUITests.swift +++ b/Examples/TicTacToe/Tests/LoginSwiftUITests.swift @@ -119,13 +119,13 @@ class LoginSwiftUITests: XCTestCase { self.scheduler.advance() }, .receive(.loginResponse(.failure(.invalidUserPassword))) { - $0.alertData = AlertData( + $0.alert = .init( title: AuthenticationError.invalidUserPassword.localizedDescription) $0.isActivityIndicatorVisible = false $0.isFormDisabled = false }, .send(.alertDismissed) { - $0.alertData = nil + $0.alert = nil } ) } diff --git a/Examples/TicTacToe/Tests/TwoFactorSwiftUITests.swift b/Examples/TicTacToe/Tests/TwoFactorSwiftUITests.swift index 1f6bde8e4c13..401ae128eaf7 100644 --- a/Examples/TicTacToe/Tests/TwoFactorSwiftUITests.swift +++ b/Examples/TicTacToe/Tests/TwoFactorSwiftUITests.swift @@ -88,12 +88,12 @@ class TwoFactorSwiftUITests: XCTestCase { self.scheduler.advance() }, .receive(.twoFactorResponse(.failure(.invalidTwoFactor))) { - $0.alertData = AlertData(title: AuthenticationError.invalidTwoFactor.localizedDescription) + $0.alert = .init(title: AuthenticationError.invalidTwoFactor.localizedDescription) $0.isActivityIndicatorVisible = false $0.isFormDisabled = false }, .send(.alertDismissed) { - $0.alertData = nil + $0.alert = nil } ) } diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj b/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj index 3a9ca3b4e98f..6a153b17dd2a 100644 --- a/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj +++ b/Examples/TicTacToe/TicTacToe.xcodeproj/project.pbxproj @@ -88,7 +88,6 @@ DC9195A324201B3B00A5BE1F /* LoginSwiftUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9195A124201B3B00A5BE1F /* LoginSwiftUITests.swift */; }; DCAF10D1242027D400483288 /* TwoFactorSwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCAF10C8242027D400483288 /* TwoFactorSwiftUI.framework */; }; DCAF10D8242027D400483288 /* TwoFactorSwiftUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAF10D7242027D400483288 /* TwoFactorSwiftUITests.swift */; }; - DCAF114E2420294700483288 /* AlertData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAF10E52420285A00483288 /* AlertData.swift */; }; DCAF1152242029EE00483288 /* TwoFactorSwiftView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAF1151242029EE00483288 /* TwoFactorSwiftView.swift */; }; DCAF115D24202A5700483288 /* AuthenticationClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC919425242012D200A5BE1F /* AuthenticationClient.framework */; }; DCAF115E24202A5C00483288 /* TicTacToeCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCAF11312420293300483288 /* TicTacToeCommon.framework */; }; @@ -600,7 +599,6 @@ DCAF10C8242027D400483288 /* TwoFactorSwiftUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TwoFactorSwiftUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DCAF10D0242027D400483288 /* TwoFactorSwiftUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TwoFactorSwiftUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DCAF10D7242027D400483288 /* TwoFactorSwiftUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwoFactorSwiftUITests.swift; sourceTree = ""; }; - DCAF10E52420285A00483288 /* AlertData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertData.swift; sourceTree = ""; }; DCAF10EC242028DE00483288 /* AppCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DCAF110E242028E600483288 /* AppSwiftUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppSwiftUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DCAF11312420293300483288 /* TicTacToeCommon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TicTacToeCommon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -934,7 +932,6 @@ isa = PBXGroup; children = ( DCAF11C72420335500483288 /* ActivityIndicator.swift */, - DCAF10E52420285A00483288 /* AlertData.swift */, ); path = Common; sourceTree = ""; @@ -1965,7 +1962,6 @@ buildActionMask = 2147483647; files = ( DCAF11CA2420335500483288 /* ActivityIndicator.swift in Sources */, - DCAF114E2420294700483288 /* AlertData.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/AppCore.xcscheme b/Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/AppCore.xcscheme index bca2876739d9..33699a02bb62 100644 --- a/Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/AppCore.xcscheme +++ b/Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/AppCore.xcscheme @@ -1,6 +1,6 @@ ? var audioRecorderPermission = RecorderPermission.undetermined var currentRecording: CurrentRecording? var voiceMemos: [VoiceMemo] = [] @@ -54,7 +54,7 @@ let voiceMemosReducer = Reducer? +/// +/// // Your other state +/// } +/// +/// Then, in the reducer you can construct an `ActionSheetState` value to represent the action +/// sheet you want to show to the user: +/// +/// let appReducer = Reducer { state, action, env in +/// switch action +/// case .cancelTapped: +/// state.actionSheet = nil +/// return .none +/// +/// case .deleteTapped: +/// state.actionSheet = nil +/// // Do deletion logic... +/// +/// case .favoriteTapped: +/// state.actionSheet = nil +/// // Do favoriting logic +/// +/// case .infoTapped: +/// state.actionSheet = .init( +/// title: "What would you like to do?", +/// buttons: [ +/// .default("Favorite", send: .favoriteTapped), +/// .destructive("Delete", send: .deleteTapped), +/// .cancel(), +/// ] +/// ) +/// return .none +/// } +/// } +/// +/// And then, in your view you can use the `.actionSheet(_:send:dismiss:)` method on `View` in order +/// to present the action sheet in a way that works best with the Composable Architecture: +/// +/// Button("Info") { viewStore.send(.infoTapped) } +/// .actionSheet( +/// self.store.scope(state: \.actionSheet), +/// dismiss: .cancelTapped +/// ) +/// +/// This makes your reducer in complete control of when the action sheet is shown or dismissed, and +/// makes it so that any choice made in the action sheet is automatically fed back into the reducer +/// so that you can handle its logic. +/// +/// Even better, you can instantly write tests that your action sheet behavior works as expected: +/// +/// let store = TestStore( +/// initialState: AppState(), +/// reducer: appReducer, +/// environment: .mock +/// ) +/// +/// store.assert( +/// .send(.infoTapped) { +/// $0.actionSheet = .init( +/// title: "What would you like to do?", +/// buttons: [ +/// .default("Favorite", send: .favoriteTapped), +/// .destructive("Delete", send: .deleteTapped), +/// .cancel(), +/// ] +/// ) +/// }, +/// .send(.favoriteTapped) { +/// $0.actionSheet = nil +/// // Also verify that favoriting logic executed correctly +/// } +/// ) +/// +@available(iOS 13, *) +@available(macCatalyst 13, *) +@available(macOS, unavailable) +@available(tvOS 13, *) +@available(watchOS 6, *) +public struct ActionSheetState { + public var buttons: [Button] + public var message: String? + public var title: String + + public init( + title: String, + message: String? = nil, + buttons: [Button] + ) { + self.buttons = buttons + self.message = message + self.title = title + } + + public typealias Button = AlertState.Button +} + +@available(iOS 13, *) +@available(macCatalyst 13, *) +@available(macOS, unavailable) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ActionSheetState: Equatable where Action: Equatable {} + +@available(iOS 13, *) +@available(macCatalyst 13, *) +@available(macOS, unavailable) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ActionSheetState: Hashable where Action: Hashable {} + +@available(iOS 13, *) +@available(macCatalyst 13, *) +@available(macOS, unavailable) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ActionSheetState: Identifiable where Action: Hashable { + public var id: Self { self } +} + +extension View { + /// Displays an action sheet when the store's state becomes non-`nil`, and dismisses it when it + /// becomes `nil`. + /// + /// - Parameters: + /// - store: A store that describes if the action sheet is shown or dismissed. + /// - dismissal: An action to send when the action sheet is dismissed through non-user actions, + /// such as when an action sheet is automatically dismissed by the system. + @available(iOS 13, *) + @available(macCatalyst 13, *) + @available(macOS, unavailable) + @available(tvOS 13, *) + @available(watchOS 6, *) + public func actionSheet( + _ store: Store?, Action>, + dismiss: Action + ) -> some View { + + let viewStore = ViewStore(store, removeDuplicates: { ($0 == nil) != ($1 == nil) }) + return self.actionSheet( + isPresented: Binding( + get: { viewStore.state != nil }, + set: { + guard !$0 else { return } + viewStore.send(dismiss) + }), + content: { viewStore.state?.toSwiftUI(send: viewStore.send) ?? ActionSheet(title: Text("")) } + ) + } +} + +@available(iOS 13, *) +@available(macCatalyst 13, *) +@available(macOS, unavailable) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ActionSheetState { + fileprivate func toSwiftUI(send: @escaping (Action) -> Void) -> SwiftUI.ActionSheet { + SwiftUI.ActionSheet( + title: Text(self.title), + message: self.message.map { Text($0) }, + buttons: self.buttons.map { + $0.toSwiftUI(send: send) + } + ) + } +} diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift new file mode 100644 index 000000000000..1fbbe756a828 --- /dev/null +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -0,0 +1,231 @@ +import SwiftUI + +/// A data type that describes the state of an alert that can be shown to the user. The `Action` +/// 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 +/// 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, +/// which greatly simplifies how data flows through your application, and gives you instant +/// testability on all parts of your application. +/// +/// To use this API, you model all the alert actions in your domain's action enum: +/// +/// enum AppAction: Equatable { +/// case cancelTapped +/// case confirmTapped +/// case deleteTapped +/// +/// // Your other actions +/// } +/// +/// And you model the state for showing the alert in your domain's state, and it can start off +/// `nil`: +/// +/// struct AppState: Equatable { +/// var alert = AlertState? +/// +/// // Your other state +/// } +/// +/// Then, in the reducer you can construct an `AlertState` value to represent the alert you want +/// to show to the user: +/// +/// let appReducer = Reducer { state, action, env in +/// switch action +/// case .cancelTapped: +/// state.alert = nil +/// return .none +/// +/// case .confirmTapped: +/// state.alert = nil +/// // Do deletion logic... +/// +/// case .deleteTapped: +/// state.alert = .init( +/// title: "Delete", +/// message: "Are you sure you want to delete this? It cannot be undone.", +/// primaryButton: .default("Confirm", send: .confirmTapped), +/// secondaryButton: .cancel() +/// ) +/// return .none +/// } +/// } +/// +/// And then, in your view you can use the `.alert(_:send:dismiss:)` method on `View` in order +/// to present the alert in a way that works best with the Composable Architecture: +/// +/// Button("Delete") { viewStore.send(.deleteTapped) } +/// .alert( +/// viewStore.scope(state: \.alert), +/// dismiss: .cancelTapped +/// ) +/// +/// This makes your reducer in complete control of when the alert is shown or dismissed, and makes +/// it so that any choice made in the alert is automatically fed back into the reducer so that you +/// can handle its logic. +/// +/// Even better, you can instantly write tests that your alert behavior works as expected: +/// +/// let store = TestStore( +/// initialState: AppState(), +/// reducer: appReducer, +/// environment: .mock +/// ) +/// +/// store.assert( +/// .send(.deleteTapped) { +/// $0.alert = .init( +/// title: "Delete", +/// message: "Are you sure you want to delete this? It cannot be undone.", +/// primaryButton: .default("Confirm", send: .confirmTapped), +/// secondaryButton: .cancel(send: .cancelTapped) +/// ) +/// }, +/// .send(.deleteTapped) { +/// $0.alert = nil +/// // Also verify that delete logic executed correctly +/// } +/// ) +/// +public struct AlertState { + public var message: String? + public var primaryButton: Button? + public var secondaryButton: Button? + public var title: String + + public init( + title: String, + message: String? = nil, + dismissButton: Button? = nil + ) { + self.title = title + self.message = message + self.primaryButton = dismissButton + } + + public init( + title: String, + message: String? = nil, + primaryButton: Button, + secondaryButton: Button + ) { + self.title = title + self.message = message + self.primaryButton = primaryButton + self.secondaryButton = secondaryButton + } + + public struct Button { + public var action: Action? + public var type: `Type` + + public static func cancel( + _ label: String, + send action: Action? = nil + ) -> Self { + Self(action: action, type: .cancel(label: label)) + } + + public static func cancel( + send action: Action? = nil + ) -> Self { + Self(action: action, type: .cancel(label: nil)) + } + + public static func `default`( + _ label: String, + send action: Action? = nil + ) -> Self { + Self(action: action, type: .default(label: label)) + } + + public static func destructive( + _ label: String, + 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) + } + } +} + +extension View { + /// Displays an alert when then store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that describes if the alert is shown or dismissed. + /// - dismissal: An action to send when the alert is dismissed through non-user actions, such + /// as when an alert is automatically dismissed by the system. + public func alert( + _ store: Store?, Action>, + dismiss: Action + ) -> some View { + + let viewStore = ViewStore(store, removeDuplicates: { ($0 == nil) != ($1 == nil) }) + return self.alert( + isPresented: Binding( + get: { viewStore.state != nil }, + set: { + guard !$0 else { return } + viewStore.send(dismiss) + }), + content: { viewStore.state?.toSwiftUI(send: viewStore.send) ?? Alert(title: Text("")) } + ) + } +} + +extension AlertState: Equatable where Action: Equatable {} +extension AlertState: Hashable where Action: Hashable {} +extension AlertState.Button: Equatable where Action: Equatable {} +extension AlertState.Button: Hashable where Action: Hashable {} + +extension AlertState: Identifiable where Action: Hashable { + public var id: Self { self } +} + +extension AlertState.Button { + func toSwiftUI(send: @escaping (Action) -> Void) -> SwiftUI.Alert.Button { + let action = { if let action = self.action { send(action) } } + switch self.type { + case let .cancel(.some(label)): + return .cancel(Text(label), action: action) + case .cancel(.none): + return .cancel(action) + case let .default(label): + return .default(Text(label), action: action) + case let .destructive(label): + return .destructive(Text(label), action: action) + } + } +} + +extension AlertState { + fileprivate func toSwiftUI(send: @escaping (Action) -> Void) -> SwiftUI.Alert { + let title = Text(self.title) + let message = self.message.map { Text($0) } + + if let primaryButton = self.primaryButton, let secondaryButton = self.secondaryButton { + return SwiftUI.Alert( + title: title, + message: message, + primaryButton: primaryButton.toSwiftUI(send: send), + secondaryButton: secondaryButton.toSwiftUI(send: send) + ) + } else { + return SwiftUI.Alert( + title: title, + message: message, + dismissButton: self.primaryButton?.toSwiftUI(send: send) + ) + } + } +}