From c25033772b7964c39d888f357d314dcef07d2557 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 9 Apr 2024 12:20:45 -0700 Subject: [PATCH] Observation (#200) * Bump * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Remove firstLaunchOnboarding (#198) Co-authored-by: Stephen Celis * use enum reducers * fix some warnings * udpate last destination reducer * ignore some errors * wip * wip * wip * wip --------- Co-authored-by: Imajin Kawabe Co-authored-by: Brandon Williams --- .github/workflows/ci.yml | 6 +- .../xcschemes/UserSettingsClient.xcscheme | 67 +++ App/isowords.xcodeproj/project.pbxproj | 3 - .../xcshareddata/swiftpm/Package.resolved | 84 ++-- Makefile | 2 +- Package.swift | 2 +- .../ActiveGamesFeature/ActiveGamesView.swift | 21 +- Sources/ApiClient/Client.swift | 2 +- Sources/AppFeature/AppDelegate.swift | 1 + Sources/AppFeature/AppView.swift | 84 ++-- Sources/Bloom/BloomBackground.swift | 26 +- Sources/BottomMenu/BottomMenu.swift | 10 +- Sources/BottomMenu/ComposableBottomMenu.swift | 58 +-- Sources/ChangelogFeature/ChangeView.swift | 41 +- Sources/ChangelogFeature/ChangelogView.swift | 57 +-- Sources/ComposableGameCenter/Interface.swift | 2 +- Sources/CubePreview/CubePreviewView.swift | 80 ++-- .../DailyChallengeFeature/CalendarView.swift | 41 +- .../DailyChallengeResults.swift | 13 +- .../DailyChallengeView.swift | 143 +++---- Sources/DemoFeature/Demo.swift | 40 +- Sources/GameCore/GameCore.swift | 79 ++-- Sources/GameCore/Views/GameFooterView.swift | 42 +- Sources/GameCore/Views/GameHeaderView.swift | 86 +--- Sources/GameCore/Views/GameNavView.swift | 32 +- Sources/GameCore/Views/GameView.swift | 126 ++---- .../GameCore/Views/PlayersAndScoresView.swift | 67 ++- Sources/GameCore/Views/WordSubmitButton.swift | 61 ++- Sources/GameOverFeature/DismissGame.swift | 15 + Sources/GameOverFeature/GameOverView.swift | 264 +++++------- .../DailyChallengeHeaderView.swift | 31 +- Sources/HomeFeature/Home.swift | 119 ++---- Sources/HomeFeature/LeaderboardLinkView.swift | 31 +- Sources/HomeFeature/NagBanner.swift | 38 +- Sources/HomeFeature/StartNewGameView.swift | 24 +- Sources/LeaderboardFeature/Leaderboard.swift | 68 ++- .../LeaderboardResultsView.swift | 39 +- .../MultiplayerFeature/MultiplayerView.swift | 50 +-- Sources/MultiplayerFeature/PastGameRow.swift | 61 ++- .../MultiplayerFeature/PastGameState.swift | 16 +- .../MultiplayerFeature/PastGamesView.swift | 13 +- .../NotificationsAuthAlert.swift | 117 +++--- .../OnboardingStepView.swift | 396 ++++++++---------- .../OnboardingFeature/OnboardingView.swift | 34 +- .../AccessibilitySettingsView.swift | 19 +- .../AppearanceSettingsView.swift | 12 +- .../DeveloperSettingsView.swift | 12 +- Sources/SettingsFeature/Mocks.swift | 17 - .../NotificationsSettingsView.swift | 22 +- .../PurchasesSettingsView.swift | 18 +- Sources/SettingsFeature/Settings.swift | 7 +- Sources/SettingsFeature/SettingsView.swift | 58 +-- .../SettingsFeature/SoundsSettingsView.swift | 25 +- Sources/SoloFeature/SoloView.swift | 99 ++--- Sources/StatsFeature/StatsFeature.swift | 50 +-- Sources/TrailerFeature/Trailer.swift | 88 ++-- Sources/UIApplicationClient/Client.swift | 1 - Sources/UIApplicationClient/LiveKey.swift | 1 - Sources/UIApplicationClient/TestKey.swift | 1 - .../UpgradeInterstitialView.swift | 181 ++++---- Sources/VocabFeature/Vocab.swift | 81 ++-- Tests/AppFeatureTests/PersistenceTests.swift | 6 +- .../RemoteNotificationsTests.swift | 4 +- Tests/AppFeatureTests/TurnBasedTests.swift | 16 +- .../UserNotificationsTests.swift | 3 +- .../ChangelogFeatureTests.swift | 3 +- ...ailyChallengeFeatureIntegrationTests.swift | 2 +- .../DailyChallengeFeatureTests.swift | 8 +- Tests/GameCoreTests/GameCoreTests.swift | 2 +- .../GameOverFeatureIntegrationTests.swift | 2 +- .../GameOverFeatureTests.swift | 31 +- .../LeaderboardFeatureIntegrationTests.swift | 3 +- .../LeaderboardFeatureTests.swift | 5 +- .../LeaderboardResultsTests.swift | 5 +- .../MultiplayerFeatureTests.swift | 4 +- .../PastGamesTests.swift | 5 +- .../OnboardingFeatureTests.swift | 4 +- .../SettingsFeatureTests.swift | 48 ++- .../SettingsPurchaseTests.swift | 8 +- .../UpgradeInterstitialFeatureTests.swift | 9 +- 80 files changed, 1466 insertions(+), 1986 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/UserSettingsClient.xcscheme create mode 100644 Sources/GameOverFeature/DismissGame.swift delete mode 100644 Sources/SettingsFeature/Mocks.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 689039b8..038add23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,13 @@ concurrency: jobs: mac: name: macOS - runs-on: macOS-13 + runs-on: macos-14 steps: - uses: actions/checkout@v4 # - name: Setup tmate session # uses: mxschmitt/action-tmate@v2 + - name: Select Xcode 15.3 + run: sudo xcode-select -s /Applications/Xcode_15.3.app - name: LFS pull run: git lfs pull - name: Install Postgres @@ -29,8 +31,6 @@ jobs: run: brew link postgresql@15 - name: Start Postgres run: brew services start postgresql@15 - - name: Select Xcode 15.2 - run: sudo xcode-select -s /Applications/Xcode_15.2.app - name: Bootstrap run: make bootstrap - name: Run tests diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/UserSettingsClient.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/UserSettingsClient.xcscheme new file mode 100644 index 00000000..71cfd4b6 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/UserSettingsClient.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/isowords.xcodeproj/project.pbxproj b/App/isowords.xcodeproj/project.pbxproj index 94678a2d..9579f478 100644 --- a/App/isowords.xcodeproj/project.pbxproj +++ b/App/isowords.xcodeproj/project.pbxproj @@ -1339,7 +1339,6 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TESTFLIGHT; @@ -1825,7 +1824,6 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1882,7 +1880,6 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cae75f33..d2cb4c1a 100644 --- a/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "a5521dde99570789d8cb7c43e51418d7cd1a87ca", - "version" : "1.1.2" + "revision" : "79623dbe2c7672f5e450d8325613d231454390b3", + "version" : "1.3.2" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", - "version" : "1.0.5" + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" } }, { @@ -113,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "ae491c9e3f66631e72d58db8bb4c27dfc3d3afd4", - "version" : "1.6.0" + "revision" : "115fe5af41d333b6156d4924d7c7058bc77fd580", + "version" : "1.9.2" } }, { @@ -129,7 +129,7 @@ { "identity" : "swift-crypto", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto", + "location" : "https://github.com/apple/swift-crypto.git", "state" : { "revision" : "ddb07e896a2a8af79512543b1c7eb9797f8898a5", "version" : "1.1.7" @@ -140,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605", - "version" : "1.1.2" + "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", + "version" : "1.3.0" } }, { @@ -149,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "9783b58167f7618cb86011156e741cbc6f4cc864", - "version" : "1.1.2" + "revision" : "d3a5af3038a09add4d7682f66555d6212058a3c0", + "version" : "1.2.2" } }, { @@ -176,8 +176,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "1827dc94bdab2eb5f2fc804e9b0cb43574282566", - "version" : "1.0.2" + "revision" : "12358d55a3824bd5fed310b999ea8cf83a9a1a65", + "version" : "1.0.3" } }, { @@ -194,8 +194,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", - "version" : "1.5.3" + "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", + "version" : "1.5.4" } }, { @@ -212,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "702cd7c56d5d44eeba73fdf83918339b26dc855c", - "version" : "2.62.0" + "revision" : "fc63f0cf4e55a4597407a9fc95b16a2bc44b4982", + "version" : "2.64.0" } }, { @@ -221,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "798c962495593a23fdea0c0c63fd55571d8dff51", - "version" : "1.20.0" + "revision" : "a3b640d7dc567225db7c94386a6e71aded1bfa63", + "version" : "1.22.0" } }, { @@ -230,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "3bd9004b9d685ed6b629760fc84903e48efec806", - "version" : "1.29.0" + "revision" : "0904bf0feb5122b7e5c3f15db7df0eabe623dd87", + "version" : "1.30.0" } }, { @@ -239,8 +239,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "320bd978cceb8e88c125dcbb774943a92f6286e9", - "version" : "2.25.0" + "revision" : "7c381eb6083542b124a6c18fae742f55001dc2b5", + "version" : "2.26.0" } }, { @@ -248,8 +248,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "ebf8b9c365a6ce043bf6e6326a04b15589bd285e", - "version" : "1.20.0" + "revision" : "6cbe0ed2b394f21ab0d46b9f0c50c6be964968ce", + "version" : "1.20.1" } }, { @@ -279,6 +279,15 @@ "version" : "0.13.0" } }, + { + "identity" : "swift-perception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-perception", + "state" : { + "revision" : "520c458a832d1287e6b698c5f657ae848fd696ff", + "version" : "1.1.4" + } + }, { "identity" : "swift-prelude", "kind" : "remoteSourceControl", @@ -293,8 +302,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "59b663f68e69f27a87b45de48cb63264b8194605", - "version" : "1.15.1" + "revision" : "625ccca8570773dd84a34ee51a81aa2bc5a4f97a", + "version" : "1.16.0" } }, { @@ -302,8 +311,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version" : "509.0.2" + "revision" : "fa8f95c2d536d6620cc2f504ebe8a6167c9fc2dd", + "version" : "510.0.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" } }, { @@ -347,8 +365,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "78f9d72cf667adb47e2040aa373185c88c63f0dc", - "version" : "1.2.0" + "revision" : "2ec6c3a15293efff6083966b38439a4004f25565", + "version" : "1.3.0" } }, { @@ -356,8 +374,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version" : "1.0.2" + "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", + "version" : "1.1.2" } } ], diff --git a/Makefile b/Makefile index 4fd203fc..9c6be0ae 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ else @git lfs pull endif -PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS 17.2,iPhone \d\+ Pro [^M]) +PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS 17.4,iPhone \d\+ Pro [^M]) test-client: @xcodebuild test \ -project App/isowords.xcodeproj \ diff --git a/Package.swift b/Package.swift index 4d0dae36..b9538df5 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ var package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-crypto", from: "1.1.6"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.1.0"), - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.5.6"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.9.2"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-gen", from: "0.3.0"), diff --git a/Sources/ActiveGamesFeature/ActiveGamesView.swift b/Sources/ActiveGamesFeature/ActiveGamesView.swift index de260b9b..f6df6e9c 100644 --- a/Sources/ActiveGamesFeature/ActiveGamesView.swift +++ b/Sources/ActiveGamesFeature/ActiveGamesView.swift @@ -6,6 +6,7 @@ import SharedModels import Styleguide import SwiftUI +@ObservableState public struct ActiveGamesState: Equatable { public var savedGames: SavedGamesState public var turnBasedMatches: [ActiveTurnBasedMatch] @@ -45,7 +46,6 @@ public struct ActiveGamesView: View { @Environment(\.date) var date let showMenuItems: Bool let store: Store - @ObservedObject var viewStore: ViewStore public init( store: Store, @@ -53,13 +53,12 @@ public struct ActiveGamesView: View { ) { self.showMenuItems = showMenuItems self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }, removeDuplicates: ==) } public var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 20) { - if self.viewStore.savedGames.dailyChallengeUnlimited != nil { + if store.savedGames.dailyChallengeUnlimited != nil { ActiveGameCard( button: .init( icon: .init(systemName: "arrow.right"), @@ -70,12 +69,12 @@ public struct ActiveGamesView: View { .fontWeight(.medium) + Text("\nleft to play!") .foregroundColor(self.color.opacity(0.4)), - tapAction: { self.viewStore.send(.dailyChallengeTapped, animation: .default) }, + tapAction: { store.send(.dailyChallengeTapped, animation: .default) }, title: Text("Daily challenge") ) } - if let inProgressGame = self.viewStore.savedGames.unlimited { + if let inProgressGame = store.savedGames.unlimited { ActiveGameCard( button: .init( icon: .init(systemName: "arrow.right"), @@ -83,12 +82,12 @@ public struct ActiveGamesView: View { title: Text("Resume") ), message: soloMessage(inProgressGame: inProgressGame), - tapAction: { self.viewStore.send(.soloTapped, animation: .default) }, + tapAction: { store.send(.soloTapped, animation: .default) }, title: Text("Solo") ) } - ForEach(self.viewStore.turnBasedMatches) { match in + ForEach(store.turnBasedMatches) { match in let sendReminderAction = !match.isYourTurn && match.isStale ? match.theirIndex.map { otherPlayerIndex in @@ -101,9 +100,9 @@ public struct ActiveGamesView: View { ActiveGameCard( button: turnBasedButton(match: match), message: turnBasedMessage(match: match), - tapAction: { self.viewStore.send(.turnBasedGameTapped(match.id), animation: .default) }, + tapAction: { store.send(.turnBasedGameTapped(match.id), animation: .default) }, buttonAction: self.showMenuItems - ? sendReminderAction.map { action in { self.viewStore.send(action) } } + ? sendReminderAction.map { action in { store.send(action) } } : nil, title: Text("vs \(match.theirName ?? "your opponent")") ) @@ -114,13 +113,13 @@ public struct ActiveGamesView: View { let sendReminderAction = sendReminderAction { Button { - self.viewStore.send(sendReminderAction) + store.send(sendReminderAction) } label: { Label("Send Reminder", systemImage: "clock") } } Button { - self.viewStore.send(.turnBasedGameMenuItemTapped(.deleteMatch(match.id))) + store.send(.turnBasedGameMenuItemTapped(.deleteMatch(match.id))) } label: { Label("Delete Match", systemImage: "trash") .foregroundColor(.red) diff --git a/Sources/ApiClient/Client.swift b/Sources/ApiClient/Client.swift index 80d3d50c..e1770499 100644 --- a/Sources/ApiClient/Client.swift +++ b/Sources/ApiClient/Client.swift @@ -3,7 +3,7 @@ import Foundation import SharedModels @DependencyClient -public struct ApiClient { +public struct ApiClient: Sendable { public var apiRequest: @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse) public var authenticate: @Sendable (ServerRoute.AuthenticateRequest) async throws -> CurrentPlayerEnvelope diff --git a/Sources/AppFeature/AppDelegate.swift b/Sources/AppFeature/AppDelegate.swift index b4e6077b..31061143 100644 --- a/Sources/AppFeature/AppDelegate.swift +++ b/Sources/AppFeature/AppDelegate.swift @@ -94,6 +94,7 @@ public struct AppDelegateReducer { ) ) ) + } catch: { _, _ in } case let .userNotifications(.willPresentNotification(_, completionHandler)): diff --git a/Sources/AppFeature/AppView.swift b/Sources/AppFeature/AppView.swift index 030deb0e..dcf417e4 100644 --- a/Sources/AppFeature/AppView.swift +++ b/Sources/AppFeature/AppView.swift @@ -12,35 +12,22 @@ import SwiftUI @Reducer public struct AppReducer { - @Reducer - public struct Destination { - public enum State: Equatable { - case game(Game.State) - case onboarding(Onboarding.State) - } - public enum Action { - case game(Game.Action) - case onboarding(Onboarding.Action) - } - public var body: some ReducerOf { - Scope(state: \.game, action: \.game) { - Game() - } - Scope(state: \.onboarding, action: \.onboarding) { - Onboarding() - } - } + @Reducer(state: .equatable) + public enum Destination { + case game(Game) + case onboarding(Onboarding) } + @ObservableState public struct State: Equatable { public var appDelegate: AppDelegateReducer.State - @PresentationState public var destination: Destination.State? + @Presents public var destination: Destination.State? public var home: Home.State public init( appDelegate: AppDelegateReducer.State = AppDelegateReducer.State(), destination: Destination.State? = nil, - home: Home.State = .init() + home: Home.State = Home.State() ) { self.appDelegate = appDelegate self.destination = destination @@ -279,6 +266,7 @@ public struct AppReducer { ) async let refresh = self.refreshServerConfig() _ = try await (register, refresh) + } catch: { _, _ in } case .didChangeScenePhase: @@ -305,67 +293,55 @@ public struct AppReducer { } } .ifLet(\.$destination, action: \.destination) { - Destination() + Destination.body } } } public struct AppView: View { let store: StoreOf - @ObservedObject var viewStore: ViewStore @Environment(\.deviceState) var deviceState - struct ViewState: Equatable { - let isHomeActive: Bool - - init(state: AppReducer.State) { - self.isHomeActive = state.destination == nil - } - } - public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { Group { - if self.viewStore.isHomeActive { + switch store.destination { + case .none: NavigationStack { - HomeView(store: self.store.scope(state: \.home, action: \.home)) + HomeView(store: store.scope(state: \.home, action: \.home)) } .zIndex(0) - } else { - IfLetStore( - self.store.scope(state: \.destination?.game, action: \.destination.game) - ) { store in + + case .some(.game): + if let store = store.scope(state: \.destination?.game, action: \.destination.game) { GameView( content: CubeView(store: store.scope(state: \.cubeScene, action: \.cubeScene)), store: store ) + .transition(.game) + .zIndex(1) } - .transition(.game) - .zIndex(1) - IfLetStore( - self.store.scope(state: \.destination?.onboarding, action: \.destination.onboarding), - then: OnboardingView.init(store:) - ) - .zIndex(2) + case .some(.onboarding): + if let store = store.scope( + state: \.destination?.onboarding, action: \.destination.onboarding + ) { + OnboardingView(store: store) + .zIndex(2) + } } } .modifier(DeviceStateModifier()) } } -#if DEBUG - struct AppView_Previews: PreviewProvider { - static var previews: some View { - AppView( - store: Store(initialState: AppReducer.State()) { - AppReducer() - } - ) +#Preview { + AppView( + store: Store(initialState: AppReducer.State()) { + AppReducer() } - } -#endif + ) +} diff --git a/Sources/Bloom/BloomBackground.swift b/Sources/Bloom/BloomBackground.swift index 12062a0c..c30e105c 100644 --- a/Sources/Bloom/BloomBackground.swift +++ b/Sources/Bloom/BloomBackground.swift @@ -66,23 +66,11 @@ public struct Blooms: View { } public struct BloomBackground: View { - public struct ViewState: Equatable { - let bloomCount: Int - let word: String - - public init( - bloomCount: Int, - word: String - ) { - self.bloomCount = bloomCount - self.word = word - } - } + let word: String @State var blooms: [Bloom] = [] @Environment(\.colorScheme) var colorScheme let size: CGSize - let store: Store @State var vertexGenerator: AnyIterator = { var rng = Xoshiro(seed: 0) var vertices: [CGPoint] = [ @@ -100,29 +88,27 @@ public struct BloomBackground: View { return vertices[index % vertices.count] } }() - @ObservedObject var viewStore: ViewStore - public init(size: CGSize, store: Store) { + public init(size: CGSize, word: String) { self.size = size - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) + self.word = word } public var body: some View { Blooms(blooms: self.blooms) - .onChange(of: self.viewStore.bloomCount) { _, count in + .onChange(of: self.word.count) { _, count in withAnimation(.easeOut(duration: 1)) { self.renderBlooms(count: count) } } - .onAppear { self.renderBlooms(count: self.viewStore.bloomCount) } + .onAppear { self.renderBlooms(count: self.word.count) } } func renderBlooms(count: Int) { if count > self.blooms.count { let colors = Styleguide.letterColors.first { key, _ in - key.contains(self.viewStore.word) + key.contains(self.word) }? .value ?? [] guard colors.count > 0 diff --git a/Sources/BottomMenu/BottomMenu.swift b/Sources/BottomMenu/BottomMenu.swift index 2a9edbfe..233900ee 100644 --- a/Sources/BottomMenu/BottomMenu.swift +++ b/Sources/BottomMenu/BottomMenu.swift @@ -77,7 +77,11 @@ private struct BottomMenuModifier: ViewModifier { Rectangle() .fill(Color.isowordsBlack.opacity(0.4)) .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture { self.item = nil } + .onTapGesture { + withAnimation { + self.item = nil + } + } .transition(.opacity.animation(.default)) .ignoresSafeArea() } @@ -91,7 +95,9 @@ private struct BottomMenuModifier: ViewModifier { .adaptiveFont(.matterMedium, size: 18) Spacer() Button { - self.item = nil + withAnimation { + self.item = nil + } } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 24)) diff --git a/Sources/BottomMenu/ComposableBottomMenu.swift b/Sources/BottomMenu/ComposableBottomMenu.swift index 6e8715ae..88176955 100644 --- a/Sources/BottomMenu/ComposableBottomMenu.swift +++ b/Sources/BottomMenu/ComposableBottomMenu.swift @@ -70,44 +70,25 @@ extension BottomMenuState: _EphemeralState { extension View { public func bottomMenu( - store: Store>, PresentationAction> + _ item: Binding, MenuAction>?> ) -> some View { - self.bottomMenu(store: store, state: { $0 }, action: { $0 }) - } - - public func bottomMenu( - store: Store, PresentationAction>, - state toMenuState: @escaping (DestinationState) -> BottomMenuState?, - action fromMenuAction: @escaping (MenuAction) -> DestinationAction - ) -> some View { - WithViewStore( - store, - observe: { $0 }, - removeDuplicates: { - ($0.wrappedValue.flatMap(toMenuState) != nil) - == ($1.wrappedValue.flatMap(toMenuState) != nil) - } - ) { viewStore in - self.bottomMenu( - item: Binding( - get: { - viewStore.wrappedValue.flatMap(toMenuState)?.converted( - send: { - viewStore.send(.presented(fromMenuAction($0))) - }, - sendWithAnimation: { - viewStore.send(.presented(fromMenuAction($0)), animation: $1) - } - ) - }, - set: { state in - if state == nil { - viewStore.send(.dismiss, animation: .default) - } + let store = item.wrappedValue + let state = store?.withState { $0 } + return self.bottomMenu( + item: Binding( + get: { + state?.converted( + send: { store?.send($0) }, + sendWithAnimation: { store?.send($0, animation: $1) } + ) + }, + set: { + if $0 == nil { + item.transaction($1).wrappedValue = nil } - ) + } ) - } + ) } } @@ -155,8 +136,9 @@ extension BottomMenuState.Button { @Reducer private struct BottomMenuReducer { + @ObservableState struct State: Equatable { - @PresentationState var bottomMenu: BottomMenuState? + @Presents var bottomMenu: BottomMenuState? } enum Action: Equatable { @@ -198,14 +180,14 @@ extension BottomMenuState.Button { struct BottomMenu_TCA_Previews: PreviewProvider { struct TestView: View { - private let store = Store(initialState: BottomMenuReducer.State()) { + @Bindable fileprivate var store = Store(initialState: BottomMenuReducer.State()) { BottomMenuReducer() } var body: some View { Button("Present") { store.send(.showMenuButtonTapped, animation: .default) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .bottomMenu(store: self.store.scope(state: \.$bottomMenu, action: \.bottomMenu)) + .bottomMenu($store.scope(state: \.bottomMenu, action: \.bottomMenu)) } } diff --git a/Sources/ChangelogFeature/ChangeView.swift b/Sources/ChangelogFeature/ChangeView.swift index 122e3058..76d9bc0d 100644 --- a/Sources/ChangelogFeature/ChangeView.swift +++ b/Sources/ChangelogFeature/ChangeView.swift @@ -6,6 +6,7 @@ import Tagged @Reducer public struct Change { + @ObservableState public struct State: Equatable, Identifiable { public var change: Changelog.Change public var isExpanded = false @@ -37,35 +38,33 @@ struct ChangeView: View { let store: StoreOf var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack(alignment: .leading, spacing: .grid(2)) { - HStack { - Text(viewStore.change.version) - .font(.title) + VStack(alignment: .leading, spacing: .grid(2)) { + HStack { + Text(store.change.version) + .font(.title) - if viewStore.change.build == self.currentBuild { - Text("Installed") - .font(.footnote) - .padding(.grid(1)) - .foregroundColor(.white) - .background(Color.gray) - } + if store.change.build == self.currentBuild { + Text("Installed") + .font(.footnote) + .padding(.grid(1)) + .foregroundColor(.white) + .background(Color.gray) + } - Spacer() + Spacer() - if !viewStore.isExpanded { - Button("Show") { - viewStore.send(.showButtonTapped, animation: .default) - } + if !store.isExpanded { + Button("Show") { + store.send(.showButtonTapped, animation: .default) } } + } - if viewStore.isExpanded { - Text(viewStore.change.log) - } + if store.isExpanded { + Text(store.change.log) } - .adaptivePadding(.vertical) } + .adaptivePadding(.vertical) .buttonStyle(PlainButtonStyle()) } } diff --git a/Sources/ChangelogFeature/ChangelogView.swift b/Sources/ChangelogFeature/ChangelogView.swift index 06713550..1e7da081 100644 --- a/Sources/ChangelogFeature/ChangelogView.swift +++ b/Sources/ChangelogFeature/ChangelogView.swift @@ -10,6 +10,7 @@ import UIApplicationClient @Reducer public struct ChangelogReducer { + @ObservableState public struct State: Equatable { public var changelog: IdentifiedArrayOf public var currentBuild: Build.Number @@ -116,54 +117,40 @@ public struct ChangelogReducer { public struct ChangelogView: View { let store: StoreOf - struct ViewState: Equatable { - let currentBuild: Build.Number - let isUpdateButtonVisible: Bool - - init(state: ChangelogReducer.State) { - self.currentBuild = state.currentBuild - self.isUpdateButtonVisible = state.isUpdateButtonVisible - } - } - - public init( - store: StoreOf - ) { + public init(store: StoreOf) { self.store = store } public var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - ScrollView { - VStack(alignment: .leading) { - if viewStore.isUpdateButtonVisible { - HStack { - Spacer() - Button("Update") { - viewStore.send(.updateButtonTapped) - } - .buttonStyle(ActionButtonStyle()) + ScrollView { + VStack(alignment: .leading) { + if store.isUpdateButtonVisible { + HStack { + Spacer() + Button("Update") { + store.send(.updateButtonTapped) } + .buttonStyle(ActionButtonStyle()) } + } - Text("What's new?") - .font(.largeTitle) + Text("What's new?") + .font(.largeTitle) - ForEachStore(self.store.scope(state: \.whatsNew, action: \.changelog)) { store in - ChangeView(currentBuild: viewStore.currentBuild, store: store) - } + ForEach(store.scope(state: \.whatsNew, action: \.changelog)) { store in + ChangeView(currentBuild: self.store.currentBuild, store: store) + } - Text("Past updates") - .font(.largeTitle) + Text("Past updates") + .font(.largeTitle) - ForEachStore(self.store.scope(state: \.pastUpdates, action: \.changelog)) { store in - ChangeView(currentBuild: viewStore.currentBuild, store: store) - } + ForEach(store.scope(state: \.pastUpdates, action: \.changelog)) { store in + ChangeView(currentBuild: self.store.currentBuild, store: store) } - .padding() } - .task { await viewStore.send(.task).finish() } + .padding() } + .task { await store.send(.task).finish() } } } diff --git a/Sources/ComposableGameCenter/Interface.swift b/Sources/ComposableGameCenter/Interface.swift index 7e2b40e7..b2d596f9 100644 --- a/Sources/ComposableGameCenter/Interface.swift +++ b/Sources/ComposableGameCenter/Interface.swift @@ -148,7 +148,7 @@ public struct TurnBasedMatchClient { } @DependencyClient -public struct TurnBasedMatchmakerViewControllerClient { +public struct TurnBasedMatchmakerViewControllerClient: Sendable { public var present: @Sendable (_ showExistingMatches: Bool) async throws -> Void public var dismiss: @Sendable () async -> Void } diff --git a/Sources/CubePreview/CubePreviewView.swift b/Sources/CubePreview/CubePreviewView.swift index c76e0f70..07669e69 100644 --- a/Sources/CubePreview/CubePreviewView.swift +++ b/Sources/CubePreview/CubePreviewView.swift @@ -11,6 +11,7 @@ import UserSettingsClient @Reducer public struct CubePreview { + @ObservableState public struct State: Equatable { var cubes: Puzzle var enableGyroMotion: Bool @@ -18,8 +19,8 @@ public struct CubePreview { var isOnLowPowerMode: Bool var moveIndex: Int var moves: Moves - @BindingState var nub: CubeSceneView.ViewState.NubState - @BindingState var selectedCubeFaces: [IndexedCubeFace] + var nub: CubeSceneView.ViewState.NubState + var selectedCubeFaces: [IndexedCubeFace] public init( cubes: ArchivablePuzzle, @@ -31,8 +32,9 @@ public struct CubePreview { ) { @Dependency(\.userSettings) var userSettings - self.cubes = .init(archivableCubes: cubes) - apply(moves: moves[0.. - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let isAnimationReduced: Bool - let selectedWordIsFinalWord: Bool - let selectedWordScore: Int? - let selectedWordString: String - - init(state: CubePreview.State) { - self.isAnimationReduced = state.isAnimationReduced - self.selectedWordString = state.cubes.string(from: state.selectedCubeFaces) - self.selectedWordIsFinalWord = state.finalWordString == self.selectedWordString - self.selectedWordScore = - self.selectedWordIsFinalWord - ? state.moves[state.moveIndex].score - : nil - } - } public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - if !self.viewStore.selectedWordString.isEmpty { - (Text(self.viewStore.selectedWordString) + if !store.selectedWordString.isEmpty { + (Text(store.selectedWordString) + self.scoreText .baselineOffset( (self.deviceState.idiom == .pad ? 2 : 1) * 16 @@ -262,48 +249,37 @@ public struct CubePreviewView: View { .matterSemiBold, size: (self.deviceState.idiom == .pad ? 2 : 1) * 32 ) - .opacity(self.viewStore.selectedWordIsFinalWord ? 1 : 0.5) + .opacity(store.selectedWordIsFinalWord ? 1 : 0.5) .allowsTightening(true) .minimumScaleFactor(0.2) .lineLimit(1) .transition(.opacity) - .animation(nil, value: self.viewStore.selectedWordString) + .animation(nil, value: store.selectedWordString) .adaptivePadding(.top, .grid(16)) } CubeView(store: self.store.scope(state: \.cubeScenePreview, action: \.cubeScene)) - .task { await self.viewStore.send(.task).finish() } + .task { await store.send(.task).finish() } } - .background( - self.viewStore.isAnimationReduced - ? nil - : BloomBackground( + .background { + if !store.isAnimationReduced { + BloomBackground( size: proxy.size, - store: self.store - .scope( - state: { _ in - BloomBackground.ViewState( - bloomCount: self.viewStore.selectedWordString.count, - word: self.viewStore.selectedWordString - ) - }, - action: absurd - ) + word: store.selectedWordString ) - ) + } + } } .onTapGesture { UIView.setAnimationsEnabled(false) - self.viewStore.send(.tap) + store.send(.tap) UIView.setAnimationsEnabled(true) } } var scoreText: Text { - self.viewStore.selectedWordScore.map { + store.selectedWordScore.map { Text(" \($0)") } ?? Text("") } } - -private func absurd(_: Never) -> A {} diff --git a/Sources/DailyChallengeFeature/CalendarView.swift b/Sources/DailyChallengeFeature/CalendarView.swift index 8615f189..6624ec3e 100644 --- a/Sources/DailyChallengeFeature/CalendarView.swift +++ b/Sources/DailyChallengeFeature/CalendarView.swift @@ -63,18 +63,11 @@ struct CalendarView: View { } let store: StoreOf - @ObservedObject var viewStore: ViewStore - - init( - store: StoreOf - ) { - self.store = store - self.viewStore = ViewStore(store, observe: ViewState.init) - } var body: some View { + let viewState = ViewState(state: store.state) VStack(alignment: .leading, spacing: .grid(4)) { - ForEach(self.viewStore.months, id: \.date) { month in + ForEach(viewState.months, id: \.date) { month in VStack(alignment: .leading) { Text(month.name) .adaptiveFont(.matterMedium, size: 14) @@ -85,7 +78,7 @@ struct CalendarView: View { ) { ForEach(month.results, id: \.gameNumber) { result in Button { - self.viewStore.send(.leaderboardResults(.timeScopeChanged(result.gameNumber))) + store.send(.leaderboardResults(.timeScopeChanged(result.gameNumber))) } label: { VStack { Text("\(dayFormatter.string(from: result.createdAt))") @@ -98,12 +91,12 @@ struct CalendarView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, .grid(1) / 2) - .background( - self.viewStore.currentChallenge == result.gameNumber - ? RoundedRectangle(cornerRadius: .grid(2), style: .continuous) + .background { + if viewState.currentChallenge == result.gameNumber { + RoundedRectangle(cornerRadius: .grid(2), style: .continuous) .fill(Color.adaptiveWhite) - : nil - ) + } + } } } } @@ -112,10 +105,10 @@ struct CalendarView: View { } } - if self.viewStore.months.isEmpty { + if viewState.months.isEmpty { HStack { Button { - self.viewStore.send(.loadHistory) + store.send(.loadHistory) } label: { Image(systemName: "arrow.clockwise") } @@ -125,14 +118,14 @@ struct CalendarView: View { } } } - .redacted(reason: self.viewStore.isLoading ? .placeholder : []) - .disabled(self.viewStore.isLoading) - .overlay( - self.viewStore.isLoading - ? ProgressView() + .redacted(reason: viewState.isLoading ? .placeholder : []) + .disabled(viewState.isLoading) + .overlay { + if viewState.isLoading { + ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .black)) - : nil - ) + } + } } } diff --git a/Sources/DailyChallengeFeature/DailyChallengeResults.swift b/Sources/DailyChallengeFeature/DailyChallengeResults.swift index 5bce375f..fcf26996 100644 --- a/Sources/DailyChallengeFeature/DailyChallengeResults.swift +++ b/Sources/DailyChallengeFeature/DailyChallengeResults.swift @@ -6,6 +6,7 @@ import SwiftUI @Reducer public struct DailyChallengeResults { + @ObservableState public struct State: Equatable { public var history: DailyChallengeHistoryResponse? public var leaderboardResults: LeaderboardResults.State @@ -89,24 +90,22 @@ public struct DailyChallengeResults { public struct DailyChallengeResultsView: View { @Environment(\.colorScheme) var colorScheme let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) } public var body: some View { LeaderboardResultsView( - store: self.store.scope(state: \.leaderboardResults, action: \.leaderboardResults), + store: store.scope(state: \.leaderboardResults, action: \.leaderboardResults), title: Text("Daily Challenge"), - subtitle: (self.viewStore.leaderboardResults.resultEnvelope?.outOf) + subtitle: (store.leaderboardResults.resultEnvelope?.outOf) .flatMap { $0 == 0 ? nil : Text("\($0) players") }, isFilterable: true, color: .dailyChallenge, timeScopeLabel: Text(self.timeScopeLabelText), timeScopeMenu: VStack(alignment: .trailing, spacing: .grid(2)) { - CalendarView(store: self.store) + CalendarView(store: store) } ) .padding(.top, .grid(4)) @@ -122,8 +121,8 @@ public struct DailyChallengeResultsView: View { var timeScopeLabelText: LocalizedStringKey { guard - let history = self.viewStore.history, - let timeScope = self.viewStore.leaderboardResults.timeScope + let history = store.history, + let timeScope = store.leaderboardResults.timeScope else { return "Today (so far)" } guard diff --git a/Sources/DailyChallengeFeature/DailyChallengeView.swift b/Sources/DailyChallengeFeature/DailyChallengeView.swift index 957b0e05..08602325 100644 --- a/Sources/DailyChallengeFeature/DailyChallengeView.swift +++ b/Sources/DailyChallengeFeature/DailyChallengeView.swift @@ -11,36 +11,19 @@ import SwiftUI @Reducer public struct DailyChallengeReducer { - @Reducer - public struct Destination { - public enum State: Equatable { - case alert(AlertState) - case notificationsAuthAlert(NotificationsAuthAlert.State) - case results(DailyChallengeResults.State) - } - - public enum Action { - case alert(Alert) - case notificationsAuthAlert(NotificationsAuthAlert.Action) - case results(DailyChallengeResults.Action) - - public enum Alert: Equatable { - } - } + @Reducer(state: .equatable) + public enum Destination { + case alert(AlertState) + case notificationsAuthAlert(NotificationsAuthAlert) + case results(DailyChallengeResults) - public var body: some ReducerOf { - Scope(state: \.notificationsAuthAlert, action: \.notificationsAuthAlert) { - NotificationsAuthAlert() - } - Scope(state: \.results, action: \.results) { - DailyChallengeResults() - } - } + public enum Alert: Equatable {} } + @ObservableState public struct State: Equatable { public var dailyChallenges: [FetchTodaysDailyChallengeResponse] - @PresentationState public var destination: Destination.State? + @Presents public var destination: Destination.State? public var gameModeIsLoading: GameMode? public var inProgressDailyChallengeUnlimited: InProgressGame? public var userNotificationSettings: UserNotificationClient.Notification.Settings? @@ -58,6 +41,11 @@ public struct DailyChallengeReducer { self.inProgressDailyChallengeUnlimited = inProgressDailyChallengeUnlimited self.userNotificationSettings = userNotificationSettings } + + var isNotificationStatusDetermined: Bool { + ![.notDetermined, .provisional] + .contains(self.userNotificationSettings?.authorizationStatus) + } } public enum Action { @@ -206,12 +194,12 @@ public struct DailyChallengeReducer { } } .ifLet(\.$destination, action: \.destination) { - Destination() + Destination.body } } } -extension AlertState where Action == DailyChallengeReducer.Destination.Action.Alert { +extension AlertState where Action == DailyChallengeReducer.Destination.Alert { static func alreadyPlayed(nextStartsAt: Date) -> Self { Self { TextState("Already played") @@ -251,42 +239,31 @@ public struct DailyChallengeView: View { @Environment(\.adaptiveSize) var adaptiveSize @Environment(\.colorScheme) var colorScheme @Environment(\.date) var date - let store: StoreOf - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let gameModeIsLoading: GameMode? - let isNotificationStatusDetermined: Bool - let numberOfPlayers: Int - let timedState: ButtonState - let unlimitedState: ButtonState - - enum ButtonState: Equatable { - case played(rank: Int, outOf: Int) - case playable - case resume(currentScore: Int) - case unplayable - } + @Bindable var store: StoreOf - init(state: DailyChallengeReducer.State) { - self.gameModeIsLoading = state.gameModeIsLoading - self.isNotificationStatusDetermined = ![.notDetermined, .provisional] - .contains(state.userNotificationSettings?.authorizationStatus) - self.numberOfPlayers = state.dailyChallenges.numberOfPlayers - self.timedState = .init( - fetchedResponse: state.dailyChallenges.timed, - inProgressGame: nil - ) - self.unlimitedState = .init( - fetchedResponse: state.dailyChallenges.unlimited, - inProgressGame: state.inProgressDailyChallengeUnlimited - ) - } + enum ButtonState: Equatable { + case played(rank: Int, outOf: Int) + case playable + case resume(currentScore: Int) + case unplayable + } + + var timedState: ButtonState { + .init( + fetchedResponse: store.dailyChallenges.timed, + inProgressGame: nil + ) + } + + var unlimitedState: ButtonState { + .init( + fetchedResponse: store.dailyChallenges.unlimited, + inProgressGame: store.inProgressDailyChallengeUnlimited + ) } public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { @@ -297,12 +274,12 @@ public struct DailyChallengeView: View { VStack(spacing: .grid(8)) { Group { - if self.viewStore.numberOfPlayers <= 1 { + if store.dailyChallenges.numberOfPlayers <= 1 { (Text("Play") + Text("\nagainst the") + Text("\ncommunity")) } else { - (Text("\(self.viewStore.numberOfPlayers)") + (Text("\(store.dailyChallenges.numberOfPlayers)") + Text("\npeople have") + Text("\nplayed!")) } @@ -329,29 +306,29 @@ public struct DailyChallengeView: View { title: Text("Timed"), icon: Image(systemName: "clock.fill"), color: .dailyChallenge, - inactiveText: self.viewStore.timedState.inactiveText, - isLoading: self.viewStore.gameModeIsLoading == .timed, - resumeText: self.viewStore.timedState.resumeText, - action: { self.viewStore.send(.gameButtonTapped(.timed), animation: .default) } + inactiveText: timedState.inactiveText, + isLoading: store.gameModeIsLoading == .timed, + resumeText: timedState.resumeText, + action: { store.send(.gameButtonTapped(.timed), animation: .default) } ) - .disabled(self.viewStore.gameModeIsLoading != nil) + .disabled(store.gameModeIsLoading != nil) GameButton( title: Text("Unlimited"), icon: Image(systemName: "infinity"), color: .dailyChallenge, - inactiveText: self.viewStore.unlimitedState.inactiveText, - isLoading: self.viewStore.gameModeIsLoading == .unlimited, - resumeText: self.viewStore.unlimitedState.resumeText, - action: { self.viewStore.send(.gameButtonTapped(.unlimited), animation: .default) } + inactiveText: unlimitedState.inactiveText, + isLoading: store.gameModeIsLoading == .unlimited, + resumeText: unlimitedState.resumeText, + action: { store.send(.gameButtonTapped(.unlimited), animation: .default) } ) - .disabled(self.viewStore.gameModeIsLoading != nil) + .disabled(store.gameModeIsLoading != nil) } .adaptivePadding(.vertical) .screenEdgePadding(.horizontal) Button { - self.viewStore.send(.resultsButtonTapped) + store.send(.resultsButtonTapped) } label: { HStack { Text("View all results") @@ -368,15 +345,15 @@ public struct DailyChallengeView: View { .foregroundColor(self.colorScheme == .dark ? .isowordsBlack : .dailyChallenge) .background(self.colorScheme == .dark ? Color.dailyChallenge : .isowordsBlack) } - .task { await self.viewStore.send(.task).finish() } + .task { await store.send(.task).finish() } .navigationStyle( backgroundColor: self.colorScheme == .dark ? .isowordsBlack : .dailyChallenge, foregroundColor: self.colorScheme == .dark ? .dailyChallenge : .isowordsBlack, title: Text("Daily Challenge"), trailing: Group { - if !self.viewStore.isNotificationStatusDetermined { + if !store.isNotificationStatusDetermined { ReminderBell { - self.viewStore.send(.notificationButtonTapped, animation: .default) + store.send(.notificationButtonTapped, animation: .default) } .transition( .scale(scale: 0) @@ -387,20 +364,22 @@ public struct DailyChallengeView: View { ) .edgesIgnoringSafeArea(.bottom) } - .alert(store: self.store.scope(state: \.$destination.alert, action: \.destination.alert)) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .navigationDestination( - store: self.store.scope(state: \.$destination.results, action: \.destination.results), - destination: DailyChallengeResultsView.init(store:) - ) + item: $store.scope(state: \.destination?.results, action: \.destination.results) + ) { store in + DailyChallengeResultsView(store: store) + } .notificationsAlert( - store: self.store.scope(state: \.$destination, action: \.destination), - state: \.notificationsAuthAlert, - action: { .notificationsAuthAlert($0) } + $store.scope( + state: \.destination?.notificationsAuthAlert, + action: \.destination.notificationsAuthAlert + ) ) } } -extension DailyChallengeView.ViewState.ButtonState { +extension DailyChallengeView.ButtonState { init( fetchedResponse: FetchTodaysDailyChallengeResponse?, inProgressGame: InProgressGame? diff --git a/Sources/DemoFeature/Demo.swift b/Sources/DemoFeature/Demo.swift index a18acff8..3cf8f61b 100644 --- a/Sources/DemoFeature/Demo.swift +++ b/Sources/DemoFeature/Demo.swift @@ -10,6 +10,7 @@ import TcaHelpers @Reducer public struct Demo { + @ObservableState public struct State: Equatable { var appStoreOverlayIsPresented: Bool var step: Step @@ -126,55 +127,38 @@ public struct Demo { } public struct DemoView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStore + @Bindable var store: StoreOf - struct ViewState: Equatable { - let appStoreOverlayIsPresented: Bool - let isGameOver: Bool - - init(state: Demo.State) { - self.appStoreOverlayIsPresented = state.appStoreOverlayIsPresented - self.isGameOver = state.isGameOver - } - } - - public init( - store: StoreOf - ) { + public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { - SwitchStore(self.store.scope(state: \.step, action: \.self)) { step in - switch step { + Group { + switch store.step { case .onboarding: - CaseLet(\Demo.State.Step.onboarding, action: Demo.Action.onboarding) { - OnboardingView(store: $0) - .onAppear { self.viewStore.send(.onAppear) } + if let store = store.scope(state: \.step.onboarding, action: \.onboarding) { + OnboardingView(store: store) + .onAppear { self.store.send(.onAppear) } } case .game: - CaseLet(\Demo.State.Step.game, action: Demo.Action.game) { store in + if let store = store.scope(state: \.step.game, action: \.game) { GameWrapper( content: GameView( content: CubeView(store: store.scope(state: \.cubeScene, action: \.cubeScene)), store: store ), - isGameOver: self.viewStore.isGameOver, + isGameOver: self.store.isGameOver, bannerAction: { - self.viewStore.send(.fullVersionButtonTapped) + self.store.send(.fullVersionButtonTapped) } ) } } } .appStoreOverlay( - isPresented: self.viewStore.binding( - get: \.appStoreOverlayIsPresented, - send: Demo.Action.appStoreOverlay(isPresented:) - ) + isPresented: $store.appStoreOverlayIsPresented.sending(\.appStoreOverlay) ) { SKOverlay.AppClipConfiguration(position: .bottom) } diff --git a/Sources/GameCore/GameCore.swift b/Sources/GameCore/GameCore.swift index b8bfa686..40ae2aa3 100644 --- a/Sources/GameCore/GameCore.swift +++ b/Sources/GameCore/GameCore.swift @@ -5,6 +5,7 @@ import ClientModels import ComposableArchitecture import ComposableGameCenter import CubeCore +import Dependencies import DictionaryClient import GameOverFeature import HapticsCore @@ -20,58 +21,35 @@ import UserSettingsClient @Reducer public struct Game { - @Reducer - public struct Destination { - public enum State: Equatable { - case alert(AlertState) - case bottomMenu(BottomMenuState) - case gameOver(GameOver.State) - case settings(Settings.State = Settings.State()) - case upgradeInterstitial(UpgradeInterstitial.State = .init()) + @Reducer(state: .equatable) + public enum Destination { + case alert(AlertState) + @ReducerCaseEphemeral + case bottomMenu(BottomMenuState) + case gameOver(GameOver) + case settings(Settings) + case upgradeInterstitial(UpgradeInterstitial) + + @CasePathable + public enum Alert { + case forfeitButtonTapped } - - public enum Action { - case alert(Alert) - case bottomMenu(BottomMenu) - case gameOver(GameOver.Action) - case settings(Settings.Action) - case upgradeInterstitial(UpgradeInterstitial.Action) - - @CasePathable - public enum Alert { - case forfeitButtonTapped - } - @CasePathable - public enum BottomMenu: Equatable { - case confirmRemoveCube(LatticePoint) - case endGameButtonTapped - case exitButtonTapped - case forfeitGameButtonTapped - case settingsButtonTapped - } - } - - let dismissGame: DismissEffect - - public var body: some ReducerOf { - Scope(state: \.gameOver, action: \.gameOver) { - GameOver() - .dependency(\.dismiss, self.dismissGame) - } - Scope(state: \.settings, action: \.settings) { - Settings() - } - Scope(state: \.upgradeInterstitial, action: \.upgradeInterstitial) { - UpgradeInterstitial() - } + @CasePathable + public enum BottomMenu: Equatable { + case confirmRemoveCube(LatticePoint) + case endGameButtonTapped + case exitButtonTapped + case forfeitGameButtonTapped + case settingsButtonTapped } } + @ObservableState public struct State: Equatable { public var activeGames: ActiveGamesState public var cubes: Puzzle public var cubeStartedShakingAt: Date? - @PresentationState public var destination: Destination.State? + @Presents public var destination: Destination.State? public var gameContext: ClientModels.GameContext public var gameCurrentTime: Date public var gameMode: GameMode @@ -218,7 +196,8 @@ public struct Game { } .filterActionsForYourTurn() .ifLet(\.$destination, action: \.destination) { - Destination(dismissGame: self.dismiss) + Destination.body + .dependency(\.dismissGame, self.dismiss) } .sounds() } @@ -239,7 +218,7 @@ public struct Game { return .none case .delayedShowUpgradeInterstitial: - state.destination = .upgradeInterstitial() + state.destination = .upgradeInterstitial(UpgradeInterstitial.State()) return .none case .destination(.presented(.alert(.forfeitButtonTapped))): @@ -299,7 +278,7 @@ public struct Game { return .none case .destination(.presented(.bottomMenu(.settingsButtonTapped))): - state.destination = .settings() + state.destination = .settings(Settings.State()) return .none case let .destination(.presented(.gameOver(.delegate(.startGame(inProgressGame))))): @@ -433,12 +412,14 @@ public struct Game { type: .playedWord(state.selectedWord) ) + var cubes = state.cubes let result = verify( move: move, - on: &state.cubes, + on: &cubes, isValidWord: { self.dictionaryContains($0, state.language) }, previousMoves: state.moves ) + state.cubes = cubes defer { state.selectedWord = [] } @@ -568,7 +549,7 @@ extension TurnBasedMatchData { } } -extension BottomMenuState where Action == Game.Destination.Action.BottomMenu { +extension BottomMenuState where Action == Game.Destination.BottomMenu { public static func removeCube( index: LatticePoint, state: Game.State, diff --git a/Sources/GameCore/Views/GameFooterView.swift b/Sources/GameCore/Views/GameFooterView.swift index 601e8e90..49de9ea8 100644 --- a/Sources/GameCore/Views/GameFooterView.swift +++ b/Sources/GameCore/Views/GameFooterView.swift @@ -6,17 +6,6 @@ import SwiftUI public struct GameFooterView: View { let isLeftToRight: Bool let store: StoreOf - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let isAnimationReduced: Bool - let selectedWordString: String - - init(state: Game.State) { - self.isAnimationReduced = state.isAnimationReduced - self.selectedWordString = state.selectedWordString - } - } public init( isLeftToRight: Bool = false, @@ -24,17 +13,16 @@ public struct GameFooterView: View { ) { self.isLeftToRight = isLeftToRight self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { - if self.viewStore.selectedWordString.isEmpty { + if store.selectedWordString.isEmpty { WordListView( isLeftToRight: self.isLeftToRight, - store: self.store + store: store ) .transition( - viewStore.isAnimationReduced + store.isAnimationReduced ? .opacity : AnyTransition.offset(y: 50) .combined(with: .opacity) @@ -47,15 +35,8 @@ public struct WordListView: View { @Environment(\.adaptiveSize) var adaptiveSize @Environment(\.deviceState) var deviceState - struct ViewState: Equatable { - let isTurnBasedGame: Bool - let isYourTurn: Bool - let words: [PlayedWord] - } - let isLeftToRight: Bool let store: StoreOf - @ObservedObject var viewStore: ViewStore public init( isLeftToRight: Bool = false, @@ -63,14 +44,13 @@ public struct WordListView: View { ) { self.isLeftToRight = isLeftToRight self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } struct SpacerId: Hashable {} public var body: some View { Group { - if self.viewStore.words.isEmpty { + if store.playedWords.isEmpty { Text("Tap the cube to play") .adaptiveFont(.matterMedium, size: 14) } else { @@ -78,7 +58,7 @@ public struct WordListView: View { ScrollViewReader { reader in HStack(spacing: 10) { ForEach( - self.isLeftToRight ? self.viewStore.words : self.viewStore.words.reversed(), + self.isLeftToRight ? store.playedWords : store.playedWords.reversed(), id: \.word ) { word in ZStack(alignment: .topTrailing) { @@ -116,7 +96,7 @@ public struct WordListView: View { guard self.isLeftToRight else { return } reader.scrollTo(SpacerId(), anchor: self.isLeftToRight ? .trailing : .leading) } - .onChange(of: self.viewStore.words) { + .onChange(of: store.playedWords) { guard self.isLeftToRight else { return } withAnimation { reader.scrollTo(SpacerId(), anchor: self.isLeftToRight ? .trailing : .leading) @@ -140,7 +120,7 @@ public struct WordListView: View { @ViewBuilder func colors(for playedWord: PlayedWord) -> some View { - if self.viewStore.isTurnBasedGame && playedWord.isYourWord { + if store.gameContext.is(\.turnBased) && playedWord.isYourWord { LinearGradient( gradient: Gradient(colors: Styleguide.colors(for: playedWord.word)), startPoint: .bottomLeading, @@ -152,14 +132,6 @@ public struct WordListView: View { } } -extension WordListView.ViewState { - init(state: Game.State) { - self.isTurnBasedGame = state.gameContext.is(\.turnBased) - self.isYourTurn = state.isYourTurn - self.words = state.playedWords - } -} - #if DEBUG import ClientModels import ComposableGameCenter diff --git a/Sources/GameCore/Views/GameHeaderView.swift b/Sources/GameCore/Views/GameHeaderView.swift index 026449d5..25e1b0c7 100644 --- a/Sources/GameCore/Views/GameHeaderView.swift +++ b/Sources/GameCore/Views/GameHeaderView.swift @@ -5,84 +5,42 @@ import SwiftUI struct GameHeaderView: View { let store: StoreOf - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let isTurnBasedGame: Bool - let selectedWordString: String - - init(state: Game.State) { - self.isTurnBasedGame = state.gameContext.is(\.turnBased) - self.selectedWordString = state.selectedWordString - } - } - - public init( - store: StoreOf - ) { - self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) - } var body: some View { - if self.viewStore.isTurnBasedGame, self.viewStore.selectedWordString.isEmpty { - PlayersAndScoresView(store: self.store) + if store.gameContext.is(\.turnBased), store.selectedWordString.isEmpty { + PlayersAndScoresView(store: store) .transition(.opacity) } else { - ScoreView(store: self.store) + ScoreView(store: store) } } } +extension Game.State { + fileprivate var secondsRemaining: Int { + max(0, self.gameMode.seconds - self.secondsPlayed) + } +} + struct ScoreView: View { @Environment(\.deviceState) var deviceState let store: StoreOf - @ObservedObject var viewStore: ViewStore @State var isTimeAccented = false - struct ViewState: Equatable { - let currentScore: Int - let gameContext: GameContext - let gameMode: GameMode - let secondsRemaining: Int - let selectedWordHasAlreadyBeenPlayed: Bool - let selectedWordIsValid: Bool - let selectedWordScore: Int - let selectedWordString: String - - init(state: Game.State) { - self.currentScore = state.currentScore - self.gameContext = state.gameContext - self.gameMode = state.gameMode - self.secondsRemaining = max(0, state.gameMode.seconds - state.secondsPlayed) - self.selectedWordHasAlreadyBeenPlayed = state.selectedWordHasAlreadyBeenPlayed - self.selectedWordIsValid = state.selectedWordIsValid - self.selectedWordScore = state.selectedWordScore - self.selectedWordString = state.selectedWordString - } - } - - public init( - store: StoreOf - ) { - self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) - } - var body: some View { HStack { - if self.viewStore.selectedWordString.isEmpty { - if !self.viewStore.gameContext.is(\.turnBased) { - Text("\(self.viewStore.currentScore)") + if store.selectedWordString.isEmpty { + if !store.gameContext.is(\.turnBased) { + Text("\(store.currentScore)") } } else { - Text(self.viewStore.selectedWordString) + Text(store.selectedWordString) .overlay( Text( - self.viewStore.selectedWordIsValid - ? "\(self.viewStore.selectedWordScore)" - : self.viewStore.selectedWordHasAlreadyBeenPlayed + store.selectedWordIsValid + ? "\(store.selectedWordScore)" + : store.selectedWordHasAlreadyBeenPlayed ? "(used)" : "" ) @@ -91,27 +49,27 @@ struct ScoreView: View { .alignmentGuide(.trailing) { _ in 0 }, alignment: .topTrailing ) - .opacity(self.viewStore.selectedWordIsValid ? 1 : 0.5) + .opacity(store.selectedWordIsValid ? 1 : 0.5) .allowsTightening(true) .minimumScaleFactor(0.2) .lineLimit(1) .transition(.opacity) - .animation(nil, value: self.viewStore.selectedWordString) + .animation(nil, value: store.selectedWordString) } Spacer() - if !self.viewStore.gameContext.is(\.turnBased) { + if !store.gameContext.is(\.turnBased) { Text( displayTime( - gameMode: self.viewStore.gameMode, - secondsRemaining: self.viewStore.secondsRemaining + gameMode: store.gameMode, + secondsRemaining: store.secondsRemaining ) ) .foregroundColor(.white) .colorMultiply(self.isTimeAccented ? .red : .adaptiveBlack) .scaleEffect(self.isTimeAccented ? 1.5 : 1) - .onChange(of: self.viewStore.secondsRemaining) { _, secondsRemaining in + .onChange(of: store.secondsRemaining) { _, secondsRemaining in guard secondsRemaining == 10 || (secondsRemaining <= 5 && secondsRemaining > 0) else { return } diff --git a/Sources/GameCore/Views/GameNavView.swift b/Sources/GameCore/Views/GameNavView.swift index 20d7a716..fae8769f 100644 --- a/Sources/GameCore/Views/GameNavView.swift +++ b/Sources/GameCore/Views/GameNavView.swift @@ -3,41 +3,21 @@ import SwiftUI struct GameNavView: View { let store: StoreOf - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let isTrayAvailable: Bool - let isTrayVisible: Bool - let trayTitle: String - - init(state: Game.State) { - self.isTrayAvailable = state.isTrayAvailable - self.isTrayVisible = state.isTrayVisible - self.trayTitle = state.displayTitle - } - } - - public init( - store: StoreOf - ) { - self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) - } var body: some View { HStack(alignment: .center, spacing: 8) { Button { - self.viewStore.send(.trayButtonTapped, animation: .default) + store.send(.trayButtonTapped, animation: .default) } label: { HStack { - Text(self.viewStore.trayTitle) + Text(store.displayTitle) .lineLimit(1) Spacer() Image(systemName: "chevron.down") - .rotationEffect(.degrees(self.viewStore.isTrayVisible ? 180 : 0)) - .opacity(self.viewStore.isTrayAvailable ? 1 : 0) + .rotationEffect(.degrees(store.isTrayVisible ? 180 : 0)) + .opacity(store.isTrayAvailable ? 1 : 0) } .adaptiveFont(.matterMedium, size: 14) .foregroundColor(.adaptiveBlack) @@ -48,10 +28,10 @@ struct GameNavView: View { .opacity(0.05) ) .cornerRadius(12) - .disabled(!self.viewStore.isTrayAvailable) + .disabled(!store.isTrayAvailable) Button { - self.viewStore.send(.menuButtonTapped, animation: .default) + store.send(.menuButtonTapped, animation: .default) } label: { Image(systemName: "ellipsis") .foregroundColor(.adaptiveBlack) diff --git a/Sources/GameCore/Views/GameView.swift b/Sources/GameCore/Views/GameView.swift index 6a6b259e..7c33d2cc 100644 --- a/Sources/GameCore/Views/GameView.swift +++ b/Sources/GameCore/Views/GameView.swift @@ -15,27 +15,8 @@ public struct GameView: View where Content: View { @Environment(\.colorScheme) var colorScheme @Environment(\.deviceState) var deviceState let content: Content - let store: StoreOf + @Bindable var store: StoreOf var trayHeight: CGFloat { ActiveGamesView.height + (16 + self.adaptiveSize.padding) * 2 } - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let isAnimationReduced: Bool - let isDailyChallenge: Bool - let isGameLoaded: Bool - let isNavVisible: Bool - let isTrayVisible: Bool - let selectedWordString: String - - init(state: Game.State) { - self.isAnimationReduced = state.isAnimationReduced - self.isDailyChallenge = state.gameContext.is(\.dailyChallenge) - self.isGameLoaded = state.isGameLoaded - self.isNavVisible = state.isNavVisible - self.isTrayVisible = state.isTrayVisible - self.selectedWordString = state.selectedWordString - } - } public init( content: Content, @@ -43,14 +24,13 @@ public struct GameView: View where Content: View { ) { self.content = content self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { GeometryReader { proxy in ZStack { ZStack(alignment: .top) { - if self.viewStore.isGameLoaded { + if store.isGameLoaded { self.content .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .ignoresSafeArea() @@ -70,40 +50,36 @@ public struct GameView: View where Content: View { VStack { Group { - if self.viewStore.isNavVisible { - GameNavView(store: self.store) + if store.isNavVisible { + GameNavView(store: store) } else { - GameNavView(store: self.store) + GameNavView(store: store) .hidden() } - GameHeaderView(store: self.store) + GameHeaderView(store: store) } .screenEdgePadding(self.deviceState.isPad ? .horizontal : []) Spacer() - GameFooterView(store: self.store) + GameFooterView(store: store) .padding(.bottom) } .ignoresSafeArea(.keyboard) - if !self.viewStore.selectedWordString.isEmpty { + if !store.selectedWordString.isEmpty { WordSubmitButton( - store: self.store.scope( - state: \.wordSubmitButtonFeature, - action: \.wordSubmitButton - ) + store: store.scope(state: \.wordSubmitButtonFeature, action: \.wordSubmitButton) ) .ignoresSafeArea() .transition( - viewStore.isAnimationReduced + store.isAnimationReduced ? .opacity - : AnyTransition - .asymmetric(insertion: .offset(y: 50), removal: .offset(y: 50)) + : .asymmetric(insertion: .offset(y: 50), removal: .offset(y: 50)) .combined(with: .opacity) ) } ActiveGamesView( - store: self.store.scope(state: \.activeGames, action: \.activeGames), + store: store.scope(state: \.activeGames, action: \.activeGames), showMenuItems: false ) .adaptivePadding(.vertical, 8) @@ -121,76 +97,56 @@ public struct GameView: View where Content: View { ) ) .fixedSize(horizontal: false, vertical: true) - .opacity(self.viewStore.isTrayVisible ? 1 : 0) + .opacity(store.isTrayVisible ? 1 : 0) .offset(y: -self.trayHeight) } - .offset(y: self.viewStore.isTrayVisible ? self.trayHeight : 0) + .offset(y: store.isTrayVisible ? self.trayHeight : 0) .zIndex(0) - IfLetStore( - self.store.scope( - state: \.destination?.gameOver, action: \.destination.gameOver.presented - ), - then: GameOverView.init(store:) - ) - .background(Color.adaptiveWhite.ignoresSafeArea()) - .transition( - .asymmetric( - insertion: AnyTransition.opacity.animation(.linear(duration: 1)), - removal: .game - ) - ) - .zIndex(1) - - IfLetStore( - self.store.scope( - state: \.destination?.upgradeInterstitial, - action: \.destination.upgradeInterstitial.presented - ) - ) { store in + if let store = store.scope( + state: \.destination?.gameOver, action: \.destination.gameOver.presented + ) { + GameOverView(store: store) + .background(Color.adaptiveWhite.ignoresSafeArea()) + .transition( + .asymmetric( + insertion: AnyTransition.opacity.animation(.linear(duration: 1)), + removal: .game + ) + ) + .zIndex(1) + } else if let store = store.scope( + state: \.destination?.upgradeInterstitial, + action: \.destination.upgradeInterstitial.presented + ) { UpgradeInterstitialView(store: store) .transition(.opacity) + .zIndex(2) } - .zIndex(2) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background( - viewStore.isAnimationReduced - ? nil - : BloomBackground( + .background { + if !store.isAnimationReduced { + BloomBackground( size: proxy.size, - store: self.store - .scope( - state: { - BloomBackground.ViewState( - bloomCount: $0.selectedWord.count, - word: $0.selectedWordString - ) - }, - action: absurd - ) + word: store.selectedWordString ) - ) + } + } .background( Color(self.colorScheme == .dark ? .hex(0x111111) : .white) .ignoresSafeArea() ) - .bottomMenu( - store: self.store.scope(state: \.$destination, action: \.destination), - state: \.bottomMenu, - action: { .bottomMenu($0) } - ) - .alert(store: self.store.scope(state: \.$destination.alert, action: \.destination.alert)) + .bottomMenu($store.scope(state: \.destination?.bottomMenu, action: \.destination.bottomMenu)) + .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .sheet( - store: self.store.scope(state: \.$destination.settings, action: \.destination.settings) + item: $store.scope(state: \.destination?.settings, action: \.destination.settings) ) { store in NavigationStack { SettingsView(store: store, navPresentationStyle: .modal) } } } - .task { await self.viewStore.send(.task).finish() } + .task { await store.send(.task).finish() } } } - -private func absurd(_: Never) -> A {} diff --git a/Sources/GameCore/Views/PlayersAndScoresView.swift b/Sources/GameCore/Views/PlayersAndScoresView.swift index c12309fb..abe432a2 100644 --- a/Sources/GameCore/Views/PlayersAndScoresView.swift +++ b/Sources/GameCore/Views/PlayersAndScoresView.swift @@ -8,69 +8,40 @@ struct PlayersAndScoresView: View { @State var opponentImage: UIImage? let store: StoreOf @State var yourImage: UIImage? - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let isYourTurn: Bool - let opponent: ComposableGameCenter.Player? - let opponentScore: Int - let you: ComposableGameCenter.Player? - let yourScore: Int - - init(state: Game.State) { - self.isYourTurn = state.isYourTurn - self.opponent = state.gameContext.turnBased?.otherParticipant?.player - self.you = state.gameContext.turnBased?.localPlayer.player - self.yourScore = - state.gameContext.turnBased?.localPlayerIndex - .flatMap { state.turnBasedScores[$0] } - ?? (state.gameContext.is(\.turnBased) ? 0 : state.currentScore) - self.opponentScore = - state.gameContext.turnBased?.otherPlayerIndex - .flatMap { state.turnBasedScores[$0] } ?? 0 - } - } - - public init( - store: StoreOf - ) { - self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) - } var body: some View { HStack(spacing: 0) { PlayerView( - displayName: (self.viewStore.you?.displayName).map { LocalizedStringKey($0) } ?? "You", + displayName: (store.you?.displayName).map { LocalizedStringKey($0) } ?? "You", image: self.defaultYourImage ?? self.yourImage, - isPlayerTurn: self.viewStore.isYourTurn, + isPlayerTurn: store.isYourTurn, isYou: true, - score: self.viewStore.yourScore + score: store.yourScore ) PlayerView( - displayName: (self.viewStore.opponent?.displayName).map { LocalizedStringKey($0) } + displayName: (store.opponent?.displayName).map { LocalizedStringKey($0) } ?? "Your opponent", image: self.defaultOpponentImage ?? self.opponentImage, - isPlayerTurn: !self.viewStore.isYourTurn, + isPlayerTurn: !store.isYourTurn, isYou: false, - score: self.viewStore.opponentScore + score: store.opponentScore ) } .onAppear { - self.viewStore.opponent?.rawValue?.loadPhoto(for: .small) { image, _ in + store.opponent?.rawValue?.loadPhoto(for: .small) { image, _ in self.opponentImage = image } - self.viewStore.you?.rawValue?.loadPhoto(for: .small) { image, _ in + store.you?.rawValue?.loadPhoto(for: .small) { image, _ in self.yourImage = image } } - .onChange(of: self.viewStore.opponent) { _, player in + .onChange(of: store.opponent) { _, player in player?.rawValue?.loadPhoto(for: .small) { image, _ in self.opponentImage = image } } - .onChange(of: self.viewStore.you) { _, player in + .onChange(of: store.you) { _, player in player?.rawValue?.loadPhoto(for: .small) { image, _ in self.yourImage = image } @@ -143,6 +114,24 @@ private struct PlayerView: View { } } +fileprivate extension Game.State { + var opponent: ComposableGameCenter.Player? { + self.gameContext.turnBased?.otherParticipant?.player + } + var opponentScore: Int { + self.gameContext.turnBased?.otherPlayerIndex + .flatMap { self.turnBasedScores[$0] } ?? 0 + } + var you: ComposableGameCenter.Player? { + self.gameContext.turnBased?.localPlayer.player + } + var yourScore: Int { + self.gameContext.turnBased?.localPlayerIndex + .flatMap { self.turnBasedScores[$0] } + ?? (self.gameContext.is(\.turnBased) ? 0 : self.currentScore) + } +} + #if DEBUG import ClientModels import Overture diff --git a/Sources/GameCore/Views/WordSubmitButton.swift b/Sources/GameCore/Views/WordSubmitButton.swift index 4a107947..457d4991 100644 --- a/Sources/GameCore/Views/WordSubmitButton.swift +++ b/Sources/GameCore/Views/WordSubmitButton.swift @@ -4,6 +4,7 @@ import SwiftUI @Reducer public struct WordSubmitButtonFeature { + @ObservableState public struct State: Equatable { public var isSelectedWordValid: Bool public let isTurnBasedMatch: Bool @@ -23,6 +24,7 @@ public struct WordSubmitButtonFeature { } } + @ObservableState public struct ButtonState: Equatable { public var areReactionsOpen: Bool public var favoriteReactions: [Move.Reaction] @@ -138,19 +140,15 @@ public struct WordSubmitButtonFeature { public struct WordSubmitButton: View { @Environment(\.deviceState) var deviceState let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf @State var isTouchDown = false - public init( - store: StoreOf - ) { + public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) } public var body: some View { ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) { - if self.viewStore.wordSubmitButton.areReactionsOpen { + if store.wordSubmitButton.areReactionsOpen { RadialGradient( gradient: Gradient(colors: [.white, Color.white.opacity(0)]), center: .bottom, @@ -164,13 +162,13 @@ public struct WordSubmitButton: View { Spacer() ZStack { - ReactionsView(store: self.store.scope(state: \.wordSubmitButton, action: \.self)) + ReactionsView(store: store.scope(state: \.wordSubmitButton, action: \.self)) Button { - self.viewStore.send(.submitButtonTapped, animation: .default) + store.send(.submitButtonTapped, animation: .default) } label: { Group { - if !self.viewStore.wordSubmitButton.areReactionsOpen { + if !store.wordSubmitButton.areReactionsOpen { Image(systemName: "hand.thumbsup") } else { Image(systemName: "xmark") @@ -182,7 +180,7 @@ public struct WordSubmitButton: View { ) .background(Circle().fill(Color.adaptiveBlack)) .foregroundColor(.adaptiveWhite) - .opacity(self.viewStore.isSelectedWordValid ? 1 : 0.5) + .opacity(store.isSelectedWordValid ? 1 : 0.5) .font(.system(size: self.deviceState.isPad ? 40 : 30)) .adaptivePadding([.all], .grid(4)) // NB: Expand the tappable radius of the button. @@ -192,12 +190,12 @@ public struct WordSubmitButton: View { DragGesture(minimumDistance: 0) .onChanged { touch in if !self.isTouchDown { - self.viewStore.send(.submitButtonPressed, animation: .default) + store.send(.submitButtonPressed, animation: .default) } self.isTouchDown = true } .onEnded { _ in - self.viewStore.send(.submitButtonReleased, animation: .default) + store.send(.submitButtonReleased, animation: .default) self.isTouchDown = false } ) @@ -207,32 +205,25 @@ public struct WordSubmitButton: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background( - self.viewStore.wordSubmitButton.areReactionsOpen - ? Color.isowordsBlack.opacity(0.4) - : nil - ) - .animation(.default, value: self.viewStore.wordSubmitButton.areReactionsOpen) - .onTapGesture { self.viewStore.send(.backgroundTapped, animation: .default) } + .background { + if store.wordSubmitButton.areReactionsOpen { + Color.isowordsBlack.opacity(0.4) + } + } + .animation(.default, value: store.wordSubmitButton.areReactionsOpen) + .onTapGesture { store.send(.backgroundTapped, animation: .default) } } } struct ReactionsView: View { let store: Store - @ObservedObject var viewStore: - ViewStore - - public init(store: Store) { - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) - } var body: some View { - ForEach(Array(self.viewStore.favoriteReactions.enumerated()), id: \.offset) { idx, reaction in + ForEach(Array(store.favoriteReactions.enumerated()), id: \.offset) { idx, reaction in let offset = self.offset(index: idx) Button { - self.viewStore.send(.reactionButtonTapped(reaction), animation: .default) + store.send(.reactionButtonTapped(reaction), animation: .default) } label: { Text(reaction.rawValue) .font(.system(size: 32)) @@ -240,23 +231,23 @@ struct ReactionsView: View { } .background(Color.white.opacity(0.5)) .clipShape(Circle()) - .rotationEffect(.degrees(self.viewStore.areReactionsOpen ? -360 : 0)) - .opacity(self.viewStore.areReactionsOpen ? 1 : 0) + .rotationEffect(.degrees(store.areReactionsOpen ? -360 : 0)) + .opacity(store.areReactionsOpen ? 1 : 0) .offset(x: offset.x, y: offset.y) .animation( - .default.delay(Double(idx) / Double(self.viewStore.favoriteReactions.count * 10)), - value: self.viewStore.areReactionsOpen + .default.delay(Double(idx) / Double(store.favoriteReactions.count * 10)), + value: store.areReactionsOpen ) } } func offset(index: Int) -> CGPoint { let angle: CGFloat = - CGFloat.pi / CGFloat(self.viewStore.favoriteReactions.count - 1) * CGFloat(index) + .pi + CGFloat.pi / CGFloat(store.favoriteReactions.count - 1) * CGFloat(index) + .pi return .init( - x: self.viewStore.areReactionsOpen ? cos(angle) * 130 : 0, - y: self.viewStore.areReactionsOpen ? sin(angle) * 130 : 0 + x: store.areReactionsOpen ? cos(angle) * 130 : 0, + y: store.areReactionsOpen ? sin(angle) * 130 : 0 ) } } diff --git a/Sources/GameOverFeature/DismissGame.swift b/Sources/GameOverFeature/DismissGame.swift new file mode 100644 index 00000000..1bb13b99 --- /dev/null +++ b/Sources/GameOverFeature/DismissGame.swift @@ -0,0 +1,15 @@ +import ComposableArchitecture +import Dependencies + +private enum DismissGameKey: DependencyKey { + static var liveValue: DismissEffect { + @Dependency(\.dismiss) var dismiss + return dismiss + } +} +extension DependencyValues { + public var dismissGame: DismissEffect { + get { self[DismissGameKey.self] } + set { self[DismissGameKey.self] = newValue } + } +} diff --git a/Sources/GameOverFeature/GameOverView.swift b/Sources/GameOverFeature/GameOverView.swift index e1395972..a93559c5 100644 --- a/Sources/GameOverFeature/GameOverView.swift +++ b/Sources/GameOverFeature/GameOverView.swift @@ -17,32 +17,17 @@ import UserDefaultsClient @Reducer public struct GameOver { - @Reducer - public struct Destination { - public enum State: Equatable { - case notificationsAuthAlert(NotificationsAuthAlert.State = .init()) - case upgradeInterstitial(UpgradeInterstitial.State = .init()) - } - - public enum Action { - case notificationsAuthAlert(NotificationsAuthAlert.Action) - case upgradeInterstitial(UpgradeInterstitial.Action) - } - - public var body: some ReducerOf { - Scope(state: \.notificationsAuthAlert, action: \.notificationsAuthAlert) { - NotificationsAuthAlert() - } - Scope(state: \.upgradeInterstitial, action: \.upgradeInterstitial) { - UpgradeInterstitial() - } - } + @Reducer(state: .equatable) + public enum Destination { + case notificationsAuthAlert(NotificationsAuthAlert) + case upgradeInterstitial(UpgradeInterstitial) } + @ObservableState public struct State: Equatable { public var completedGame: CompletedGame public var dailyChallenges: [FetchTodaysDailyChallengeResponse] - @PresentationState public var destination: Destination.State? + @Presents public var destination: Destination.State? public var gameModeIsLoading: GameMode? public var isDemo: Bool public var isNotificationMenuPresented: Bool @@ -52,6 +37,38 @@ public struct GameOver { public var turnBasedContext: TurnBasedContext? public var userNotificationSettings: UserNotificationClient.Notification.Settings? + var completedMatch: CompletedMatch? { + switch self.completedGame.gameContext { + case .dailyChallenge, .shared, .solo: + return nil + case .turnBased: + return self.turnBasedContext.flatMap { + CompletedMatch(completedGame: self.completedGame, turnBasedContext: $0) + } + } + } + var theirWords: [PlayedWord] { self.words.filter { !$0.isYourWord } } + var unplayedDaily: GameMode? { + self.dailyChallenges + .first(where: { $0.yourResult.rank == nil })?.dailyChallenge.gameMode + } + var words: [PlayedWord] { + self.completedGame.moves.compactMap { move in + move.type.playedWord.map { + PlayedWord( + isYourWord: move.playerIndex == self.completedGame.localPlayerIndex, + reactions: move.reactions, + score: move.score, + word: self.completedGame.cubes.string(from: $0) + ) + } + } + } + var you: ComposableGameCenter.Player? { self.turnBasedContext?.localPlayer.player } + var yourOpponent: ComposableGameCenter.Player? { self.turnBasedContext?.otherPlayer } + var yourWords: [PlayedWord] { self.words.filter { $0.isYourWord } } + var yourScore: Int { yourWords.reduce(into: 0) { $0 += $1.score } } + public init( completedGame: CompletedGame, dailyChallenges: [FetchTodaysDailyChallengeResponse] = [], @@ -110,7 +127,7 @@ public struct GameOver { @Dependency(\.apiClient) var apiClient @Dependency(\.audioPlayer) var audioPlayer @Dependency(\.database) var database - @Dependency(\.dismiss) var dismiss + @Dependency(\.dismissGame) var dismissGame @Dependency(\.fileClient) var fileClient @Dependency(\.mainRunLoop) var mainRunLoop @Dependency(\.storeKit.requestReview) var requestReview @@ -131,7 +148,7 @@ public struct GameOver { else { return .run { send in try? await self.requestReviewAsync() - await self.dismiss(animation: .default) + await self.dismissGame(animation: .default) } } @@ -150,7 +167,7 @@ public struct GameOver { return .none case .delayedShowUpgradeInterstitial: - state.destination = .upgradeInterstitial() + state.destination = .upgradeInterstitial(UpgradeInterstitial.State()) return .none case .delegate: @@ -191,14 +208,14 @@ public struct GameOver { where state.destination.is(\.some.notificationsAuthAlert): return .run { _ in try? await self.requestReviewAsync() - await self.dismiss(animation: .default) + await self.dismissGame(animation: .default) } case .destination( .presented(.notificationsAuthAlert(.delegate(.didChooseNotificationSettings))) ): return .run { _ in - await self.dismiss(animation: .default) + await self.dismissGame(animation: .default) } case .destination: @@ -259,7 +276,7 @@ public struct GameOver { return .run { [completedGame = state.completedGame, isDemo = state.isDemo] send in guard isDemo || completedGame.currentScore > 0 else { - await self.dismiss(animation: .default) + await self.dismissGame(animation: .default) return } @@ -341,7 +358,7 @@ public struct GameOver { } } .ifLet(\.$destination, action: \.destination) { - Destination() + Destination.body } } @@ -371,76 +388,13 @@ public struct GameOverView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.opponentImage) var defaultOpponentImage @Environment(\.yourImage) var defaultYourImage - let store: StoreOf - @ObservedObject var viewStore: ViewStore + @Bindable var store: StoreOf @State var yourImage: UIImage? @State var yourOpponentImage: UIImage? @State var isSharePresented = false - struct ViewState: Equatable { - let completedMatch: CompletedMatch? - let gameContext: CompletedGame.GameContext - let gameMode: GameMode - let gameModeIsLoading: GameMode? - let isDemo: Bool - let isUpgradeInterstitialPresented: Bool - let isViewEnabled: Bool - let showConfetti: Bool - let summary: GameOver.State.RankSummary? - let unplayedDaily: GameMode? - let words: [PlayedWord] - let you: ComposableGameCenter.Player? - let yourOpponent: ComposableGameCenter.Player? - let yourScore: Int - var theirWords: [PlayedWord] { self.words.filter { !$0.isYourWord } } - var yourWords: [PlayedWord] { self.words.filter { $0.isYourWord } } - - init(state: GameOver.State) { - self.gameContext = state.completedGame.gameContext - self.gameMode = state.completedGame.gameMode - let yourWords = state.completedGame.words( - forPlayerIndex: state.completedGame.localPlayerIndex) - self.gameModeIsLoading = state.gameModeIsLoading - let yourScore = yourWords.reduce(into: 0) { $0 += $1.score } - switch state.completedGame.gameContext { - case .dailyChallenge: - self.completedMatch = nil - case .shared: - self.completedMatch = nil - case .solo: - self.completedMatch = nil - case .turnBased: - self.completedMatch = state.turnBasedContext.flatMap { - CompletedMatch(completedGame: state.completedGame, turnBasedContext: $0) - } - } - self.isDemo = state.isDemo - self.isUpgradeInterstitialPresented = state.destination.is(\.some.upgradeInterstitial) - self.isViewEnabled = state.isViewEnabled - self.showConfetti = state.showConfetti - self.summary = state.summary - self.unplayedDaily = - state.dailyChallenges - .first(where: { $0.yourResult.rank == nil })?.dailyChallenge.gameMode - self.words = state.completedGame.moves.compactMap { move in - move.type.playedWord.map { - PlayedWord( - isYourWord: move.playerIndex == state.completedGame.localPlayerIndex, - reactions: move.reactions, - score: move.score, - word: state.completedGame.cubes.string(from: $0) - ) - } - } - self.you = state.turnBasedContext?.localPlayer.player - self.yourOpponent = state.turnBasedContext?.otherPlayer - self.yourScore = yourScore - } - } - public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { @@ -450,10 +404,10 @@ public struct GameOverView: View { HStack { Image(systemName: "cube.fill") - if !self.viewStore.isDemo { + if !store.isDemo { Spacer() Button { - self.viewStore.send(.closeButtonTapped, animation: .default) + store.send(.closeButtonTapped, animation: .default) } label: { Image(systemName: "xmark") } @@ -462,7 +416,7 @@ public struct GameOverView: View { .font(.system(size: 24)) .adaptivePadding() - switch self.viewStore.gameContext { + switch store.completedGame.gameContext { case .dailyChallenge: self.dailyChallengeResults case .shared: @@ -474,7 +428,7 @@ public struct GameOverView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .opacity(self.viewStore.isUpgradeInterstitialPresented ? 0 : 1) + .opacity(store.destination.is(\.some.upgradeInterstitial) ? 0 : 1) VStack(spacing: .grid(8)) { Divider() @@ -494,17 +448,15 @@ public struct GameOverView: View { foregroundColor: self.colorScheme == .dark ? .isowordsBlack : self.color ) ) - .padding(.bottom, .grid(self.viewStore.isDemo ? 30 : 0)) + .padding(.bottom, .grid(store.isDemo ? 30 : 0)) } .padding(.vertical, .grid(12)) } - IfLetStore( - self.store.scope( - state: \.destination?.upgradeInterstitial, - action: \.destination.upgradeInterstitial.presented - ) - ) { store in + if let store = store.scope( + state: \.destination?.upgradeInterstitial, + action: \.destination.upgradeInterstitial.presented + ) { UpgradeInterstitialView(store: store) .transition(.opacity) } @@ -514,22 +466,23 @@ public struct GameOverView: View { (self.colorScheme == .dark ? .isowordsBlack : self.color) .ignoresSafeArea() ) - .task { await self.viewStore.send(.task).finish() } + .task { await store.send(.task).finish() } .notificationsAlert( - store: self.store.scope(state: \.$destination, action: \.destination), - state: \.notificationsAuthAlert, - action: { .notificationsAuthAlert($0) } + $store.scope( + state: \.destination?.notificationsAuthAlert, + action: \.destination.notificationsAuthAlert + ) ) .sheet(isPresented: self.$isSharePresented) { ActivityView(activityItems: [URL(string: "https://www.isowords.xyz")!]) .ignoresSafeArea() } - .disabled(!self.viewStore.isViewEnabled) + .disabled(!store.isViewEnabled) } @ViewBuilder var dailyChallengeResults: some View { - let result = self.viewStore.summary?.dailyChallenge + let result = store.summary?.dailyChallenge VStack(spacing: -8) { result.map { @@ -547,14 +500,11 @@ public struct GameOverView: View { .lineLimit(2) .multilineTextAlignment(.center) .redacted(reason: result == nil ? .placeholder : []) - .overlay( - self.viewStore.showConfetti - ? Confetti( - foregroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack - ) - : nil, - alignment: .top - ) + .overlay(alignment: .top) { + if store.showConfetti { + Confetti(foregroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack) + } + } VStack(spacing: 48) { VStack(spacing: self.adaptiveSize.pad(8)) { @@ -575,12 +525,12 @@ public struct GameOverView: View { HStack { Text("Score") Spacer() - Text("\(self.viewStore.yourScore)") + Text("\(store.yourScore)") } HStack { Text("Words found") Spacer() - Text("\(self.viewStore.yourWords.count)") + Text("\(store.yourWords.count)") } } .adaptivePadding(.horizontal) @@ -588,7 +538,7 @@ public struct GameOverView: View { self.wordList - if let unplayedDaily = self.viewStore.unplayedDaily { + if let unplayedDaily = store.unplayedDaily { VStack(spacing: self.adaptiveSize.pad(8)) { LazyVGrid( columns: [ @@ -601,22 +551,22 @@ public struct GameOverView: View { icon: Image(systemName: "clock.fill"), color: self.color, inactiveText: unplayedDaily == .unlimited ? Text("Played") : nil, - isLoading: self.viewStore.gameModeIsLoading == .timed, + isLoading: store.gameModeIsLoading == .timed, resumeText: nil, - action: { self.viewStore.send(.gameButtonTapped(.timed), animation: .default) } + action: { store.send(.gameButtonTapped(.timed), animation: .default) } ) - .disabled(self.viewStore.gameModeIsLoading != nil) + .disabled(store.gameModeIsLoading != nil) GameButton( title: Text("Unlimited"), icon: Image(systemName: "infinity"), color: self.color, inactiveText: unplayedDaily == .timed ? Text("Played") : nil, - isLoading: self.viewStore.gameModeIsLoading == .unlimited, + isLoading: store.gameModeIsLoading == .unlimited, resumeText: nil, - action: { self.viewStore.send(.gameButtonTapped(.unlimited), animation: .default) } + action: { store.send(.gameButtonTapped(.unlimited), animation: .default) } ) - .disabled(self.viewStore.gameModeIsLoading != nil) + .disabled(store.gameModeIsLoading != nil) } } .adaptivePadding(.horizontal) @@ -628,9 +578,9 @@ public struct GameOverView: View { @ViewBuilder var soloResults: some View { VStack(spacing: -8) { - Text("\(self.viewStore.yourScore).").fontWeight(.medium) + Text("\(store.yourScore).").fontWeight(.medium) + Text("\n") - + Text(praise(mode: self.viewStore.gameMode, score: self.viewStore.yourScore)) + + Text(praise(mode: store.completedGame.gameMode, score: store.yourScore)) } .adaptiveFont(.matter, size: 52) .adaptivePadding(.horizontal) @@ -651,7 +601,7 @@ public struct GameOverView: View { HStack { Text(timeScope.displayTitle) Spacer() - let rank = self.viewStore.summary?.leaderboard?[timeScope] + let rank = store.summary?.leaderboard?[timeScope] Text( """ \((rank?.rank ?? 0) as NSNumber, formatter: ordinalFormatter) of \ @@ -663,22 +613,19 @@ public struct GameOverView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .animation(.default, value: self.viewStore.summary) + .animation(.default, value: store.summary) } .adaptiveFont(.matterMedium, size: 16) .adaptivePadding(.horizontal) - .overlay( - self.viewStore.showConfetti - ? Confetti( - foregroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack - ) - : nil, - alignment: .top - ) + .overlay(alignment: .top) { + if store.showConfetti { + Confetti(foregroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack) + } + } self.wordList - if !self.viewStore.isDemo { + if !store.isDemo { VStack(spacing: self.adaptiveSize.pad(8)) { Text("Play again") .adaptiveFont(.matterMedium, size: 16) @@ -698,7 +645,7 @@ public struct GameOverView: View { inactiveText: nil, isLoading: false, resumeText: nil, - action: { self.viewStore.send(.gameButtonTapped(.timed), animation: .default) } + action: { store.send(.gameButtonTapped(.timed), animation: .default) } ) GameButton( @@ -708,7 +655,7 @@ public struct GameOverView: View { inactiveText: nil, isLoading: false, resumeText: nil, - action: { self.viewStore.send(.gameButtonTapped(.unlimited), animation: .default) } + action: { store.send(.gameButtonTapped(.unlimited), animation: .default) } ) } .adaptivePadding(.horizontal) @@ -725,23 +672,20 @@ public struct GameOverView: View { @ViewBuilder var turnBasedResults: some View { - if let completedMatch = self.viewStore.completedMatch { + if let completedMatch = store.completedMatch { VStack(spacing: -8) { Text(completedMatch.description).fontWeight(.medium) Text(completedMatch.detailDescription) } .adaptiveFont(.matter, size: 52) - .overlay( - self.viewStore.showConfetti - ? Confetti( - foregroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack - ) - : nil, - alignment: .bottom - ) + .overlay(alignment: .bottom) { + if store.showConfetti { + Confetti(foregroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack) + } + } if completedMatch.isTurnBased { - Button("Rematch?") { self.viewStore.send(.rematchButtonTapped, animation: .default) } + Button("Rematch?") { store.send(.rematchButtonTapped, animation: .default) } .adaptiveFont(.matter, size: 14) .buttonStyle( ActionButtonStyle( @@ -760,7 +704,7 @@ public struct GameOverView: View { .adaptiveFont(.matterMedium, size: 14) .frame(maxWidth: .infinity, alignment: .trailing) .lineLimit(1) - Text("\(self.viewStore.yourScore)") + Text("\(store.yourScore)") .adaptiveFont(.matterMedium, size: 20) .frame(maxWidth: .infinity, alignment: .trailing) } @@ -785,7 +729,7 @@ public struct GameOverView: View { .background((self.colorScheme == .dark ? self.color : .isowordsBlack).opacity(0.2)) VStack(alignment: .trailing) { - ForEach(self.viewStore.yourWords, id: \.word) { word in + ForEach(store.yourWords, id: \.word) { word in WordView( backgroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack, foregroundColor: self.colorScheme == .dark ? .isowordsBlack : self.color, @@ -793,7 +737,7 @@ public struct GameOverView: View { ) } } - .padding(.top, self.viewStore.words.first?.isYourWord == .some(true) ? 0 : .grid(6)) + .padding(.top, store.words.first?.isYourWord == .some(true) ? 0 : .grid(6)) .padding(.grid(2)) } .padding(.vertical) @@ -842,7 +786,7 @@ public struct GameOverView: View { ) VStack(alignment: .leading) { - ForEach(self.viewStore.theirWords, id: \.word) { word in + ForEach(store.theirWords, id: \.word) { word in WordView( backgroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack, foregroundColor: self.colorScheme == .dark ? .isowordsBlack : self.color, @@ -850,7 +794,7 @@ public struct GameOverView: View { ) } } - .padding(.top, self.viewStore.words.first?.isYourWord == .some(true) ? .grid(6) : 0) + .padding(.top, store.words.first?.isYourWord == .some(true) ? .grid(6) : 0) .padding(.grid(2)) } .padding(.vertical) @@ -874,10 +818,10 @@ public struct GameOverView: View { } ) .onAppear { - self.viewStore.you?.rawValue?.loadPhoto(for: .small) { image, _ in + store.you?.rawValue?.loadPhoto(for: .small) { image, _ in self.yourImage = image } - self.viewStore.yourOpponent?.rawValue?.loadPhoto(for: .small) { image, _ in + store.yourOpponent?.rawValue?.loadPhoto(for: .small) { image, _ in self.yourOpponentImage = image } } @@ -885,7 +829,7 @@ public struct GameOverView: View { } var color: Color { - switch self.viewStore.gameContext { + switch store.completedGame.gameContext { case .dailyChallenge: return .dailyChallenge case .shared, .solo: @@ -904,7 +848,7 @@ public struct GameOverView: View { ScrollView(.horizontal, showsIndicators: false) { HStack { - ForEach(self.viewStore.yourWords, id: \.word) { word in + ForEach(store.yourWords, id: \.word) { word in WordView( backgroundColor: self.colorScheme == .dark ? self.color : .isowordsBlack, foregroundColor: self.colorScheme == .dark ? .isowordsBlack : self.color, diff --git a/Sources/HomeFeature/DailyChallengeHeaderView.swift b/Sources/HomeFeature/DailyChallengeHeaderView.swift index f258434a..ab7b7675 100644 --- a/Sources/HomeFeature/DailyChallengeHeaderView.swift +++ b/Sources/HomeFeature/DailyChallengeHeaderView.swift @@ -8,24 +8,10 @@ import SwiftUI struct DailyChallengeHeaderView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.date) var date - let store: StoreOf - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let dailyChallenges: [FetchTodaysDailyChallengeResponse]? - - init(homeState: Home.State) { - self.dailyChallenges = homeState.dailyChallenges - } - } - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) - } + @Bindable var store: StoreOf var body: some View { - let numberOfPlayers = self.viewStore.dailyChallenges?.reduce(into: 0) { + let numberOfPlayers = store.dailyChallenges?.reduce(into: 0) { $0 += $1.yourResult.outOf } @@ -60,7 +46,7 @@ struct DailyChallengeHeaderView: View { VStack { Button { - self.viewStore.send(.dailyChallengeButtonTapped) + store.send(.dailyChallengeButtonTapped) } label: { HStack { Group { @@ -115,15 +101,14 @@ struct DailyChallengeHeaderView: View { } } .navigationDestination( - store: self.store.scope( - state: \.$destination.dailyChallenge, action: \.destination.dailyChallenge - ), - destination: DailyChallengeView.init(store:) - ) + item: $store.scope(state: \.destination?.dailyChallenge, action: \.destination.dailyChallenge) + ) { store in + DailyChallengeView(store: store) + } } var hasPlayedAllDailyChallenges: Bool { - self.viewStore.dailyChallenges + store.dailyChallenges .map { $0.allSatisfy { $0.yourResult.rank != nil } } ?? false } diff --git a/Sources/HomeFeature/Home.swift b/Sources/HomeFeature/Home.swift index 4b0b678f..ad59735b 100644 --- a/Sources/HomeFeature/Home.swift +++ b/Sources/HomeFeature/Home.swift @@ -22,54 +22,23 @@ public struct ActiveMatchResponse: Equatable { @Reducer public struct Home { - @Reducer - public struct Destination { - public enum State: Equatable { - case changelog(ChangelogReducer.State = .init()) - case dailyChallenge(DailyChallengeReducer.State = .init()) - case leaderboard(Leaderboard.State = .init()) - case multiplayer(Multiplayer.State) - case settings(Settings.State = Settings.State()) - case solo(Solo.State = .init()) - } - - public enum Action { - case changelog(ChangelogReducer.Action) - case dailyChallenge(DailyChallengeReducer.Action) - case leaderboard(Leaderboard.Action) - case multiplayer(Multiplayer.Action) - case settings(Settings.Action) - case solo(Solo.Action) - } - - public var body: some ReducerOf { - Scope(state: \.changelog, action: \.changelog) { - ChangelogReducer() - } - Scope(state: \.dailyChallenge, action: \.dailyChallenge) { - DailyChallengeReducer() - } - Scope(state: \.leaderboard, action: \.leaderboard) { - Leaderboard() - } - Scope(state: \.multiplayer, action: \.multiplayer) { - Multiplayer() - } - Scope(state: \.settings, action: \.settings) { - Settings() - } - Scope(state: \.solo, action: \.solo) { - Solo() - } - } + @Reducer(state: .equatable) + public enum Destination { + case changelog(ChangelogReducer) + case dailyChallenge(DailyChallengeReducer) + case leaderboard(Leaderboard) + case multiplayer(Multiplayer) + case settings(Settings) + case solo(Solo) } + @ObservableState public struct State: Equatable { public var dailyChallenges: [FetchTodaysDailyChallengeResponse]? - @PresentationState public var destination: Destination.State? + @Presents public var destination: Destination.State? public var hasChangelog: Bool public var hasPastTurnBasedGames: Bool - @PresentationState public var nagBanner: NagBanner.State? + @Presents public var nagBanner: NagBanner.State? public var savedGames: SavedGamesState { didSet { guard var dailyChallengeState = self.destination?.dailyChallenge @@ -163,7 +132,7 @@ public struct Home { public var body: some ReducerOf { Reduce(self.core) .ifLet(\.$destination, action: \.destination) { - Destination() + Destination.body } .ifLet(\.$nagBanner, action: \.nagBanner) { NagBanner() @@ -286,7 +255,7 @@ public struct Home { return .none case .leaderboardButtonTapped: - state.destination = .leaderboard() + state.destination = .leaderboard(Leaderboard.State()) return .none case .multiplayerButtonTapped: @@ -297,7 +266,7 @@ public struct Home { return .none case .settingsButtonTapped: - state.destination = .settings() + state.destination = .settings(Settings.State()) return .none case .soloButtonTapped: @@ -410,28 +379,11 @@ extension GameCenterClient { } public struct HomeView: View { - struct ViewState: Equatable { - let hasActiveGames: Bool - let hasChangelog: Bool - let isNagBannerVisible: Bool - - init(state: Home.State) { - self.hasActiveGames = - state.savedGames.dailyChallengeUnlimited != nil - || state.savedGames.unlimited != nil - || !state.turnBasedMatches.isEmpty - self.hasChangelog = state.hasChangelog - self.isNagBannerVisible = state.nagBanner != nil - } - } - @Environment(\.colorScheme) var colorScheme - let store: StoreOf - @ObservedObject var viewStore: ViewStore + @Bindable var store: StoreOf public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(store, observe: ViewState.init) } public var body: some View { @@ -440,20 +392,20 @@ public struct HomeView: View { VStack(spacing: .grid(12)) { VStack(spacing: .grid(6)) { HStack { - CubeIconView(shake: self.viewStore.hasChangelog) { - self.viewStore.send(.cubeButtonTapped) + CubeIconView(shake: store.hasChangelog) { + store.send(.cubeButtonTapped) } Spacer() Button { - self.viewStore.send(.howToPlayButtonTapped, animation: .default) + store.send(.howToPlayButtonTapped, animation: .default) } label: { Image(systemName: "questionmark.circle") } Button { - self.viewStore.send(.settingsButtonTapped) + store.send(.settingsButtonTapped) } label: { Image(systemName: "gear") } @@ -462,11 +414,11 @@ public struct HomeView: View { .foregroundColor(self.colorScheme == .dark ? .hex(0xF2E29F) : .isowordsBlack) .adaptivePadding(.horizontal) - DailyChallengeHeaderView(store: self.store) + DailyChallengeHeaderView(store: store) .screenEdgePadding(.horizontal) } - if self.viewStore.hasActiveGames { + if store.hasActiveGames { VStack(alignment: .leading) { Text("Active games") .adaptiveFont(.matterMedium, size: 16) @@ -474,19 +426,16 @@ public struct HomeView: View { .screenEdgePadding(.horizontal) ActiveGamesView( - store: self.store.scope( - state: \.activeGames, - action: Home.Action.activeGames - ), + store: store.scope(state: \.activeGames, action: \.activeGames), showMenuItems: true ) .foregroundColor(self.colorScheme == .dark ? .hex(0xE9A27C) : .isowordsBlack) } } - StartNewGameView(store: self.store) + StartNewGameView(store: store) .screenEdgePadding(.horizontal) - LeaderboardLinkView(store: self.store) + LeaderboardLinkView(store: store) .screenEdgePadding(.horizontal) } .adaptivePadding(.vertical, .grid(4)) @@ -502,7 +451,7 @@ public struct HomeView: View { ) ) - if self.viewStore.isNagBannerVisible { + if store.nagBanner != nil { Spacer().frame(height: 80) } } @@ -526,22 +475,22 @@ public struct HomeView: View { .ignoresSafeArea() ) - IfLetStore( - self.store.scope(state: \.nagBanner, action: \.nagBanner.presented), - then: NagBannerView.init(store:) - ) + if let store = store.scope(state: \.nagBanner, action: \.nagBanner.presented) { + NagBannerView(store: store) + } } .navigationBarHidden(true) .navigationDestination( - store: self.store.scope(state: \.$destination.settings, action: \.destination.settings) + item: $store.scope(state: \.destination?.settings, action: \.destination.settings) ) { store in SettingsView(store: store, navPresentationStyle: .navigation) } .sheet( - store: self.store.scope(state: \.$destination.changelog, action: \.destination.changelog), - content: ChangelogView.init(store:) - ) - .task { await self.viewStore.send(.task).finish() } + item: $store.scope(state: \.destination?.changelog, action: \.destination.changelog) + ) { store in + ChangelogView(store: store) + } + .task { await store.send(.task).finish() } } } diff --git a/Sources/HomeFeature/LeaderboardLinkView.swift b/Sources/HomeFeature/LeaderboardLinkView.swift index 94710201..5ae13b33 100644 --- a/Sources/HomeFeature/LeaderboardLinkView.swift +++ b/Sources/HomeFeature/LeaderboardLinkView.swift @@ -6,21 +6,7 @@ import SwiftUI struct LeaderboardLinkView: View { @Environment(\.colorScheme) var colorScheme - let store: StoreOf - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - var weekInReview: FetchWeekInReviewResponse? - - init(state: Home.State) { - self.weekInReview = state.weekInReview - } - } - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) - } + @Bindable var store: StoreOf var body: some View { VStack(alignment: .leading) { @@ -32,14 +18,14 @@ struct LeaderboardLinkView: View { Spacer() Button("View all") { - self.viewStore.send(.leaderboardButtonTapped) + store.send(.leaderboardButtonTapped) } .adaptiveFont(.matterMedium, size: 12) } .foregroundColor(self.colorScheme == .dark ? .hex(0xE79072) : .isowordsBlack) Button { - self.viewStore.send(.leaderboardButtonTapped) + store.send(.leaderboardButtonTapped) } label: { VStack(alignment: .leading, spacing: .grid(4)) { Text("Week in review") @@ -48,7 +34,7 @@ struct LeaderboardLinkView: View { .frame(height: 2) .background(self.colorScheme == .dark ? Color.isowordsBlack : .hex(0xE26C5E)) - self.weekInReview(self.viewStore.weekInReview) + self.weekInReview(store.weekInReview) .adaptiveFont(.matterMedium, size: 14) } } @@ -59,11 +45,10 @@ struct LeaderboardLinkView: View { ) ) .navigationDestination( - store: self.store.scope( - state: \.$destination.leaderboard, action: \.destination.leaderboard - ), - destination: LeaderboardView.init(store:) - ) + item: $store.scope(state: \.destination?.leaderboard, action: \.destination.leaderboard) + ) { store in + LeaderboardView(store: store) + } } } diff --git a/Sources/HomeFeature/NagBanner.swift b/Sources/HomeFeature/NagBanner.swift index 8b4fa66a..deb0b93f 100644 --- a/Sources/HomeFeature/NagBanner.swift +++ b/Sources/HomeFeature/NagBanner.swift @@ -4,8 +4,9 @@ import UpgradeInterstitialFeature @Reducer public struct NagBanner { + @ObservableState public struct State: Equatable { - @PresentationState var upgradeInterstitial: UpgradeInterstitial.State? = nil + @Presents var upgradeInterstitial: UpgradeInterstitial.State? = nil public init(upgradeInterstitial: UpgradeInterstitial.State? = nil) { self.upgradeInterstitial = upgradeInterstitial @@ -44,34 +45,33 @@ public struct NagBanner { } public struct NagBannerView: View { - let store: StoreOf + @Bindable var store: StoreOf public init(store: StoreOf) { self.store = store } public var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - Button { - viewStore.send(.tapped) - } label: { - Marquee(duration: TimeInterval(messages.count) * 9) { - ForEach(messages, id: \.self) { message in - Text(message) - .adaptiveFont(.matterMedium, size: 14) - .foregroundColor(.isowordsRed) - } + Button { + store.send(.tapped) + } label: { + Marquee(duration: TimeInterval(messages.count) * 9) { + ForEach(messages, id: \.self) { message in + Text(message) + .adaptiveFont(.matterMedium, size: 14) + .foregroundColor(.isowordsRed) } } - .buttonStyle(PlainButtonStyle()) - .frame(maxWidth: .infinity, alignment: .center) - .frame(height: 56) - .background(Color.white.edgesIgnoringSafeArea(.bottom)) } + .buttonStyle(PlainButtonStyle()) + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 56) + .background(Color.white.edgesIgnoringSafeArea(.bottom)) .sheet( - store: self.store.scope(state: \.$upgradeInterstitial, action: \.upgradeInterstitial), - content: UpgradeInterstitialView.init(store:) - ) + item: $store.scope(state: \.upgradeInterstitial, action: \.upgradeInterstitial) + ) { store in + UpgradeInterstitialView(store: store) + } } } diff --git a/Sources/HomeFeature/StartNewGameView.swift b/Sources/HomeFeature/StartNewGameView.swift index 9289e3d4..0bbe67e2 100644 --- a/Sources/HomeFeature/StartNewGameView.swift +++ b/Sources/HomeFeature/StartNewGameView.swift @@ -6,11 +6,7 @@ import SwiftUI struct StartNewGameView: View { @Environment(\.colorScheme) var colorScheme - let store: StoreOf - - init(store: StoreOf) { - self.store = store - } + @Bindable var store: StoreOf var body: some View { VStack(alignment: .leading) { @@ -20,7 +16,7 @@ struct StartNewGameView: View { .padding(.vertical) Button { - self.store.send(.soloButtonTapped) + store.send(.soloButtonTapped) } label: { HStack { Text("Solo") @@ -36,7 +32,7 @@ struct StartNewGameView: View { ) Button { - self.store.send(.multiplayerButtonTapped) + store.send(.multiplayerButtonTapped) } label: { HStack { Text("Multiplayer") @@ -52,13 +48,15 @@ struct StartNewGameView: View { ) } .navigationDestination( - store: self.store.scope(state: \.$destination.solo, action: \.destination.solo), - destination: SoloView.init(store:) - ) + item: $store.scope(state: \.destination?.solo, action: \.destination.solo) + ) { store in + SoloView(store: store) + } .navigationDestination( - store: self.store.scope(state: \.$destination.multiplayer, action: \.destination.multiplayer), - destination: MultiplayerView.init(store:) - ) + item: $store.scope(state: \.destination?.multiplayer, action: \.destination.multiplayer) + ) { store in + MultiplayerView(store: store) + } } } diff --git a/Sources/LeaderboardFeature/Leaderboard.swift b/Sources/LeaderboardFeature/Leaderboard.swift index 40ec3d35..3eabf569 100644 --- a/Sources/LeaderboardFeature/Leaderboard.swift +++ b/Sources/LeaderboardFeature/Leaderboard.swift @@ -31,25 +31,14 @@ public enum LeaderboardScope: CaseIterable, Equatable { @Reducer public struct Leaderboard { - @Reducer - public struct Destination { - public enum State: Equatable { - case cubePreview(CubePreview.State) - } - - public enum Action { - case cubePreview(CubePreview.Action) - } - - public var body: some ReducerOf { - Scope(state: \.cubePreview, action: \.cubePreview) { - CubePreview() - } - } + @Reducer(state: .equatable) + public enum Destination { + case cubePreview(CubePreview) } + @ObservableState public struct State: Equatable { - @PresentationState public var destination: Destination.State? + @Presents public var destination: Destination.State? public var scope: LeaderboardScope = .games public var solo: LeaderboardResults.State = .init(timeScope: .lastWeek) public var vocab: LeaderboardResults.State = .init(timeScope: .lastWeek) @@ -141,7 +130,7 @@ public struct Leaderboard { } } .ifLet(\.$destination, action: \.destination) { - Destination() + Destination.body } Scope(state: \.solo, action: \.solo) { @@ -155,12 +144,10 @@ public struct Leaderboard { public struct LeaderboardView: View { @Environment(\.colorScheme) var colorScheme - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf + @Bindable var store: StoreOf public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) } public var body: some View { @@ -168,11 +155,11 @@ public struct LeaderboardView: View { HStack { ForEach(LeaderboardScope.allCases, id: \.self) { scope in Button { - self.viewStore.send(.scopeTapped(scope), animation: .default) + store.send(.scopeTapped(scope), animation: .default) } label: { Text(scope.title) - .foregroundColor(self.viewStore.state.scope == scope ? scope.color : nil) - .opacity(self.viewStore.state.scope == scope ? 1 : 0.3) + .foregroundColor(store.state.scope == scope ? scope.color : nil) + .opacity(store.state.scope == scope ? 1 : 0.3) } } } @@ -181,22 +168,22 @@ public struct LeaderboardView: View { .screenEdgePadding(.horizontal) Group { - switch self.viewStore.state.scope { + switch store.state.scope { case .games: LeaderboardResultsView( - store: self.store.scope(state: \.solo, action: \.solo), + store: store.scope(state: \.solo, action: \.solo), title: Text("Solo"), - subtitle: Text("\(self.viewStore.solo.resultEnvelope?.outOf ?? 0) players"), + subtitle: Text("\(store.solo.resultEnvelope?.outOf ?? 0) players"), isFilterable: true, color: .isowordsOrange, - timeScopeLabel: Text(self.viewStore.solo.timeScope.displayTitle), + timeScopeLabel: Text(store.solo.timeScope.displayTitle), timeScopeMenu: VStack(alignment: .trailing, spacing: .grid(2)) { ForEach([TimeScope.lastDay, .lastWeek, .allTime], id: \.self) { scope in Button(scope.displayTitle) { - self.viewStore.send(.solo(.timeScopeChanged(scope)), animation: .default) + store.send(.solo(.timeScopeChanged(scope)), animation: .default) } - .disabled(self.viewStore.solo.timeScope == scope) - .opacity(self.viewStore.solo.timeScope == scope ? 0.3 : 1) + .disabled(store.solo.timeScope == scope) + .opacity(store.solo.timeScope == scope ? 0.3 : 1) } .padding(.leading, .grid(12)) } @@ -204,21 +191,21 @@ public struct LeaderboardView: View { case .vocab: LeaderboardResultsView( - store: self.store.scope(state: \.vocab, action: \.vocab), - title: (self.viewStore.vocab.resultEnvelope?.outOf).flatMap { + store: store.scope(state: \.vocab, action: \.vocab), + title: (store.vocab.resultEnvelope?.outOf).flatMap { $0 == 0 ? nil : Text("\($0) words") }, subtitle: nil, isFilterable: false, color: .isowordsRed, - timeScopeLabel: Text(self.viewStore.vocab.timeScope.displayTitle), + timeScopeLabel: Text(store.vocab.timeScope.displayTitle), timeScopeMenu: VStack(alignment: .trailing, spacing: .grid(2)) { ForEach([TimeScope.lastDay, .lastWeek, .allTime, .interesting], id: \.self) { scope in Button(scope.displayTitle) { - self.viewStore.send(.vocab(.timeScopeChanged(scope)), animation: .default) + store.send(.vocab(.timeScopeChanged(scope)), animation: .default) } - .disabled(self.viewStore.vocab.timeScope == scope) - .opacity(self.viewStore.vocab.timeScope == scope ? 0.3 : 1) + .disabled(store.vocab.timeScope == scope) + .opacity(store.vocab.timeScope == scope ? 0.3 : 1) } .padding(.leading, .grid(12)) } @@ -233,15 +220,16 @@ public struct LeaderboardView: View { .navigationStyle( foregroundColor: self.colorScheme == .light ? .hex(0x393939) - : self.viewStore.state.scope == .games + : store.state.scope == .games ? .isowordsOrange : .isowordsRed, title: Text("Leaderboards") ) .sheet( - store: self.store.scope(state: \.$destination.cubePreview, action: \.destination.cubePreview), - content: CubePreviewView.init(store:) - ) + item: $store.scope(state: \.destination?.cubePreview, action: \.destination.cubePreview) + ) { store in + CubePreviewView(store: store) + } } } diff --git a/Sources/LeaderboardFeature/LeaderboardResultsView.swift b/Sources/LeaderboardFeature/LeaderboardResultsView.swift index e2058e3e..56bcc0c4 100644 --- a/Sources/LeaderboardFeature/LeaderboardResultsView.swift +++ b/Sources/LeaderboardFeature/LeaderboardResultsView.swift @@ -4,6 +4,7 @@ import SwiftUI // NB: `@Reducer` prevents us from synthesizing conditional conformances in this file. public struct LeaderboardResults: Reducer { + @ObservableState public struct State { public var gameMode: GameMode public var isLoading: Bool @@ -128,7 +129,6 @@ where let title: Text? let store: StoreOf> - @ObservedObject var viewStore: ViewStoreOf> public init( store: StoreOf>, @@ -143,7 +143,6 @@ where self.isFilterable = isFilterable self.subtitle = subtitle self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) self.timeScopeLabel = timeScopeLabel self.timeScopeMenu = timeScopeMenu self.title = title @@ -157,12 +156,12 @@ where .adaptiveFont(.matterMedium, size: 16) .frame(maxWidth: .infinity, alignment: .leading) .foregroundColor(self.colorScheme == .dark ? self.color : .isowordsBlack) - .redacted(reason: self.viewStore.isLoading ? .placeholder : []) + .redacted(reason: store.isLoading ? .placeholder : []) Spacer() Button { - self.viewStore.send(.tappedTimeScopeLabel, animation: .default) + store.send(.tappedTimeScopeLabel, animation: .default) } label: { HStack { self.timeScopeLabel @@ -170,7 +169,7 @@ where Image(systemName: "chevron.down") .font(.system(size: 10)) .rotationEffect( - .degrees(self.viewStore.isTimeScopeMenuVisible ? -180 : 0) + .degrees(store.isTimeScopeMenuVisible ? -180 : 0) ) } .padding(.vertical, 8) @@ -186,11 +185,11 @@ where HStack(spacing: .grid(4)) { ForEach(GameMode.allCases) { gameMode in Button { - self.viewStore.send(.gameModeButtonTapped(gameMode)) + store.send(.gameModeButtonTapped(gameMode)) } label: { Text(gameMode.title) .adaptiveFont(.matterMedium, size: 12) - .opacity(self.viewStore.gameMode == gameMode ? 1 : 0.4) + .opacity(store.gameMode == gameMode ? 1 : 0.4) } .buttonStyle(PlainButtonStyle()) } @@ -213,33 +212,33 @@ where Group { ForEach( - self.viewStore.resultEnvelope?.contiguousResults ?? [], id: \.id + store.resultEnvelope?.contiguousResults ?? [], id: \.id ) { result in Button { - self.viewStore.send(.tappedRow(id: result.id)) + store.send(.tappedRow(id: result.id)) } label: { ResultRow(color: self.color, result: result) } } - if let result = self.viewStore.resultEnvelope?.nonContiguousResult { + if let result = store.resultEnvelope?.nonContiguousResult { Image(systemName: "ellipsis") .opacity(0.4) .adaptivePadding(.vertical, .grid(5)) .adaptiveFont(.matterMedium, size: 16) Button { - self.viewStore.send(.tappedRow(id: result.id)) + store.send(.tappedRow(id: result.id)) } label: { ResultRow(color: self.color, result: result) } } - if self.viewStore.nonDisplayedResultsCount > 0 { + if store.nonDisplayedResultsCount > 0 { VStack(spacing: .grid(5)) { Image(systemName: "ellipsis") .opacity(0.4) - Text("and \(self.viewStore.nonDisplayedResultsCount) more!") + Text("and \(store.nonDisplayedResultsCount) more!") } .adaptivePadding(.top, .grid(5)) .adaptiveFont(.matterMedium, size: 16) @@ -248,13 +247,13 @@ where Spacer().frame(height: .grid(5)) } - .disabled(self.viewStore.isLoading) - .redacted(reason: self.viewStore.isLoading ? .placeholder : []) + .disabled(store.isLoading) + .redacted(reason: store.isLoading ? .placeholder : []) } .background(self.color) .foregroundColor(.isowordsBlack) .overlay( - self.viewStore.isLoading + store.isLoading ? ZStack { Color.black .opacity(0.4) @@ -264,17 +263,17 @@ where : nil ) .overlay( - self.viewStore.isTimeScopeMenuVisible + store.isTimeScopeMenuVisible ? Color.black.opacity(0.4) .onTapGesture { - self.viewStore.send(.dismissTimeScopeMenu, animation: .default) + store.send(.dismissTimeScopeMenu, animation: .default) } : nil ) .continuousCornerRadius(.grid(3)) } .overlay( - self.viewStore.isTimeScopeMenuVisible + store.isTimeScopeMenuVisible ? VStack { self.timeScopeMenu .adaptiveFont(.matterMedium, size: 12) @@ -296,7 +295,7 @@ where alignment: .topTrailing ) - .task { await self.viewStore.send(.task).finish() } + .task { await store.send(.task).finish() } } } diff --git a/Sources/MultiplayerFeature/MultiplayerView.swift b/Sources/MultiplayerFeature/MultiplayerView.swift index 7134619f..a1b9c1d7 100644 --- a/Sources/MultiplayerFeature/MultiplayerView.swift +++ b/Sources/MultiplayerFeature/MultiplayerView.swift @@ -4,25 +4,14 @@ import TcaHelpers @Reducer public struct Multiplayer { - @Reducer - public struct Destination { - public enum State: Equatable { - case pastGames(PastGames.State) - } - - public enum Action { - case pastGames(PastGames.Action) - } - - public var body: some ReducerOf { - Scope(state: \.pastGames, action: \.pastGames) { - PastGames() - } - } + @Reducer(state: .equatable) + public enum Destination { + case pastGames(PastGames) } + @ObservableState public struct State: Equatable { - @PresentationState public var destination: Destination.State? + @Presents public var destination: Destination.State? public var hasPastGames: Bool public init( @@ -65,7 +54,7 @@ public struct Multiplayer { } } .ifLet(\.$destination, action: \.destination) { - Destination() + Destination.body } } } @@ -73,20 +62,10 @@ public struct Multiplayer { public struct MultiplayerView: View { @Environment(\.adaptiveSize) var adaptiveSize @Environment(\.colorScheme) var colorScheme - let store: StoreOf - @ObservedObject var viewStore: ViewStore + @Bindable var store: StoreOf public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) - } - - struct ViewState: Equatable { - let hasPastGames: Bool - - init(state: Multiplayer.State) { - self.hasPastGames = state.hasPastGames - } } public var body: some View { @@ -111,7 +90,7 @@ public struct MultiplayerView: View { Spacer() Button { - self.viewStore.send(.startButtonTapped) + store.send(.startButtonTapped) } label: { VStack(spacing: 20) { Image(systemName: "person.2.fill") @@ -126,11 +105,11 @@ public struct MultiplayerView: View { } .buttonStyle(PlainButtonStyle()) .adaptivePadding(.vertical) - .adaptivePadding(.bottom, .grid(self.viewStore.hasPastGames ? 0 : 8)) + .adaptivePadding(.bottom, .grid(store.hasPastGames ? 0 : 8)) - if self.viewStore.hasPastGames { + if store.hasPastGames { Button { - self.viewStore.send(.pastGamesButtonTapped) + store.send(.pastGamesButtonTapped) } label: { HStack { Text("View past games") @@ -149,9 +128,10 @@ public struct MultiplayerView: View { } } .navigationDestination( - store: self.store.scope(state: \.$destination.pastGames, action: \.destination.pastGames), - destination: PastGamesView.init(store:) - ) + item: $store.scope(state: \.destination?.pastGames, action: \.destination.pastGames) + ) { store in + PastGamesView(store: store) + } .navigationStyle( backgroundColor: self.colorScheme == .dark ? .isowordsBlack : .multiplayer, foregroundColor: self.colorScheme == .dark ? .multiplayer : .isowordsBlack, diff --git a/Sources/MultiplayerFeature/PastGameRow.swift b/Sources/MultiplayerFeature/PastGameRow.swift index 34081b06..1e7ddbb9 100644 --- a/Sources/MultiplayerFeature/PastGameRow.swift +++ b/Sources/MultiplayerFeature/PastGameRow.swift @@ -5,8 +5,9 @@ import Tagged @Reducer public struct PastGame { + @ObservableState public struct State: Equatable, Identifiable { - @PresentationState public var alert: AlertState? + @Presents public var alert: AlertState? public var challengeeDisplayName: String public var challengerDisplayName: String public var challengeeScore: Int @@ -33,6 +34,28 @@ public struct PastGame { ? .challengee : .tied } + + init( + alert: AlertState? = nil, + challengeeDisplayName: String, + challengerDisplayName: String, + challengeeScore: Int, + challengerScore: Int, + endDate: Date, + isRematchRequestInFlight: Bool = false, + matchId: TurnBasedMatch.Id, + opponentDisplayName: String + ) { + self.alert = alert + self.challengeeDisplayName = challengeeDisplayName + self.challengerDisplayName = challengerDisplayName + self.challengeeScore = challengeeScore + self.challengerScore = challengerScore + self.endDate = endDate + self.isRematchRequestInFlight = isRematchRequestInFlight + self.matchId = matchId + self.opponentDisplayName = opponentDisplayName + } } public enum Action { @@ -112,24 +135,18 @@ public struct PastGame { struct PastGameRow: View { @Environment(\.colorScheme) var colorScheme - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) - } + @Bindable var store: StoreOf var body: some View { ZStack(alignment: .bottomLeading) { Button { - self.viewStore.send(.tappedRow, animation: .default) + store.send(.tappedRow, animation: .default) } label: { VStack(alignment: .leading, spacing: .grid(6)) { HStack(spacing: .grid(1)) { - Text("\(self.viewStore.endDate, formatter: dateFormatter)") + Text("\(store.endDate, formatter: dateFormatter)") - Text("vs \(self.viewStore.opponentDisplayName)") + Text("vs \(store.opponentDisplayName)") .opacity(0.5) .lineLimit(1) .truncationMode(.tail) @@ -138,46 +155,46 @@ struct PastGameRow: View { VStack(alignment: .leading, spacing: .grid(1)) { HStack { - Text(self.viewStore.challengerDisplayName) + Text(store.challengerDisplayName) .adaptiveFont(.matterMedium, size: 16) - if self.viewStore.outcome != .challengee { + if store.outcome != .challengee { Image(systemName: "checkmark.circle.fill") .font(.system(size: 18)) } Spacer() - Text("\(self.viewStore.challengerScore)") + Text("\(store.challengerScore)") .adaptiveFont(.matterMedium, size: 16) { $0.monospacedDigit() } } HStack(spacing: .grid(1)) { - Text(self.viewStore.challengeeDisplayName) + Text(store.challengeeDisplayName) .adaptiveFont(.matterMedium, size: 16) - if self.viewStore.outcome != .challenger { + if store.outcome != .challenger { Image(systemName: "checkmark.circle.fill") .font(.system(size: 18)) } Spacer() - Text("\(self.viewStore.challengeeScore)") + Text("\(store.challengeeScore)") .adaptiveFont(.matterMedium, size: 16) { $0.monospacedDigit() } } } - self.rematchButton(matchId: self.viewStore.matchId) + self.rematchButton(matchId: store.matchId) .hidden() } } .frame(maxWidth: .infinity, alignment: .leading) - self.rematchButton(matchId: self.viewStore.matchId) + self.rematchButton(matchId: store.matchId) } - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) + .alert($store.scope(state: \.alert, action: \.alert)) } func rematchButton(matchId: TurnBasedMatch.Id) -> some View { Button { - self.viewStore.send(.rematchButtonTapped, animation: .default) + store.send(.rematchButtonTapped, animation: .default) } label: { HStack(spacing: .grid(1)) { - if self.viewStore.isRematchRequestInFlight { + if store.isRematchRequestInFlight { ProgressView() .progressViewStyle( CircularProgressViewStyle( diff --git a/Sources/MultiplayerFeature/PastGameState.swift b/Sources/MultiplayerFeature/PastGameState.swift index 4f9e2c35..b1c5d480 100644 --- a/Sources/MultiplayerFeature/PastGameState.swift +++ b/Sources/MultiplayerFeature/PastGameState.swift @@ -30,12 +30,14 @@ extension PastGame.State { let opponentPlayer = match.participants[opponentIndex].player else { return nil } - self.challengeeDisplayName = challengeePlayer.displayName - self.challengeeScore = matchData.score(forPlayerIndex: 1) - self.challengerDisplayName = challengerPlayer.displayName - self.challengerScore = matchData.score(forPlayerIndex: 0) - self.endDate = endDate - self.matchId = match.matchId - self.opponentDisplayName = opponentPlayer.displayName + self.init( + challengeeDisplayName: challengeePlayer.displayName, + challengerDisplayName: challengerPlayer.displayName, + challengeeScore: matchData.score(forPlayerIndex: 1), + challengerScore: matchData.score(forPlayerIndex: 0), + endDate: endDate, + matchId: match.matchId, + opponentDisplayName: opponentPlayer.displayName + ) } } diff --git a/Sources/MultiplayerFeature/PastGamesView.swift b/Sources/MultiplayerFeature/PastGamesView.swift index 06828e70..8fad29ad 100644 --- a/Sources/MultiplayerFeature/PastGamesView.swift +++ b/Sources/MultiplayerFeature/PastGamesView.swift @@ -6,6 +6,7 @@ import SwiftUI @Reducer public struct PastGames { + @ObservableState public struct State: Equatable { public var pastGames: IdentifiedArrayOf = [] } @@ -60,18 +61,10 @@ public struct PastGames { struct PastGamesView: View { @Environment(\.colorScheme) var colorScheme let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) - } var body: some View { ScrollView { - ForEachStore( - self.store.scope(state: \.pastGames, action: \.pastGames) - ) { store in + ForEach(store.scope(state: \.pastGames, action: \.pastGames)) { store in Group { PastGameRow(store: store) @@ -83,7 +76,7 @@ struct PastGamesView: View { } .padding() } - .task { await viewStore.send(.task).finish() } + .task { await store.send(.task).finish() } .navigationStyle( backgroundColor: self.colorScheme == .dark ? .isowordsBlack : .multiplayer, foregroundColor: self.colorScheme == .dark ? .multiplayer : .isowordsBlack, diff --git a/Sources/NotificationsAuthAlert/NotificationsAuthAlert.swift b/Sources/NotificationsAuthAlert/NotificationsAuthAlert.swift index ef017fd8..acd8dfc8 100644 --- a/Sources/NotificationsAuthAlert/NotificationsAuthAlert.swift +++ b/Sources/NotificationsAuthAlert/NotificationsAuthAlert.swift @@ -56,98 +56,81 @@ public struct NotificationsAuthAlert { } extension View { - public func notificationsAlert( - store: Store, PresentationAction>, - state toAlertState: @escaping (DestinationState) -> NotificationsAuthAlert.State?, - action fromAlertAction: @escaping (NotificationsAuthAlert.Action) -> DestinationAction + public func notificationsAlert( + _ store: Binding?> ) -> some View { - self.modifier( - NotificationsAuthAlertViewModifier( - store: store, toAlertState: toAlertState, fromAlertAction: fromAlertAction - ) - ) + self.modifier(NotificationsAuthAlertViewModifier(store: store)) } } -struct NotificationsAuthAlertViewModifier: ViewModifier { - let store: Store, PresentationAction> - let toAlertState: (DestinationState) -> NotificationsAuthAlert.State? - let fromAlertAction: (NotificationsAuthAlert.Action) -> DestinationAction +struct NotificationsAuthAlertViewModifier: ViewModifier { + @Binding var store: Store? func body(content: Content) -> some View { - WithViewStore( - self.store, observe: { $0.wrappedValue.flatMap(self.toAlertState) } - ) { viewStore in - content - .overlay { - if viewStore.state != nil { - Rectangle() - .fill(Color.dailyChallenge.opacity(0.8)) - .ignoresSafeArea() - .transition(.opacity.animation(.default)) - } + let state = store?.withState { $0 } + content + .overlay { + if state != nil { + Rectangle() + .fill(Color.dailyChallenge.opacity(0.8)) + .ignoresSafeArea() + .transition(.opacity.animation(.default)) } - .overlay { - if let state = viewStore.state { - ZStack(alignment: .topTrailing) { - NotificationsAuthAlertView( - store: store.scope( - state: { _ in state }, action: { .presented(fromAlertAction($0)) } - ) - ) + } + .overlay { + if state != nil { + ZStack(alignment: .topTrailing) { + NotificationsAuthAlertView { + store?.send(.turnOnNotificationsButtonTapped) + } - Button { - viewStore.send(.dismiss) - } label: { - Image(systemName: "xmark") - .font(.system(size: 20)) - .foregroundColor(.dailyChallenge) - .padding(.grid(5)) - } + Button { + store = nil + } label: { + Image(systemName: "xmark") + .font(.system(size: 20)) + .foregroundColor(.dailyChallenge) + .padding(.grid(5)) } - .transition( - .scale(scale: 0.8, anchor: .center) - .animation(.spring()) - .combined(with: .opacity.animation(.default)) - ) } + .transition( + .scale(scale: 0.8, anchor: .center) + .animation(.spring()) + .combined(with: .opacity.animation(.default)) + ) } - } + } } } struct NotificationsAuthAlertView: View { - let store: StoreOf + let action: () -> Void var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in - VStack(spacing: .grid(8)) { - (Text("Want to get notified about ") - + Text("your ranks?").fontWeight(.medium)) - .adaptiveFont(.matter, size: 28) - .foregroundColor(.dailyChallenge) - .lineLimit(.max) - .minimumScaleFactor(0.2) - .multilineTextAlignment(.center) + VStack(spacing: .grid(8)) { + (Text("Want to get notified about ") + + Text("your ranks?").fontWeight(.medium)) + .adaptiveFont(.matter, size: 28) + .foregroundColor(.dailyChallenge) + .lineLimit(.max) + .minimumScaleFactor(0.2) + .multilineTextAlignment(.center) - Button("Turn on notifications") { - viewStore.send(.turnOnNotificationsButtonTapped, animation: .default) + Button("Turn on notifications") { + withAnimation { + action() } - .buttonStyle(ActionButtonStyle(backgroundColor: .dailyChallenge, foregroundColor: .black)) } - .padding(.top, .grid(4)) - .padding(.grid(8)) - .background(Color.black) + .buttonStyle(ActionButtonStyle(backgroundColor: .dailyChallenge, foregroundColor: .black)) } + .padding(.top, .grid(4)) + .padding(.grid(8)) + .background(Color.black) } } struct NotificationMenu_Previews: PreviewProvider { static var previews: some View { - NotificationsAuthAlertView( - store: Store(initialState: NotificationsAuthAlert.State()) { - NotificationsAuthAlert() - } - ) + NotificationsAuthAlertView(action: {}) } } diff --git a/Sources/OnboardingFeature/OnboardingStepView.swift b/Sources/OnboardingFeature/OnboardingStepView.swift index 0b994776..8e4770a3 100644 --- a/Sources/OnboardingFeature/OnboardingStepView.swift +++ b/Sources/OnboardingFeature/OnboardingStepView.swift @@ -3,229 +3,166 @@ import Styleguide import SwiftUI struct OnboardingStepView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStore + @Bindable var store: StoreOf @Environment(\.colorScheme) var colorScheme - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) - } - - struct ViewState: Equatable { - let isGetStartedButtonVisible: Bool - let isNextButtonVisible: Bool - let isSubmitButtonVisible: Bool - let presentationStyle: Onboarding.State.PresentationStyle - let step: Onboarding.State.Step - - init(onboardingState state: Onboarding.State) { - self.isGetStartedButtonVisible = state.step == Onboarding.State.Step.allCases.last - self.isNextButtonVisible = - state.step != Onboarding.State.Step.allCases.first - && state.step.isFullscreen - && state.step != Onboarding.State.Step.allCases.last - - switch state.step { - case .step5_SubmitGame: - self.isSubmitButtonVisible = state.game.selectedWordString == "GAME" - case .step8_FindCubes: - self.isSubmitButtonVisible = state.game.selectedWordString == "CUBES" - case .step12_CubeIsShaking: - self.isSubmitButtonVisible = state.game.selectedWordString.isRemove - case .step16_FindAnyWord: - self.isSubmitButtonVisible = !state.game.selectedWordString.isEmpty - default: - self.isSubmitButtonVisible = false - } - - self.presentationStyle = state.presentationStyle - self.step = state.step - } - } - var body: some View { GeometryReader { proxy in let height = proxy.size.height / 4 ZStack(alignment: .bottom) { VStack { - if self.viewStore.step.isFullscreen { + if store.step.isFullscreen { Spacer() } Group { - Group { - if self.viewStore.step == .step1_Welcome { - FullscreenStepView( - Text("Hello!\nWelcome to ") - + Text("isowords").fontWeight(.medium) - + Text(", a word game.") - ) - } - if self.viewStore.step == .step2_FindWordsOnCube { - FullscreenStepView( - Text("The point of the game is to find words on a ") - + Text("cube").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step3_ConnectLettersTouching { - FullscreenStepView( - Text("Words are formed by connecting letters that are ") - + Text("touching").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step4_FindGame { - InlineStepView( - height: height, - Text("Let’s try!\nConnect letters to form ") - + Text("GAME").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step5_SubmitGame { - InlineStepView( - height: height, - Text("Now submit the word by tapping the ") - + Text("thumbs up").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step6_Congrats { - InlineStepView( - height: height, - Text("Well done!") - ) - } - if self.viewStore.step == .step7_BiggerCube { - FullscreenStepView( - Text("Let’s find another word, but this time with more ") - + Text("letters revealed").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step8_FindCubes { - InlineStepView( - height: height, - Text("Find and submit the word ") - + Text("CUBES").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step9_Congrats { - InlineStepView( - height: height, - Text("You got it!") - ) - } - if self.viewStore.step == .step10_CubeDisappear { - FullscreenStepView( - Text("You can use each letter three times before the cube ") - + Text("disappears").fontWeight(.medium) - + Text(".") - ) - } - } - Group { - if self.viewStore.step == .step11_FindRemove { - InlineStepView( - height: height, - Text("Let’s try it!\nFind the word ") - + Text("REMOVE").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step12_CubeIsShaking { - InlineStepView( - height: height, - Text("The shaking cube means it will ") - + Text("disappear").fontWeight(.medium) - + Text(". Now submit the word.") - ) - } - if self.viewStore.step == .step13_Congrats { - InlineStepView( - height: height, - Text("Ohhhhhhh,\n").italic() - + Text("interesting!") - ) - } - if self.viewStore.step == .step14_LettersRevealed { - FullscreenStepView( - Text( - "As cubes are removed the letters inside are revealed, helping you find more " - ) - + Text("words").fontWeight(.medium) - + Text(".") + switch store.step { + case .step1_Welcome: + FullscreenStepView( + Text("Hello!\nWelcome to ") + + Text("isowords").fontWeight(.medium) + + Text(", a word game.") + ) + case .step2_FindWordsOnCube: + FullscreenStepView( + Text("The point of the game is to find words on a ") + + Text("cube").fontWeight(.medium) + + Text(".") + ) + case .step3_ConnectLettersTouching: + FullscreenStepView( + Text("Words are formed by connecting letters that are ") + + Text("touching").fontWeight(.medium) + + Text(".") + ) + case .step4_FindGame: + InlineStepView( + height: height, + Text("Let’s try!\nConnect letters to form ") + + Text("GAME").fontWeight(.medium) + + Text(".") + ) + case .step5_SubmitGame: + InlineStepView( + height: height, + Text("Now submit the word by tapping the ") + + Text("thumbs up").fontWeight(.medium) + + Text(".") + ) + case .step6_Congrats: + InlineStepView( + height: height, + Text("Well done!") + ) + case .step7_BiggerCube: + FullscreenStepView( + Text("Let’s find another word, but this time with more ") + + Text("letters revealed").fontWeight(.medium) + + Text(".") + ) + case .step8_FindCubes: + InlineStepView( + height: height, + Text("Find and submit the word ") + + Text("CUBES").fontWeight(.medium) + + Text(".") + ) + case .step9_Congrats: + InlineStepView( + height: height, + Text("You got it!") + ) + case .step10_CubeDisappear: + FullscreenStepView( + Text("You can use each letter three times before the cube ") + + Text("disappears").fontWeight(.medium) + + Text(".") + ) + case .step11_FindRemove: + InlineStepView( + height: height, + Text("Let’s try it!\nFind the word ") + + Text("REMOVE").fontWeight(.medium) + + Text(".") + ) + case .step12_CubeIsShaking: + InlineStepView( + height: height, + Text("The shaking cube means it will ") + + Text("disappear").fontWeight(.medium) + + Text(". Now submit the word.") + ) + case .step13_Congrats: + InlineStepView( + height: height, + Text("Ohhhhhhh,\n").italic() + + Text("interesting!") + ) + case .step14_LettersRevealed: + FullscreenStepView( + Text( + "As cubes are removed the letters inside are revealed, helping you find more " ) - } - if self.viewStore.step == .step15_FullCube { + + Text("words").fontWeight(.medium) + + Text(".") + ) + case .step15_FullCube: + FullscreenStepView( + Text("Good job so far, but the real game is played with all letters ") + + Text("revealed").fontWeight(.medium) + + Text(".") + ) + case .step16_FindAnyWord: + InlineStepView( + height: height, + Text("Find ") + + Text("any").fontWeight(.medium) + + Text(" word on the full cube.") + ) + case .step17_Congrats: + InlineStepView( + height: height, + Text("That’s a great one!") + ) + case .step18_OneLastThing: + FullscreenStepView( + Text("One last thing.\nYou can remove a cube by double-tapping it. ") + + Text("This can be handy for exposing ") + + Text("more letters").fontWeight(.medium) + + Text(".") + ) + case .step19_DoubleTapToRemove: + InlineStepView( + height: height, + Text("Let’s try it.\nDouble tap any cube to ") + + Text("remove").fontWeight(.medium) + + Text(" it.") + ) + case .step20_Congrats: + InlineStepView( + height: height, + Text("Perfect!") + ) + case .step21_PlayAGameYourself: + switch store.presentationStyle { + case .demo: FullscreenStepView( - Text("Good job so far, but the real game is played with all letters ") - + Text("revealed").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step16_FindAnyWord { - InlineStepView( - height: height, - Text("Find ") - + Text("any").fontWeight(.medium) - + Text(" word on the full cube.") + Text("Ok, ready?\n Let’s try a 3 minute timed game!") ) - } - if self.viewStore.step == .step17_Congrats { - InlineStepView( - height: height, - Text("That’s a great one!") - ) - } - if self.viewStore.step == .step18_OneLastThing { + + case .firstLaunch, .help: FullscreenStepView( - Text("One last thing.\nYou can remove a cube by double-tapping it. ") - + Text("This can be handy for exposing ") - + Text("more letters").fontWeight(.medium) - + Text(".") - ) - } - if self.viewStore.step == .step19_DoubleTapToRemove { - InlineStepView( - height: height, - Text("Let’s try it.\nDouble tap any cube to ") - + Text("remove").fontWeight(.medium) - + Text(" it.") + Text("Ok, there’s more strategy to the game, but the only way to learn is to ") + + Text("play a game yourself").fontWeight(.medium) + + Text("!") ) } } - Group { - if self.viewStore.step == .step20_Congrats { - InlineStepView( - height: height, - Text("Perfect!") - ) - } - if self.viewStore.step == .step21_PlayAGameYourself { - switch self.viewStore.presentationStyle { - case .demo: - FullscreenStepView( - Text("Ok, ready?\n Let’s try a 3 minute timed game!") - ) - - case .firstLaunch, .help: - FullscreenStepView( - Text("Ok, there’s more strategy to the game, but the only way to learn is to ") - + Text("play a game yourself").fontWeight(.medium) - + Text("!") - ) - } - } - } } .foregroundColor( self.colorScheme == .dark - ? self.viewStore.step.color + ? store.step.color : Color.isowordsBlack ) .transition( @@ -247,29 +184,29 @@ struct OnboardingStepView: View { .padding(.bottom, 80) Group { - if self.viewStore.isNextButtonVisible { + if store.isNextButtonVisible { Button { - self.viewStore.send(.nextButtonTapped, animation: .default) + store.send(.nextButtonTapped, animation: .default) } label: { Image(systemName: "arrow.right") .frame(width: 80, height: 80) .background( self.colorScheme == .dark - ? self.viewStore.step.color + ? store.step.color : Color.isowordsBlack ) .foregroundColor( self.colorScheme == .dark ? Color.isowordsBlack - : self.viewStore.step.color + : store.step.color ) .font(.system(size: 30)) .clipShape(Circle()) } - } else if !self.viewStore.step.isFullscreen { - if self.viewStore.isSubmitButtonVisible { + } else if !store.step.isFullscreen { + if store.isSubmitButtonVisible { Button { - self.viewStore.send( + store.send( .game(.submitButtonTapped(reaction: nil)), animation: .default ) } label: { @@ -277,24 +214,24 @@ struct OnboardingStepView: View { .frame(width: 80, height: 80) .background( self.colorScheme == .dark - ? self.viewStore.step.color + ? store.step.color : Color.isowordsBlack ) .foregroundColor( self.colorScheme == .dark ? Color.isowordsBlack - : self.viewStore.step.color + : store.step.color ) .font(.system(size: 30)) .clipShape(Circle()) } } - } else if self.viewStore.isGetStartedButtonVisible { + } else if store.isGetStartedButtonVisible { Button { - self.viewStore.send(.getStartedButtonTapped, animation: .default) + store.send(.getStartedButtonTapped, animation: .default) } label: { HStack { - switch self.viewStore.presentationStyle { + switch store.presentationStyle { case .demo: Text("Let’s play!") case .firstLaunch, .help: @@ -307,18 +244,18 @@ struct OnboardingStepView: View { .buttonStyle( ActionButtonStyle( backgroundColor: self.colorScheme == .dark - ? self.viewStore.step.color + ? store.step.color : .isowordsBlack, foregroundColor: self.colorScheme == .dark ? .isowordsBlack - : self.viewStore.step.color + : store.step.color ) ) } } .padding() .transition( - AnyTransition.asymmetric( + .asymmetric( insertion: .offset(x: 0, y: 50), removal: .offset(x: 0, y: 50) ) @@ -326,8 +263,8 @@ struct OnboardingStepView: View { ) } } - .task { await self.viewStore.send(.task).finish() } - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) + .task { await store.send(.task).finish() } + .alert($store.scope(state: \.alert, action: \.alert)) } } @@ -362,6 +299,29 @@ private struct InlineStepView: View { } } +fileprivate extension Onboarding.State { + var isGetStartedButtonVisible: Bool { self.step == Onboarding.State.Step.allCases.last } + var isNextButtonVisible: Bool { + self.step != Onboarding.State.Step.allCases.first + && self.step.isFullscreen + && self.step != Onboarding.State.Step.allCases.last + } + var isSubmitButtonVisible: Bool { + switch self.step { + case .step5_SubmitGame: + return self.game.selectedWordString == "GAME" + case .step8_FindCubes: + return self.game.selectedWordString == "CUBES" + case .step12_CubeIsShaking: + return self.game.selectedWordString.isRemove + case .step16_FindAnyWord: + return !self.game.selectedWordString.isEmpty + default: + return false + } + } +} + extension Onboarding.State.Step { var color: Color { let t = Double(self.rawValue) / Double(Self.allCases.count - 1) diff --git a/Sources/OnboardingFeature/OnboardingView.swift b/Sources/OnboardingFeature/OnboardingView.swift index b8625bba..a2896c34 100644 --- a/Sources/OnboardingFeature/OnboardingView.swift +++ b/Sources/OnboardingFeature/OnboardingView.swift @@ -15,8 +15,9 @@ import UserDefaultsClient @Reducer public struct Onboarding { + @ObservableState public struct State: Equatable { - @PresentationState public var alert: AlertState? + @Presents public var alert: AlertState? public var game: Game.State public var presentationStyle: PresentationStyle public var step: Step @@ -33,6 +34,10 @@ public struct Onboarding { self.step = step } + fileprivate var isSkipButtonVisible: Bool { + self.step != Onboarding.State.Step.allCases.last + } + fileprivate var cubeScene: CubeSceneView.ViewState { var viewState = CubeSceneView.ViewState(game: self.game, nub: nil) @@ -390,44 +395,32 @@ public struct Onboarding { public struct OnboardingView: View { @Environment(\.colorScheme) var colorScheme let store: StoreOf - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let isSkipButtonVisible: Bool - let step: Onboarding.State.Step - - init(state: Onboarding.State) { - self.isSkipButtonVisible = state.step != Onboarding.State.Step.allCases.last - self.step = state.step - } - } public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { ZStack(alignment: .topTrailing) { - CubeView(store: self.store.scope(state: \.cubeScene, action: \.game.cubeScene)) - .opacity(viewStore.step.isFullscreen ? 0 : 1) + CubeView(store: store.scope(state: \.cubeScene, action: \.game.cubeScene)) + .opacity(store.step.isFullscreen ? 0 : 1) - OnboardingStepView(store: self.store) + OnboardingStepView(store: store) - if viewStore.isSkipButtonVisible { - Button("Skip") { viewStore.send(.skipButtonTapped, animation: .default) } + if store.isSkipButtonVisible { + Button("Skip") { store.send(.skipButtonTapped, animation: .default) } .adaptiveFont(.matterMedium, size: 18) .buttonStyle(PlainButtonStyle()) .padding(.horizontal) .foregroundColor( self.colorScheme == .dark - ? viewStore.step.color + ? store.step.color : Color.isowordsBlack ) } } .background( - (self.colorScheme == .dark ? Color.isowordsBlack : viewStore.step.color) + (self.colorScheme == .dark ? Color.isowordsBlack : store.step.color) .ignoresSafeArea() ) } @@ -474,7 +467,6 @@ private enum CancelID { static var previews: some View { OnboardingView( store: Store(initialState: .init(presentationStyle: .firstLaunch)) { - } ) } diff --git a/Sources/SettingsFeature/AccessibilitySettingsView.swift b/Sources/SettingsFeature/AccessibilitySettingsView.swift index 40909e15..679b57b2 100644 --- a/Sources/SettingsFeature/AccessibilitySettingsView.swift +++ b/Sources/SettingsFeature/AccessibilitySettingsView.swift @@ -3,19 +3,13 @@ import Styleguide import SwiftUI struct AccessibilitySettingsView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) - } + @Bindable var store: StoreOf var body: some View { SettingsForm { SettingsRow { VStack(alignment: .leading) { - Toggle("Cube motion", isOn: self.viewStore.$userSettings.enableGyroMotion) + Toggle("Cube motion", isOn: $store.userSettings.enableGyroMotion) .adaptiveFont(.matterMedium, size: 16) Text("Use your device’s gyroscope to apply a small amount of motion to the cube.") @@ -26,17 +20,14 @@ struct AccessibilitySettingsView: View { } SettingsRow { VStack(alignment: .leading) { - Toggle("Haptics", isOn: self.viewStore.$userSettings.enableHaptics) + Toggle("Haptics", isOn: $store.userSettings.enableHaptics) .adaptiveFont(.matterMedium, size: 16) } } SettingsRow { VStack(alignment: .leading) { - Toggle( - "Reduce animation", - isOn: self.viewStore.$userSettings.enableReducedAnimation - ) - .adaptiveFont(.matterMedium, size: 16) + Toggle("Reduce animation", isOn: $store.userSettings.enableReducedAnimation) + .adaptiveFont(.matterMedium, size: 16) } } } diff --git a/Sources/SettingsFeature/AppearanceSettingsView.swift b/Sources/SettingsFeature/AppearanceSettingsView.swift index 3bff448e..caff2c9d 100644 --- a/Sources/SettingsFeature/AppearanceSettingsView.swift +++ b/Sources/SettingsFeature/AppearanceSettingsView.swift @@ -4,22 +4,16 @@ import SwiftUI import UserSettingsClient struct AppearanceSettingsView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) - } + @Bindable var store: StoreOf var body: some View { SettingsForm { SettingsSection(title: "Theme") { - ColorSchemePicker(colorScheme: self.viewStore.$userSettings.colorScheme) + ColorSchemePicker(colorScheme: $store.userSettings.colorScheme) } SettingsSection(title: "App Icon", padContents: false) { - AppIconPicker(appIcon: self.viewStore.$userSettings.appIcon.animation()) + AppIconPicker(appIcon: $store.userSettings.appIcon.animation()) } } .navigationStyle(title: Text("Appearance")) diff --git a/Sources/SettingsFeature/DeveloperSettingsView.swift b/Sources/SettingsFeature/DeveloperSettingsView.swift index 09ef7cc4..eefbc274 100644 --- a/Sources/SettingsFeature/DeveloperSettingsView.swift +++ b/Sources/SettingsFeature/DeveloperSettingsView.swift @@ -3,25 +3,19 @@ import Styleguide import SwiftUI struct DeveloperSettingsView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf + @Bindable var store: StoreOf @AppStorage(.enableCubeShadow) var enableCubeShadow @AppStorage(.showSceneStatistics) var showSceneStatistics - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(store, observe: { $0 }) - } - var body: some View { SettingsForm { SettingsRow { VStack(alignment: .leading) { Text("API") - Text(self.viewStore.developer.currentBaseUrl.rawValue) + Text(store.developer.currentBaseUrl.rawValue) .adaptiveFont(.matter, size: 14) - Picker("Base URL", selection: self.viewStore.$developer.currentBaseUrl) { + Picker("Base URL", selection: $store.developer.currentBaseUrl) { ForEach(DeveloperSettings.BaseUrl.allCases, id: \.self) { Text($0.description) } diff --git a/Sources/SettingsFeature/Mocks.swift b/Sources/SettingsFeature/Mocks.swift deleted file mode 100644 index 6323f5af..00000000 --- a/Sources/SettingsFeature/Mocks.swift +++ /dev/null @@ -1,17 +0,0 @@ -#if DEBUG - import Dependencies - import UserSettingsClient - - extension Settings.State { - public static let everythingOff = withDependencies { - $0.userSettings = .mock( - initialUserSettings: UserSettings( - enableGyroMotion: false, - enableHaptics: false - ) - ) - } operation: { - Self() - } - } -#endif diff --git a/Sources/SettingsFeature/NotificationsSettingsView.swift b/Sources/SettingsFeature/NotificationsSettingsView.swift index a080e844..fa0b257d 100644 --- a/Sources/SettingsFeature/NotificationsSettingsView.swift +++ b/Sources/SettingsFeature/NotificationsSettingsView.swift @@ -3,29 +3,22 @@ import Styleguide import SwiftUI struct NotificationsSettingsView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) - } + @Bindable var store: StoreOf var body: some View { SettingsForm { SettingsRow { Toggle( - "Enable notifications", isOn: self.viewStore.$userSettings.enableNotifications.animation() + "Enable notifications", isOn: $store.userSettings.enableNotifications.animation() ) .adaptiveFont(.matterMedium, size: 16) } - if self.viewStore.userSettings.enableNotifications { + if store.userSettings.enableNotifications { SettingsRow { VStack(alignment: .leading, spacing: 16) { Toggle( - "Daily challenge reminders", - isOn: self.viewStore.$userSettings.sendDailyChallengeReminder + "Daily challenge reminders", isOn: $store.userSettings.sendDailyChallengeReminder ) .adaptiveFont(.matterMedium, size: 16) @@ -37,11 +30,8 @@ struct NotificationsSettingsView: View { SettingsRow { VStack(alignment: .leading, spacing: 16) { - Toggle( - "Daily challenge summary", - isOn: self.viewStore.$userSettings.sendDailyChallengeSummary - ) - .adaptiveFont(.matterMedium, size: 16) + Toggle("Daily challenge summary", isOn: $store.userSettings.sendDailyChallengeSummary) + .adaptiveFont(.matterMedium, size: 16) Text("Receive your rank for yesterday’s challenge if you played.") .foregroundColor(.gray) diff --git a/Sources/SettingsFeature/PurchasesSettingsView.swift b/Sources/SettingsFeature/PurchasesSettingsView.swift index fb28c2e5..5c33ddd8 100644 --- a/Sources/SettingsFeature/PurchasesSettingsView.swift +++ b/Sources/SettingsFeature/PurchasesSettingsView.swift @@ -4,16 +4,10 @@ import SwiftUI struct PurchasesSettingsView: View { let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(store, observe: { $0 }) - } var body: some View { SettingsForm { - if let fullGamePurchasedAt = self.viewStore.fullGamePurchasedAt { + if let fullGamePurchasedAt = store.fullGamePurchasedAt { VStack(alignment: .leading, spacing: 16) { Text("🎉") .font(.system(size: 40)) @@ -28,14 +22,14 @@ struct PurchasesSettingsView: View { .continuousCornerRadius(12) .padding() } else { - if !self.viewStore.isPurchasing, - let fullGameProduct = self.viewStore.fullGameProduct + if !store.isPurchasing, + let fullGameProduct = store.fullGameProduct { switch fullGameProduct { case let .success(product): SettingsRow { Button { - self.viewStore.send(.tappedProduct(product), animation: .default) + store.send(.tappedProduct(product), animation: .default) } label: { Text("Upgrade") .foregroundColor(.isowordsOrange) @@ -55,10 +49,10 @@ struct PurchasesSettingsView: View { } } - if !self.viewStore.isRestoring { + if !store.isRestoring { SettingsRow { Button { - self.viewStore.send(.restoreButtonTapped, animation: .default) + store.send(.restoreButtonTapped, animation: .default) } label: { Text("Restore purchases") .foregroundColor(.isowordsOrange) diff --git a/Sources/SettingsFeature/Settings.swift b/Sources/SettingsFeature/Settings.swift index b57aa3a6..35f727a0 100644 --- a/Sources/SettingsFeature/Settings.swift +++ b/Sources/SettingsFeature/Settings.swift @@ -42,17 +42,18 @@ public struct DeveloperSettings: Equatable { @Reducer public struct Settings { + @ObservableState public struct State: Equatable { - @PresentationState public var alert: AlertState? + @Presents public var alert: AlertState? public var buildNumber: Build.Number? - @BindingState public var developer: DeveloperSettings + public var developer: DeveloperSettings public var fullGameProduct: Result? public var fullGamePurchasedAt: Date? public var isPurchasing: Bool public var isRestoring: Bool public var stats: Stats.State public var userNotificationSettings: UserNotificationClient.Notification.Settings? - @BindingState public var userSettings: UserSettings + public var userSettings: UserSettings public struct ProductError: Error, Equatable {} diff --git a/Sources/SettingsFeature/SettingsView.swift b/Sources/SettingsFeature/SettingsView.swift index dbd2f3b8..6f0962ac 100644 --- a/Sources/SettingsFeature/SettingsView.swift +++ b/Sources/SettingsFeature/SettingsView.swift @@ -11,22 +11,7 @@ public struct SettingsView: View { @Environment(\.colorScheme) var colorScheme let navPresentationStyle: NavPresentationStyle @State var isSharePresented = false - let store: StoreOf - @ObservedObject var viewStore: ViewStore - - struct ViewState: Equatable { - let buildNumber: Build.Number? - let fullGameProduct: Result? - let isFullGamePurchased: Bool - let isPurchasing: Bool - - init(state: Settings.State) { - self.buildNumber = state.buildNumber - self.fullGameProduct = state.fullGameProduct - self.isFullGamePurchased = state.isFullGamePurchased - self.isPurchasing = state.isPurchasing - } - } + @Bindable var store: StoreOf public init( store: StoreOf, @@ -34,7 +19,6 @@ public struct SettingsView: View { ) { self.navPresentationStyle = navPresentationStyle self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { @@ -42,15 +26,15 @@ public struct SettingsView: View { SettingsSection(title: "Support the game", padContents: false) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { - if !self.viewStore.isFullGamePurchased { + if !store.isFullGamePurchased { Group { - if !self.viewStore.isPurchasing, - let fullGameProduct = self.viewStore.fullGameProduct + if !store.isPurchasing, + let fullGameProduct = store.fullGameProduct { switch fullGameProduct { case let .success(product): Button { - self.viewStore.send(.tappedProduct(product), animation: .default) + store.send(.tappedProduct(product), animation: .default) } label: { HStack(alignment: .top, spacing: 0) { Text(product.priceLocale.currencySymbol ?? "$") @@ -81,7 +65,7 @@ public struct SettingsView: View { } Button { - self.viewStore.send(.leaveUsAReviewButtonTapped) + store.send(.leaveUsAReviewButtonTapped) } label: { Image(systemName: "star") .font(.system(size: 40)) @@ -115,33 +99,33 @@ public struct SettingsView: View { } SettingsNavigationLink( - destination: NotificationsSettingsView(store: self.store), + destination: NotificationsSettingsView(store: store), title: "Notifications" ) SettingsNavigationLink( - destination: SoundsSettingsView(store: self.store), + destination: SoundsSettingsView(store: store), title: "Sounds" ) SettingsNavigationLink( - destination: AppearanceSettingsView(store: self.store), + destination: AppearanceSettingsView(store: store), title: "Appearance" ) SettingsNavigationLink( - destination: AccessibilitySettingsView(store: self.store), + destination: AccessibilitySettingsView(store: store), title: "Accessibility" ) SettingsNavigationLink( - destination: StatsView(store: self.store.scope(state: \.stats, action: \.stats)), + destination: StatsView(store: store.scope(state: \.stats, action: \.stats)), title: "Stats" ) SettingsNavigationLink( - destination: PurchasesSettingsView(store: self.store), + destination: PurchasesSettingsView(store: store), title: "Purchases" ) - if self.viewStore.isFullGamePurchased { + if store.isFullGamePurchased { SettingsRow { Button { - self.viewStore.send(.leaveUsAReviewButtonTapped) + store.send(.leaveUsAReviewButtonTapped) } label: { HStack { Text("Leave us a review") @@ -154,17 +138,17 @@ public struct SettingsView: View { } #if DEBUG SettingsNavigationLink( - destination: DeveloperSettingsView(store: self.store), + destination: DeveloperSettingsView(store: store), title: "\(Image(systemName: "hammer.fill")) Developer" ) #endif VStack(spacing: 6) { - if let buildNumber = self.viewStore.buildNumber { + if let buildNumber = store.buildNumber { Text("Build \(buildNumber.rawValue)") } Button { - self.viewStore.send(.reportABugButtonTapped) + store.send(.reportABugButtonTapped) } label: { Text("Report a bug") .underline() @@ -179,11 +163,11 @@ public struct SettingsView: View { foregroundColor: .hex(self.colorScheme == .dark ? 0x7d7d7d : 0x393939), title: Text("Settings"), navPresentationStyle: self.navPresentationStyle, - onDismiss: { self.viewStore.send(.onDismiss) } + onDismiss: { store.send(.onDismiss) } ) - .task { await self.viewStore.send(.task).finish() } - .alert(store: self.store.scope(state: \.$alert, action: \.alert)) - .sheet(isPresented: self.$isSharePresented) { + .task { await store.send(.task).finish() } + .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(isPresented: $isSharePresented) { ActivityView(activityItems: [URL(string: "https://www.isowords.xyz")!]) .ignoresSafeArea() } diff --git a/Sources/SettingsFeature/SoundsSettingsView.swift b/Sources/SettingsFeature/SoundsSettingsView.swift index e7069084..350aa560 100644 --- a/Sources/SettingsFeature/SoundsSettingsView.swift +++ b/Sources/SettingsFeature/SoundsSettingsView.swift @@ -3,13 +3,7 @@ import Styleguide import SwiftUI struct SoundsSettingsView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf - - init(store: StoreOf) { - self.store = store - self.viewStore = ViewStore(self.store, observe: { $0 }) - } + @Bindable var store: StoreOf var body: some View { SettingsForm { @@ -18,12 +12,10 @@ struct SoundsSettingsView: View { Text("Music volume") VStack { - Slider( - value: self.viewStore.$userSettings.musicVolume.animation(), in: 0...1 - ) - .accentColor(.isowordsOrange) + Slider(value: $store.userSettings.musicVolume.animation(), in: 0...1) + .accentColor(.isowordsOrange) - if self.viewStore.userSettings.musicVolume <= 0 { + if store.userSettings.musicVolume <= 0 { Text("Music is off") .foregroundColor(.gray) .adaptiveFont(.matterMedium, size: 14) @@ -38,13 +30,10 @@ struct SoundsSettingsView: View { Text("Sound FX volume") VStack { - Slider( - value: self.viewStore.$userSettings.soundEffectsVolume.animation(), - in: 0...1 - ) - .accentColor(.isowordsOrange) + Slider(value: $store.userSettings.soundEffectsVolume.animation(), in: 0...1) + .accentColor(.isowordsOrange) - if self.viewStore.userSettings.soundEffectsVolume <= 0 { + if store.userSettings.soundEffectsVolume <= 0 { Text("Sound FX are off") .foregroundColor(.gray) .adaptiveFont(.matterMedium, size: 14) diff --git a/Sources/SoloFeature/SoloView.swift b/Sources/SoloFeature/SoloView.swift index 0fc5f7e5..dfae4583 100644 --- a/Sources/SoloFeature/SoloView.swift +++ b/Sources/SoloFeature/SoloView.swift @@ -8,6 +8,7 @@ import SwiftUI @Reducer public struct Solo { + @ObservableState public struct State: Equatable { var inProgressGame: InProgressGame? @@ -53,67 +54,57 @@ public struct SoloView: View { @Environment(\.colorScheme) var colorScheme let store: StoreOf - struct ViewState: Equatable { - let currentScore: Int? - - init(state: Solo.State) { - self.currentScore = state.inProgressGame?.currentScore - } - } - public init(store: StoreOf) { self.store = store } public var body: some View { - WithViewStore(self.store, observe: ViewState.init) { viewStore in - VStack { - Spacer() - .frame(maxHeight: .grid(16)) - - VStack(spacing: -8) { - Text("Kill time") - Text("and refine") - Text("your skills") - } - .font(.custom(.matter, size: self.adaptiveSize.pad(48, by: 2))) - .multilineTextAlignment(.center) - - Spacer() - - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: .grid(4)), - GridItem(.flexible()), - ] - ) { - GameButton( - title: Text("Timed"), - icon: Image(systemName: "clock.fill"), - color: .solo, - inactiveText: nil, - isLoading: false, - resumeText: nil, - action: { viewStore.send(.gameButtonTapped(.timed), animation: .default) } - ) - - GameButton( - title: Text("Unlimited"), - icon: Image(systemName: "infinity"), - color: .solo, - inactiveText: nil, - isLoading: false, - resumeText: viewStore.currentScore.flatMap { - $0 > 0 ? Text("\($0) points") : nil - }, - action: { viewStore.send(.gameButtonTapped(.unlimited), animation: .default) } - ) - } + VStack { + Spacer() + .frame(maxHeight: .grid(16)) + + VStack(spacing: -8) { + Text("Kill time") + Text("and refine") + Text("your skills") + } + .font(.custom(.matter, size: self.adaptiveSize.pad(48, by: 2))) + .multilineTextAlignment(.center) + + Spacer() + + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: .grid(4)), + GridItem(.flexible()), + ] + ) { + GameButton( + title: Text("Timed"), + icon: Image(systemName: "clock.fill"), + color: .solo, + inactiveText: nil, + isLoading: false, + resumeText: nil, + action: { store.send(.gameButtonTapped(.timed), animation: .default) } + ) + + GameButton( + title: Text("Unlimited"), + icon: Image(systemName: "infinity"), + color: .solo, + inactiveText: nil, + isLoading: false, + resumeText: (store.inProgressGame?.currentScore).flatMap { + $0 > 0 ? Text("\($0) points") : nil + }, + action: { store.send(.gameButtonTapped(.unlimited), animation: .default) } + ) } - .adaptivePadding(.vertical) - .screenEdgePadding(.horizontal) - .task { await viewStore.send(.task).finish() } } + .adaptivePadding(.vertical) + .screenEdgePadding(.horizontal) + .task { await store.send(.task).finish() } .navigationStyle( backgroundColor: self.colorScheme == .dark ? .isowordsBlack : .solo, foregroundColor: self.colorScheme == .dark ? .solo : .isowordsBlack, diff --git a/Sources/StatsFeature/StatsFeature.swift b/Sources/StatsFeature/StatsFeature.swift index e9a2c08c..1c9e1fa1 100644 --- a/Sources/StatsFeature/StatsFeature.swift +++ b/Sources/StatsFeature/StatsFeature.swift @@ -6,26 +6,15 @@ import VocabFeature @Reducer public struct Stats { - @Reducer - public struct Destination { - public enum State: Equatable { - case vocab(Vocab.State) - } - - public enum Action { - case vocab(Vocab.Action) - } - - public var body: some ReducerOf { - Scope(state: \.vocab, action: \.vocab) { - Vocab() - } - } + @Reducer(state: .equatable) + public enum Destination { + case vocab(Vocab) } + @ObservableState public struct State: Equatable { public var averageWordLength: Double? - @PresentationState public var destination: Destination.State? + @Presents public var destination: Destination.State? public var gamesPlayed: Int public var highestScoringWord: LocalDatabaseClient.Stats.Word? public var highScoreTimed: Int? @@ -111,18 +100,16 @@ public struct Stats { } } .ifLet(\.$destination, action: \.destination) { - Destination() + Destination.body } } } public struct StatsView: View { - let store: StoreOf - @ObservedObject var viewStore: ViewStoreOf + @Bindable var store: StoreOf public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(store, observe: { $0 }) } public var body: some View { @@ -131,7 +118,7 @@ public struct StatsView: View { HStack { Text("Games played") Spacer() - Text("\(self.viewStore.gamesPlayed)") + Text("\(store.gamesPlayed)") .foregroundColor(.isowordsOrange) } .adaptiveFont(.matterMedium, size: 16) @@ -147,7 +134,7 @@ public struct StatsView: View { Text("Timed") Spacer() Group { - if let highScoreTimed = self.viewStore.highScoreTimed { + if let highScoreTimed = store.highScoreTimed { Text("\(highScoreTimed)") } else { Text("none") @@ -160,7 +147,7 @@ public struct StatsView: View { Text("Unlimited") Spacer() Group { - if let highScoreUnlimited = self.viewStore.highScoreUnlimited { + if let highScoreUnlimited = store.highScoreUnlimited { Text("\(highScoreUnlimited)") } else { Text("none") @@ -176,13 +163,13 @@ public struct StatsView: View { SettingsRow { Button { - self.viewStore.send(.vocabButtonTapped) + store.send(.vocabButtonTapped) } label: { HStack { Text("Words found") Spacer() Group { - Text("\(self.viewStore.wordsFound)") + Text("\(store.wordsFound)") Image(systemName: "arrow.right") } .foregroundColor(.isowordsOrange) @@ -193,7 +180,7 @@ public struct StatsView: View { .buttonStyle(PlainButtonStyle()) } - if let highestScoringWord = self.viewStore.highestScoringWord { + if let highestScoringWord = store.highestScoringWord { SettingsRow { VStack(alignment: .trailing, spacing: 12) { HStack { @@ -218,17 +205,18 @@ public struct StatsView: View { HStack { Text("Time played") Spacer() - Text(timePlayed(seconds: self.viewStore.secondsPlayed)) + Text(timePlayed(seconds: store.secondsPlayed)) .foregroundColor(.isowordsOrange) } .adaptiveFont(.matterMedium, size: 16) } } - .task { await self.viewStore.send(.task).finish() } + .task { await store.send(.task).finish() } .navigationDestination( - store: self.store.scope(state: \.$destination.vocab, action: \.destination.vocab), - destination: VocabView.init(store:) - ) + item: $store.scope(state: \.destination?.vocab, action: \.destination.vocab) + ) { store in + VocabView(store: store) + } .navigationStyle(title: Text("Stats")) } } diff --git a/Sources/TrailerFeature/Trailer.swift b/Sources/TrailerFeature/Trailer.swift index b376900b..f09a2a0c 100644 --- a/Sources/TrailerFeature/Trailer.swift +++ b/Sources/TrailerFeature/Trailer.swift @@ -9,10 +9,11 @@ import UIApplicationClient @Reducer public struct Trailer { + @ObservableState public struct State: Equatable { var game: Game.State - @BindingState var nub: CubeSceneView.ViewState.NubState - @BindingState var opacity: Double + var nub: CubeSceneView.ViewState.NubState + var opacity: Double public init( game: Game.State, @@ -25,7 +26,7 @@ public struct Trailer { } public init() { - self = .init( + self.init( game: .init( cubes: .trailer, gameContext: .solo, @@ -93,7 +94,7 @@ public struct Trailer { await self.audioPlayer.play(.onboardingBgMusic) // Fade the cube in after a second - await send(.set(\.$opacity, 1), animation: .easeInOut(duration: fadeInDuration)) + await send(.set(\.opacity, 1), animation: .easeInOut(duration: fadeInDuration)) try await self.mainQueue.sleep(for: firstWordDelay) // Play each word @@ -105,7 +106,7 @@ public struct Trailer { // Move the nub to the face being played nub.location = .face(face) await send( - .set(\.$nub, nub), + .set(\.nub, nub), animateWithDuration: moveNubToFaceDuration, options: .curveEaseInOut ) @@ -120,7 +121,7 @@ public struct Trailer { // Press the nub on the first character nub.isPressed = true if characterIndex == 0 { - await send(.set(\.$nub, nub), animateWithDuration: 0.3) + await send(.set(\.nub, nub), animateWithDuration: 0.3) } // Select the cube face await send(.game(.tap(.began, face)), animation: .default) @@ -128,14 +129,14 @@ public struct Trailer { // Release the nub when the last character is played nub.isPressed = false - await send(.set(\.$nub, nub), animateWithDuration: 0.3) + await send(.set(\.nub, nub), animateWithDuration: 0.3) // Move the nub to the submit button try await self.mainQueue.sleep(for: .seconds(0.3)) nub.location = .submitButton await send( - .set(\.$nub, nub), + .set(\.nub, nub), animateWithDuration: moveNubToSubmitButtonDuration, options: .curveEaseInOut ) @@ -157,7 +158,7 @@ public struct Trailer { group.addTask { [nub] in var nub = nub nub.isPressed = true - await send(.set(\.$nub, nub), animateWithDuration: 0.3) + await send(.set(\.nub, nub), animateWithDuration: 0.3) } group.addTask { [nub] in var nub = nub @@ -165,7 +166,7 @@ public struct Trailer { await send(.game(.submitButtonTapped(reaction: nil))) try await self.mainQueue.sleep(for: .seconds(0.3)) nub.isPressed = false - await send(.set(\.$nub, nub), animateWithDuration: 0.3) + await send(.set(\.nub, nub), animateWithDuration: 0.3) } } } @@ -174,12 +175,12 @@ public struct Trailer { try await self.mainQueue.sleep(for: .seconds(0.3)) nub.location = .offScreenBottom await send( - .set(\.$nub, nub), + .set(\.nub, nub), animateWithDuration: moveNubOffScreenDuration, options: .curveEaseInOut ) - await send(.set(\.$opacity, 0), animation: .linear(duration: moveNubOffScreenDuration)) + await send(.set(\.opacity, 0), animation: .linear(duration: moveNubOffScreenDuration)) } } } @@ -188,36 +189,18 @@ public struct Trailer { public struct TrailerView: View { let store: StoreOf - @ObservedObject var viewStore: ViewStore @Environment(\.deviceState) var deviceState - struct ViewState: Equatable { - let opacity: Double - let selectedWordHasAlreadyBeenPlayed: Bool - let selectedWordIsValid: Bool - let selectedWordScore: Int? - let selectedWordString: String - - init(state: Trailer.State) { - self.opacity = state.opacity - self.selectedWordHasAlreadyBeenPlayed = state.game.selectedWordHasAlreadyBeenPlayed - self.selectedWordIsValid = state.game.selectedWordIsValid - self.selectedWordScore = self.selectedWordIsValid ? state.game.selectedWordScore : nil - self.selectedWordString = state.game.selectedWordString - } - } - public init(store: StoreOf) { self.store = store - self.viewStore = ViewStore(self.store, observe: ViewState.init) } public var body: some View { GeometryReader { proxy in ZStack { VStack { - if !self.viewStore.selectedWordString.isEmpty { - (Text(self.viewStore.selectedWordString) + if !store.game.selectedWordString.isEmpty { + (Text(store.game.selectedWordString) + self.scoreText .baselineOffset( (self.deviceState.idiom == .pad ? 2 : 1) * 16 @@ -232,19 +215,19 @@ public struct TrailerView: View { .matterSemiBold, size: (self.deviceState.idiom == .pad ? 2 : 1) * 32 ) - .opacity(self.viewStore.selectedWordIsValid ? 1 : 0.5) + .opacity(store.game.selectedWordIsValid ? 1 : 0.5) .allowsTightening(true) .minimumScaleFactor(0.2) .lineLimit(1) .transition(.opacity) - .animation(nil, value: self.viewStore.selectedWordString) + .animation(nil, value: store.game.selectedWordString) } Spacer() - if !self.viewStore.selectedWordString.isEmpty { + if !store.game.selectedWordString.isEmpty { WordSubmitButton( - store: self.store.scope( + store: store.scope( state: \.game.wordSubmitButtonFeature, action: \.game.wordSubmitButton ) @@ -259,46 +242,39 @@ public struct TrailerView: View { WordListView( isLeftToRight: true, - store: self.store.scope(state: \.game, action: \.game) + store: store.scope(state: \.game, action: \.game) ) } .adaptivePadding(.top, .grid(18)) .adaptivePadding(.bottom, .grid(2)) - CubeView(store: self.store.scope(state: \.cubeScene, action: \.game.cubeScene)) + CubeView(store: store.scope(state: \.cubeScene, action: \.game.cubeScene)) .adaptivePadding( self.deviceState.idiom == .pad ? .horizontal : [], .grid(30) ) } - .background( + .background { BloomBackground( size: proxy.size, - store: self.store - .scope( - state: { - BloomBackground.ViewState( - bloomCount: $0.game.selectedWord.count, - word: $0.game.selectedWordString - ) - }, - action: absurd - ) + word: store.game.selectedWordString ) - ) + } } .padding( self.deviceState.idiom == .pad ? .vertical : [], .grid(15) ) - .opacity(self.viewStore.opacity) - .task { await self.viewStore.send(.task).finish() } + .opacity(store.opacity) + .task { await store.send(.task).finish() } } var scoreText: Text { - self.viewStore.selectedWordScore.map { - Text(" \($0)") - } ?? Text("") + if store.game.selectedWordIsValid { + return Text(" \(store.game.selectedWordScore)") + } else { + return Text(verbatim: "") + } } } @@ -319,5 +295,3 @@ private let fadeInDuration = 0.3 private let fadeOutDuration = 0.3 private let submitPressDuration = 0.05 private let submitHestitationDuration = 0.15 - -private func absurd(_: Never) -> A {} diff --git a/Sources/UIApplicationClient/Client.swift b/Sources/UIApplicationClient/Client.swift index 5e4925ea..43efb3fd 100644 --- a/Sources/UIApplicationClient/Client.swift +++ b/Sources/UIApplicationClient/Client.swift @@ -13,6 +13,5 @@ public struct UIApplicationClient { public var setAlternateIconName: @Sendable (String?) async throws -> Void // TODO: Should these endpoints be merged and `@MainActor`? Should `Reducer` be `@MainActor`? public var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void - @available(*, deprecated) public var supportsAlternateIcons: () -> Bool = { false } public var supportsAlternateIconsAsync: @Sendable () async -> Bool = { false } } diff --git a/Sources/UIApplicationClient/LiveKey.swift b/Sources/UIApplicationClient/LiveKey.swift index 0c9928c7..7bbf5daf 100644 --- a/Sources/UIApplicationClient/LiveKey.swift +++ b/Sources/UIApplicationClient/LiveKey.swift @@ -18,7 +18,6 @@ extension UIApplicationClient: DependencyKey { scene.keyWindow?.overrideUserInterfaceStyle = userInterfaceStyle } }, - supportsAlternateIcons: { UIApplication.shared.supportsAlternateIcons }, supportsAlternateIconsAsync: { await UIApplication.shared.supportsAlternateIcons } ) } diff --git a/Sources/UIApplicationClient/TestKey.swift b/Sources/UIApplicationClient/TestKey.swift index dbbb617e..40246617 100644 --- a/Sources/UIApplicationClient/TestKey.swift +++ b/Sources/UIApplicationClient/TestKey.swift @@ -20,7 +20,6 @@ extension UIApplicationClient { openSettingsURLString: { "settings://isowords/settings" }, setAlternateIconName: { _ in }, setUserInterfaceStyle: { _ in }, - supportsAlternateIcons: { true }, supportsAlternateIconsAsync: { true } ) } diff --git a/Sources/UpgradeInterstitialFeature/UpgradeInterstitialView.swift b/Sources/UpgradeInterstitialFeature/UpgradeInterstitialView.swift index 11973256..7683c040 100644 --- a/Sources/UpgradeInterstitialFeature/UpgradeInterstitialView.swift +++ b/Sources/UpgradeInterstitialFeature/UpgradeInterstitialView.swift @@ -15,6 +15,7 @@ public enum GameContext: String, Codable { @Reducer public struct UpgradeInterstitial { + @ObservableState public struct State: Equatable { public var fullGameProduct: StoreKitClient.Product? public var isDismissable: Bool @@ -164,118 +165,116 @@ public struct UpgradeInterstitialView: View { } public var body: some View { - WithViewStore(self.store, observe: { $0 }) { viewStore in + VStack { VStack { - VStack { - if !viewStore.isDismissable - && viewStore.secondsPassedCount < viewStore.upgradeInterstitialDuration - { - Text("\(viewStore.upgradeInterstitialDuration - viewStore.secondsPassedCount)s") - .animation(nil) - .multilineTextAlignment(.center) - .adaptiveFont(.matterMedium, size: 16) { $0.monospacedDigit() } - .adaptivePadding(.bottom) - .transition(.opacity) - } + if !store.isDismissable + && store.secondsPassedCount < store.upgradeInterstitialDuration + { + Text("\(store.upgradeInterstitialDuration - store.secondsPassedCount)s") + .animation(nil) + .multilineTextAlignment(.center) + .adaptiveFont(.matterMedium, size: 16) { $0.monospacedDigit() } + .adaptivePadding(.bottom) + .transition(.opacity) + } - VStack(spacing: 32) { - (Text("A personal\nappeal from\nthe creators\nof ") - + Text("isowords").fontWeight(.medium)) - .multilineTextAlignment(.center) - .adaptiveFont(.matter, size: 35) - .fixedSize() + VStack(spacing: 32) { + (Text("A personal\nappeal from\nthe creators\nof ") + + Text("isowords").fontWeight(.medium)) + .multilineTextAlignment(.center) + .adaptiveFont(.matter, size: 35) + .fixedSize() - Text( + Text( """ Hello! We could put an ad here, but we chose not to because ads suck. But also, keeping \ this game running costs money. So if you can, please purchase the full version and help \ support the development of new features and remove these annoying prompts! """ - ) - .minimumScaleFactor(0.2) - .multilineTextAlignment(.center) - .adaptiveFont(.matter, size: 16) - } - .adaptivePadding() - - Spacer() + ) + .minimumScaleFactor(0.2) + .multilineTextAlignment(.center) + .adaptiveFont(.matter, size: 16) } - .applying { - if self.colorScheme == .dark { - $0.foreground( - LinearGradient( - gradient: Gradient(colors: [.hex(0xF3EBA4), .hex(0xE1665B)]), - startPoint: .top, - endPoint: .bottom - ) + .adaptivePadding() + + Spacer() + } + .applying { + if self.colorScheme == .dark { + $0.foreground( + LinearGradient( + gradient: Gradient(colors: [.hex(0xF3EBA4), .hex(0xE1665B)]), + startPoint: .top, + endPoint: .bottom ) - } else { - $0 - } + ) + } else { + $0 } + } - VStack(spacing: 24) { - Button { - viewStore.send(.upgradeButtonTapped, animation: .default) - } label: { - HStack(spacing: .grid(2)) { - if viewStore.isPurchasing { - ProgressView() - .progressViewStyle( - CircularProgressViewStyle( - tint: self.colorScheme == .dark ? .isowordsBlack : .hex(0xE1665B) - ) + VStack(spacing: 24) { + Button { + store.send(.upgradeButtonTapped, animation: .default) + } label: { + HStack(spacing: .grid(2)) { + if store.isPurchasing { + ProgressView() + .progressViewStyle( + CircularProgressViewStyle( + tint: self.colorScheme == .dark ? .isowordsBlack : .hex(0xE1665B) ) - } - if let fullGameProduct = viewStore.fullGameProduct { - Text("Upgrade for \(cost(product: fullGameProduct))") - } else { - Text("Upgrade") - } + ) + } + if let fullGameProduct = store.fullGameProduct { + Text("Upgrade for \(cost(product: fullGameProduct))") + } else { + Text("Upgrade") } - .frame(maxWidth: .infinity) } - .buttonStyle( - ActionButtonStyle( - backgroundColor: self.colorScheme == .dark ? .hex(0xE1665B) : .isowordsBlack, - foregroundColor: self.colorScheme == .dark ? .isowordsBlack : .hex(0xE1665B) - ) + .frame(maxWidth: .infinity) + } + .buttonStyle( + ActionButtonStyle( + backgroundColor: self.colorScheme == .dark ? .hex(0xE1665B) : .isowordsBlack, + foregroundColor: self.colorScheme == .dark ? .isowordsBlack : .hex(0xE1665B) ) - .disabled(viewStore.isPurchasing) - - if viewStore.isDismissable - || viewStore.secondsPassedCount >= viewStore.upgradeInterstitialDuration - { - Button { - viewStore.send(.maybeLaterButtonTapped, animation: .default) - } label: { - Text("Maybe later") - .foregroundColor(self.colorScheme == .dark ? .hex(0xE1665B) : .isowordsBlack) - } - .foregroundColor(.isowordsBlack) - .adaptiveFont(.matterMedium, size: 14) - .transition(.opacity) + ) + .disabled(store.isPurchasing) + + if store.isDismissable + || store.secondsPassedCount >= store.upgradeInterstitialDuration + { + Button { + store.send(.maybeLaterButtonTapped, animation: .default) + } label: { + Text("Maybe later") + .foregroundColor(self.colorScheme == .dark ? .hex(0xE1665B) : .isowordsBlack) } + .foregroundColor(.isowordsBlack) + .adaptiveFont(.matterMedium, size: 14) + .transition(.opacity) } } - .adaptivePadding() - .task { await viewStore.send(.task).finish() } - .applying { - if self.colorScheme == .dark { - $0.background( - Color.isowordsBlack - .ignoresSafeArea() - ) - } else { - $0.background( - LinearGradient( - gradient: Gradient(colors: [.hex(0xF3EBA4), .hex(0xE1665B)]), - startPoint: .top, - endPoint: .bottom - ) + } + .adaptivePadding() + .task { await store.send(.task).finish() } + .applying { + if self.colorScheme == .dark { + $0.background( + Color.isowordsBlack .ignoresSafeArea() + ) + } else { + $0.background( + LinearGradient( + gradient: Gradient(colors: [.hex(0xF3EBA4), .hex(0xE1665B)]), + startPoint: .top, + endPoint: .bottom ) - } + .ignoresSafeArea() + ) } } } diff --git a/Sources/VocabFeature/Vocab.swift b/Sources/VocabFeature/Vocab.swift index c34742da..fa9a9cc7 100644 --- a/Sources/VocabFeature/Vocab.swift +++ b/Sources/VocabFeature/Vocab.swift @@ -5,25 +5,14 @@ import SwiftUI @Reducer public struct Vocab: Reducer { - @Reducer - public struct Destination: Reducer { - public enum State: Equatable { - case cubePreview(CubePreview.State) - } - - public enum Action { - case cubePreview(CubePreview.Action) - } - - public var body: some ReducerOf { - Scope(state: \.cubePreview, action: \.cubePreview) { - CubePreview() - } - } + @Reducer(state: .equatable) + public enum Destination { + case cubePreview(CubePreview) } + @ObservableState public struct State: Equatable { - @PresentationState var destination: Destination.State? + @Presents var destination: Destination.State? var isAnimationReduced: Bool var vocab: LocalDatabaseClient.Vocab? @@ -116,13 +105,13 @@ public struct Vocab: Reducer { } } .ifLet(\.$destination, action: \.destination) { - Destination() + Destination.body } } } public struct VocabView: View { - public let store: StoreOf + @Bindable var store: StoreOf public init(store: StoreOf) { self.store = store @@ -130,42 +119,38 @@ public struct VocabView: View { public var body: some View { VStack { - IfLetStore(self.store.scope(state: \.vocab, action: \.self)) { vocabStore in - WithViewStore(vocabStore, observe: { $0 }) { vocabViewStore in - List { - ForEach(vocabViewStore.words, id: \.letters) { word in - Button { - vocabViewStore.send(.wordTapped(word)) - } label: { - HStack { - HStack(alignment: .top, spacing: 0) { - Text(word.letters.capitalized) - .adaptiveFont(.matterMedium, size: 20) - - Text("\(word.score)") - .padding(.top, -4) - .adaptiveFont(.matterMedium, size: 14) - } - - Spacer() - - if word.playCount > 1 { - Text("(\(word.playCount)x)") - } + if let words = store.vocab?.words { + List { + ForEach(words, id: \.letters) { word in + Button { + store.send(.wordTapped(word)) + } label: { + HStack { + HStack(alignment: .top, spacing: 0) { + Text(word.letters.capitalized) + .adaptiveFont(.matterMedium, size: 20) + + Text("\(word.score)") + .padding(.top, -4) + .adaptiveFont(.matterMedium, size: 14) + } + + Spacer() + + if word.playCount > 1 { + Text("(\(word.playCount)x)") } } } } } } - .task { await self.store.send(.task).finish() } - .sheet( - store: self.store.scope( - state: \.$destination.cubePreview, - action: \.destination.cubePreview - ), - content: CubePreviewView.init(store:) - ) + } + .task { await store.send(.task).finish() } + .sheet( + item: $store.scope(state: \.destination?.cubePreview, action: \.destination.cubePreview) + ) { store in + CubePreviewView(store: store) } .adaptiveFont(.matterMedium, size: 16) .navigationStyle(title: Text("Words Found")) diff --git a/Tests/AppFeatureTests/PersistenceTests.swift b/Tests/AppFeatureTests/PersistenceTests.swift index 877117f2..d798225c 100644 --- a/Tests/AppFeatureTests/PersistenceTests.swift +++ b/Tests/AppFeatureTests/PersistenceTests.swift @@ -20,8 +20,8 @@ import XCTest @testable import SoloFeature @testable import UserDefaultsClient -@MainActor class PersistenceTests: XCTestCase { + @MainActor func testUnlimitedSaveAndQuit() async throws { let saves = ActorIsolated<[Data]>([]) @@ -143,6 +143,7 @@ class PersistenceTests: XCTestCase { } } + @MainActor func testUnlimitedAbandon() async throws { let didArchiveGame = ActorIsolated(false) let saves = ActorIsolated<[Data]>([]) @@ -206,6 +207,7 @@ class PersistenceTests: XCTestCase { } } + @MainActor func testTimedAbandon() async { let didArchiveGame = ActorIsolated(false) @@ -254,6 +256,7 @@ class PersistenceTests: XCTestCase { await didArchiveGame.withValue { XCTAssert($0) } } + @MainActor func testUnlimitedResume() async { let savedGames = SavedGamesState(dailyChallengeUnlimited: nil, unlimited: .mock) let store = TestStore( @@ -282,6 +285,7 @@ class PersistenceTests: XCTestCase { await task.cancel() } + @MainActor func testTurnBasedAbandon() async { let store = TestStore( initialState: AppReducer.State( diff --git a/Tests/AppFeatureTests/RemoteNotificationsTests.swift b/Tests/AppFeatureTests/RemoteNotificationsTests.swift index f7999830..28e89267 100644 --- a/Tests/AppFeatureTests/RemoteNotificationsTests.swift +++ b/Tests/AppFeatureTests/RemoteNotificationsTests.swift @@ -9,8 +9,8 @@ import XCTest @testable import AppFeature -@MainActor class RemoteNotificationsTests: XCTestCase { + @MainActor func testRegisterForRemoteNotifications_OnActivate_Authorized() async { let didRegisterForRemoteNotifications = ActorIsolated(false) let requestedAuthorizationOptions = ActorIsolated(nil) @@ -68,6 +68,7 @@ class RemoteNotificationsTests: XCTestCase { await task.cancel() } + @MainActor func testRegisterForRemoteNotifications_NotAuthorized() async { let didRegisterForRemoteNotifications = ActorIsolated(false) let requestedAuthorizationOptions = ActorIsolated(nil) @@ -94,6 +95,7 @@ class RemoteNotificationsTests: XCTestCase { await task.cancel() } + @MainActor func testReceiveNotification_dailyChallengeEndsSoon() async { let inProgressGame = InProgressGame.mock diff --git a/Tests/AppFeatureTests/TurnBasedTests.swift b/Tests/AppFeatureTests/TurnBasedTests.swift index 19bbb842..a70fe3dc 100644 --- a/Tests/AppFeatureTests/TurnBasedTests.swift +++ b/Tests/AppFeatureTests/TurnBasedTests.swift @@ -21,11 +21,11 @@ import XCTest @testable import ComposableGameCenter @testable import HomeFeature -@MainActor class TurnBasedTests: XCTestCase { let mainQueue = DispatchQueue.test let mainRunLoop = RunLoop.test + @MainActor func testNewGame() async throws { try await withMainSerialExecutor { let didEndTurnWithRequest = ActorIsolated(nil) @@ -113,13 +113,13 @@ class TurnBasedTests: XCTestCase { await store.receive(\.home.serverConfigResponse) { $0.home.hasChangelog = true } - await store.receive(\.home.activeMatchesResponse.success) await store.receive(\.home.dailyChallengeResponse.success) { $0.home.dailyChallenges = dailyChallenges } await store.receive(\.home.weekInReviewResponse.success) { $0.home.weekInReview = weekInReview } + await store.receive(\.home.activeMatchesResponse.success) await store.send(.home(.destination(.presented(.multiplayer(.startButtonTapped))))) @@ -274,6 +274,7 @@ class TurnBasedTests: XCTestCase { } } + @MainActor func testResumeGame() async { await withMainSerialExecutor { let listener = AsyncStreamProducer() @@ -338,13 +339,13 @@ class TurnBasedTests: XCTestCase { await store.receive(\.home.serverConfigResponse) { $0.home.hasChangelog = true } - await store.receive(\.home.activeMatchesResponse.success) await store.receive(\.home.dailyChallengeResponse.success) { $0.home.dailyChallenges = dailyChallenges } await store.receive(\.home.weekInReviewResponse.success) { $0.home.weekInReview = weekInReview } + await store.receive(\.home.activeMatchesResponse.success) listener.continuation .yield(.turnBased(.receivedTurnEventForMatch(.inProgress, didBecomeActive: true))) @@ -378,6 +379,7 @@ class TurnBasedTests: XCTestCase { } } + @MainActor func testResumeForfeitedGame() async { await withMainSerialExecutor { let listener = AsyncStreamProducer() @@ -439,14 +441,14 @@ class TurnBasedTests: XCTestCase { await store.receive(\.home.serverConfigResponse) { $0.home.hasChangelog = true } - await store.receive(\.home.activeMatchesResponse.success) await store.receive(\.home.dailyChallengeResponse.success) { $0.home.dailyChallenges = dailyChallenges } await store.receive(\.home.weekInReviewResponse.success) { $0.home.weekInReview = weekInReview } - + await store.receive(\.home.activeMatchesResponse.success) + listener.continuation .yield(.turnBased(.receivedTurnEventForMatch(.forfeited, didBecomeActive: true))) @@ -488,6 +490,7 @@ class TurnBasedTests: XCTestCase { } } + @MainActor func testRemovingCubes() async throws { let didEndTurnWithRequest = ActorIsolated(nil) let match = update(TurnBasedMatch.inProgress) { @@ -653,6 +656,7 @@ class TurnBasedTests: XCTestCase { } } + @MainActor func testRematch() async { let localParticipant = TurnBasedParticipant.local let match = update(TurnBasedMatch.inProgress) { @@ -737,6 +741,7 @@ class TurnBasedTests: XCTestCase { } } + @MainActor func testGameCenterNotification_ShowsRecentTurn() async { let localParticipant = TurnBasedParticipant.local let remoteParticipant = update(TurnBasedParticipant.remote) { @@ -805,6 +810,7 @@ class TurnBasedTests: XCTestCase { } } + @MainActor func testGameCenterNotification_DoesNotShow() async { let localParticipant = TurnBasedParticipant.local let remoteParticipant = update(TurnBasedParticipant.remote) { diff --git a/Tests/AppFeatureTests/UserNotificationsTests.swift b/Tests/AppFeatureTests/UserNotificationsTests.swift index 2a03049e..8ecc5b50 100644 --- a/Tests/AppFeatureTests/UserNotificationsTests.swift +++ b/Tests/AppFeatureTests/UserNotificationsTests.swift @@ -7,8 +7,8 @@ import XCTest @testable import AppFeature -@MainActor class UserNotificationsTests: XCTestCase { + @MainActor func testReceiveBackgroundNotification() async { let delegate = AsyncStream.makeStream() let response = UserNotificationClient.Notification.Response( @@ -43,6 +43,7 @@ class UserNotificationsTests: XCTestCase { await task.cancel() } + @MainActor func testReceiveForegroundNotification() async { let delegate = AsyncStream.makeStream() let notification = UserNotificationClient.Notification( diff --git a/Tests/ChangelogFeatureTests/ChangelogFeatureTests.swift b/Tests/ChangelogFeatureTests/ChangelogFeatureTests.swift index 8f4d4357..7a73a314 100644 --- a/Tests/ChangelogFeatureTests/ChangelogFeatureTests.swift +++ b/Tests/ChangelogFeatureTests/ChangelogFeatureTests.swift @@ -6,8 +6,8 @@ import XCTest @testable import ChangelogFeature @testable import UserDefaultsClient -@MainActor class ChangelogFeatureTests: XCTestCase { + @MainActor func testOnAppear_IsUpToDate() async { let changelog = Changelog( changes: [ @@ -45,6 +45,7 @@ class ChangelogFeatureTests: XCTestCase { } } + @MainActor func testOnAppear_IsUpBehind() async { let changelog = Changelog( changes: [ diff --git a/Tests/DailyChallengeFeatureIntegrationTests/DailyChallengeFeatureIntegrationTests.swift b/Tests/DailyChallengeFeatureIntegrationTests/DailyChallengeFeatureIntegrationTests.swift index 1a00ada0..6da53602 100644 --- a/Tests/DailyChallengeFeatureIntegrationTests/DailyChallengeFeatureIntegrationTests.swift +++ b/Tests/DailyChallengeFeatureIntegrationTests/DailyChallengeFeatureIntegrationTests.swift @@ -13,8 +13,8 @@ import XCTest @testable import LeaderboardFeature -@MainActor class DailyChallengeFeatureTests: XCTestCase { + @MainActor func testBasics() async { await withMainSerialExecutor { let uuid = UUID.incrementing diff --git a/Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift b/Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift index 18cd44ff..b76efd52 100644 --- a/Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift +++ b/Tests/DailyChallengeFeatureTests/DailyChallengeFeatureTests.swift @@ -7,11 +7,11 @@ import XCTest @testable import DailyChallengeFeature @testable import SharedModels -@MainActor class DailyChallengeFeatureTests: XCTestCase { let mainQueue = DispatchQueue.test let mainRunLoop = RunLoop.test + @MainActor func testOnAppear() async { let store = TestStore(initialState: DailyChallengeReducer.State()) { DailyChallengeReducer() @@ -36,6 +36,7 @@ class DailyChallengeFeatureTests: XCTestCase { } } + @MainActor func testTapGameThatWasPlayed() async { var dailyChallengeResponse = FetchTodaysDailyChallengeResponse.played dailyChallengeResponse.dailyChallenge.endsAt = Date().addingTimeInterval(60 * 60 * 2 + 1) @@ -53,6 +54,7 @@ class DailyChallengeFeatureTests: XCTestCase { } } + @MainActor func testTapGameThatWasNotStarted() async { var inProgressGame = InProgressGame.mock inProgressGame.gameStartTime = self.mainRunLoop.now.date @@ -100,6 +102,7 @@ class DailyChallengeFeatureTests: XCTestCase { await store.receive(\.delegate.startGame) } + @MainActor func testTapGameThatWasStarted_NotPlayed_HasLocalGame() async { var inProgressGame = InProgressGame.mock inProgressGame.gameStartTime = .mock @@ -132,6 +135,7 @@ class DailyChallengeFeatureTests: XCTestCase { await store.receive(\.delegate.startGame) } + @MainActor func testNotifications_OpenThenClose() async { let store = TestStore( initialState: DailyChallengeReducer.State() @@ -147,6 +151,7 @@ class DailyChallengeFeatureTests: XCTestCase { } } + @MainActor func testNotifications_GrantAccess() async { let didRegisterForRemoteNotifications = ActorIsolated(false) @@ -181,6 +186,7 @@ class DailyChallengeFeatureTests: XCTestCase { await didRegisterForRemoteNotifications.withValue { XCTAssertNoDifference($0, true) } } + @MainActor func testNotifications_DenyAccess() async { let store = TestStore(initialState: DailyChallengeReducer.State()) { DailyChallengeReducer() diff --git a/Tests/GameCoreTests/GameCoreTests.swift b/Tests/GameCoreTests/GameCoreTests.swift index 69ae3230..1990d7f4 100644 --- a/Tests/GameCoreTests/GameCoreTests.swift +++ b/Tests/GameCoreTests/GameCoreTests.swift @@ -4,8 +4,8 @@ import GameCore import GameOverFeature import XCTest -@MainActor class GameCoreTests: XCTestCase { + @MainActor func testForfeitTurnBasedGame() async { let didEndMatchInTurn = ActorIsolated(false) diff --git a/Tests/GameOverFeatureIntegrationTests/GameOverFeatureIntegrationTests.swift b/Tests/GameOverFeatureIntegrationTests/GameOverFeatureIntegrationTests.swift index 7dbdf47e..daa62373 100644 --- a/Tests/GameOverFeatureIntegrationTests/GameOverFeatureIntegrationTests.swift +++ b/Tests/GameOverFeatureIntegrationTests/GameOverFeatureIntegrationTests.swift @@ -6,8 +6,8 @@ import SharedModels import SiteMiddleware import XCTest -@MainActor class GameOverFeatureIntegrationTests: XCTestCase { + @MainActor func testSubmitSoloScore() async { await withMainSerialExecutor { let ranks: [TimeScope: LeaderboardScoreResult.Rank] = [ diff --git a/Tests/GameOverFeatureTests/GameOverFeatureTests.swift b/Tests/GameOverFeatureTests/GameOverFeatureTests.swift index d3cdd7e6..3b843628 100644 --- a/Tests/GameOverFeatureTests/GameOverFeatureTests.swift +++ b/Tests/GameOverFeatureTests/GameOverFeatureTests.swift @@ -6,15 +6,14 @@ import GameOverFeature import Overture import SharedModels import TestHelpers +import UpgradeInterstitialFeature import XCTest @testable import LocalDatabaseClient @testable import UserDefaultsClient -@MainActor class GameOverFeatureTests: XCTestCase { - let mainRunLoop = RunLoop.test - + @MainActor func testSubmitLeaderboardScore() async throws { await withMainSerialExecutor { let store = TestStore( @@ -79,6 +78,7 @@ class GameOverFeatureTests: XCTestCase { } } + @MainActor func testSubmitDailyChallenge() async { await withMainSerialExecutor { let dailyChallengeResponses = [ @@ -184,6 +184,7 @@ class GameOverFeatureTests: XCTestCase { } } + @MainActor func testTurnBased_TrackLeaderboards() async { await withMainSerialExecutor { let store = TestStore( @@ -238,7 +239,9 @@ class GameOverFeatureTests: XCTestCase { } } + @MainActor func testRequestReviewOnClose() async { + let mainRunLoop = RunLoop.test let lastReviewRequestTimeIntervalSet = ActorIsolated(nil) let requestReviewCount = ActorIsolated(0) @@ -272,7 +275,7 @@ class GameOverFeatureTests: XCTestCase { wordsFound: 1 ) } - $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() + $0.mainRunLoop = mainRunLoop.eraseToAnyScheduler() $0.storeKit.requestReview = { await requestReviewCount.withValue { $0 += 1 } } @@ -282,12 +285,12 @@ class GameOverFeatureTests: XCTestCase { await lastReviewRequestTimeIntervalSet.setValue(double) } } - $0.dismiss = DismissEffect {} + $0.dismissGame = DismissEffect {} } // Assert that the first time game over appears we do not request review await store.send(.closeButtonTapped) - await self.mainRunLoop.advance() + await mainRunLoop.advance() await requestReviewCount.withValue { XCTAssertNoDifference($0, 0) } await lastReviewRequestTimeIntervalSet.withValue { XCTAssertNoDifference($0, nil) } @@ -307,12 +310,13 @@ class GameOverFeatureTests: XCTestCase { await lastReviewRequestTimeIntervalSet.withValue { XCTAssertNoDifference($0, 0) } // Assert that when more than a week of time passes we again request review - await self.mainRunLoop.advance(by: .seconds(60 * 60 * 24 * 7)) + await mainRunLoop.advance(by: .seconds(60 * 60 * 24 * 7)) await store.send(.closeButtonTapped).finish() await requestReviewCount.withValue { XCTAssertNoDifference($0, 2) } await lastReviewRequestTimeIntervalSet.withValue { XCTAssertNoDifference($0, 60 * 60 * 24 * 7) } } + @MainActor func testAutoCloseWhenNoWordsPlayed() async throws { let store = TestStore( initialState: GameOver.State( @@ -330,13 +334,15 @@ class GameOverFeatureTests: XCTestCase { ) { GameOver() } withDependencies: { - $0.dismiss = DismissEffect {} + $0.dismissGame = DismissEffect {} } await store.send(.task) } + @MainActor func testShowUpgradeInterstitial() async { + let mainRunLoop = RunLoop.test let store = TestStore( initialState: GameOver.State( completedGame: .init( @@ -357,7 +363,7 @@ class GameOverFeatureTests: XCTestCase { $0.apiClient.currentPlayer = { .init(appleReceipt: nil, player: .blob) } $0.apiClient.apiRequest = { @Sendable _ in try await Task.never() } $0.database.playedGamesCount = { _ in 6 } - $0.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() + $0.mainRunLoop = mainRunLoop.eraseToAnyScheduler() $0.serverConfig.config = { .init() } $0.userNotifications.getNotificationSettings = { (try? await Task.never()) ?? .init(authorizationStatus: .notDetermined) @@ -365,15 +371,16 @@ class GameOverFeatureTests: XCTestCase { } let task = await store.send(.task) - await self.mainRunLoop.advance(by: .seconds(1)) + await mainRunLoop.advance(by: .seconds(1)) await store.receive(\.delayedShowUpgradeInterstitial) { - $0.destination = .upgradeInterstitial() + $0.destination = .upgradeInterstitial(UpgradeInterstitial.State()) } - await self.mainRunLoop.advance(by: .seconds(1)) + await mainRunLoop.advance(by: .seconds(1)) await store.receive(\.delayedOnAppear) { $0.isViewEnabled = true } await task.cancel() } + @MainActor func testSkipUpgradeIfLessThan6GamesPlayed() async { let store = TestStore( initialState: GameOver.State( diff --git a/Tests/LeaderboardFeatureTests/LeaderboardFeatureIntegrationTests.swift b/Tests/LeaderboardFeatureTests/LeaderboardFeatureIntegrationTests.swift index 8d4b2038..d1ec8ae0 100644 --- a/Tests/LeaderboardFeatureTests/LeaderboardFeatureIntegrationTests.swift +++ b/Tests/LeaderboardFeatureTests/LeaderboardFeatureIntegrationTests.swift @@ -9,8 +9,8 @@ import XCTest @testable import LeaderboardFeature -@MainActor class LeaderboardFeatureIntegrationTests: XCTestCase { + @MainActor func testSoloIntegrationWithLeaderboardResults() async { await withMainSerialExecutor { let fetchLeaderboardsEntries = [ @@ -59,6 +59,7 @@ class LeaderboardFeatureIntegrationTests: XCTestCase { } } + @MainActor func testVocabIntegrationWithLeaderboardResults() async { let fetchVocabEntries = [ FetchVocabLeaderboardResponse.Entry.init( diff --git a/Tests/LeaderboardFeatureTests/LeaderboardFeatureTests.swift b/Tests/LeaderboardFeatureTests/LeaderboardFeatureTests.swift index 5883ef6b..72dd966a 100644 --- a/Tests/LeaderboardFeatureTests/LeaderboardFeatureTests.swift +++ b/Tests/LeaderboardFeatureTests/LeaderboardFeatureTests.swift @@ -9,8 +9,8 @@ import XCTest @testable import LeaderboardFeature @testable import SharedModels -@MainActor class LeaderboardFeatureTests: XCTestCase { + @MainActor func testScopeSwitcher() async { let store = TestStore(initialState: Leaderboard.State()) { Leaderboard() @@ -24,6 +24,7 @@ class LeaderboardFeatureTests: XCTestCase { } } + @MainActor func testTimeScopeSynchronization() async { let store = TestStore(initialState: Leaderboard.State()) { Leaderboard() @@ -49,6 +50,7 @@ class LeaderboardFeatureTests: XCTestCase { await task2.cancel() } + @MainActor func testCubePreview() async { let wordId = Word.Id(rawValue: UUID(uuidString: "00000000-0000-0000-0000-00000000304d")!) let vocabEntry = FetchVocabLeaderboardResponse.Entry( @@ -104,7 +106,6 @@ class LeaderboardFeatureTests: XCTestCase { $0.mainQueue = .immediate } - await store.send(.vocab(.task)) { $0.vocab.isLoading = true $0.vocab.resultEnvelope = .placeholder diff --git a/Tests/LeaderboardFeatureTests/LeaderboardResultsTests.swift b/Tests/LeaderboardFeatureTests/LeaderboardResultsTests.swift index 803ed6b0..1e225d18 100644 --- a/Tests/LeaderboardFeatureTests/LeaderboardResultsTests.swift +++ b/Tests/LeaderboardFeatureTests/LeaderboardResultsTests.swift @@ -9,8 +9,8 @@ import XCTest @testable import LeaderboardFeature -@MainActor class LeaderboardTests: XCTestCase { + @MainActor func testOnAppear() async { let store = TestStore( initialState: LeaderboardResults.State(timeScope: TimeScope.lastWeek) @@ -28,6 +28,7 @@ class LeaderboardTests: XCTestCase { } } + @MainActor func testChangeGameMode() async { let store = TestStore( initialState: LeaderboardResults.State(timeScope: TimeScope.lastWeek) @@ -45,6 +46,7 @@ class LeaderboardTests: XCTestCase { } } + @MainActor func testChangeTimeScope() async { let store = TestStore( initialState: LeaderboardResults.State(timeScope: TimeScope.lastWeek) @@ -66,6 +68,7 @@ class LeaderboardTests: XCTestCase { } } + @MainActor func testUnhappyPath() async { let store = TestStore( initialState: LeaderboardResults.State(timeScope: .lastWeek) diff --git a/Tests/MultiplayerFeatureTests/MultiplayerFeatureTests.swift b/Tests/MultiplayerFeatureTests/MultiplayerFeatureTests.swift index 1035ef8b..70211771 100644 --- a/Tests/MultiplayerFeatureTests/MultiplayerFeatureTests.swift +++ b/Tests/MultiplayerFeatureTests/MultiplayerFeatureTests.swift @@ -3,8 +3,8 @@ import XCTest @testable import MultiplayerFeature -@MainActor class MultiplayerFeatureTests: XCTestCase { + @MainActor func testStartGame_GameCenterAuthenticated() async { let didPresentMatchmakerViewController = ActorIsolated(false) let store = TestStore(initialState: Multiplayer.State(hasPastGames: false)) { @@ -20,6 +20,7 @@ class MultiplayerFeatureTests: XCTestCase { await didPresentMatchmakerViewController.withValue { XCTAssertTrue($0) } } + @MainActor func testStartGame_GameCenterNotAuthenticated() async { let didPresentAuthentication = ActorIsolated(false) let store = TestStore( @@ -37,6 +38,7 @@ class MultiplayerFeatureTests: XCTestCase { await didPresentAuthentication.withValue { XCTAssertTrue($0) } } + @MainActor func testNavigateToPastGames() async { let store = TestStore( initialState: Multiplayer.State(hasPastGames: true) diff --git a/Tests/MultiplayerFeatureTests/PastGamesTests.swift b/Tests/MultiplayerFeatureTests/PastGamesTests.swift index 2f1d8d7d..55261d4c 100644 --- a/Tests/MultiplayerFeatureTests/PastGamesTests.swift +++ b/Tests/MultiplayerFeatureTests/PastGamesTests.swift @@ -8,8 +8,8 @@ import XCTest @testable import MultiplayerFeature -@MainActor class PastGamesTests: XCTestCase { + @MainActor func testLoadMatches() async { let store = TestStore(initialState: PastGames.State()) { PastGames() @@ -24,6 +24,7 @@ class PastGamesTests: XCTestCase { } } + @MainActor func testOpenMatch() async { let store = TestStore(initialState: PastGames.State(pastGames: [pastGameState])) { PastGames() @@ -36,6 +37,7 @@ class PastGamesTests: XCTestCase { await store.receive(\.pastGames[id: "id"].delegate.openMatch) } + @MainActor func testRematch() async { let store = TestStore(initialState: PastGames.State(pastGames: [pastGameState])) { PastGames() @@ -58,6 +60,7 @@ class PastGamesTests: XCTestCase { await store.receive(\.pastGames[id: "id"].delegate.openMatch) } + @MainActor func testRematch_Failure() async { struct RematchFailure: Error, Equatable {} diff --git a/Tests/OnboardingFeatureTests/OnboardingFeatureTests.swift b/Tests/OnboardingFeatureTests/OnboardingFeatureTests.swift index 4a67f956..fab0ceb7 100644 --- a/Tests/OnboardingFeatureTests/OnboardingFeatureTests.swift +++ b/Tests/OnboardingFeatureTests/OnboardingFeatureTests.swift @@ -10,10 +10,10 @@ extension DateGenerator { } } -@MainActor class OnboardingFeatureTests: XCTestCase { let mainQueue = DispatchQueue.test + @MainActor func testBasics_FirstLaunch() async { let isFirstLaunchOnboardingKeySet = ActorIsolated(false) @@ -308,6 +308,7 @@ class OnboardingFeatureTests: XCTestCase { await isFirstLaunchOnboardingKeySet.withValue { XCTAssert($0) } } + @MainActor func testSkip_HasSeenOnboardingBefore() async { let isFirstLaunchOnboardingKeySet = ActorIsolated(false) @@ -342,6 +343,7 @@ class OnboardingFeatureTests: XCTestCase { await isFirstLaunchOnboardingKeySet.withValue { XCTAssert($0) } } + @MainActor func testSkip_HasNotSeenOnboardingBefore() async { let isFirstLaunchOnboardingKeySet = ActorIsolated(false) diff --git a/Tests/SettingsFeatureTests/SettingsFeatureTests.swift b/Tests/SettingsFeatureTests/SettingsFeatureTests.swift index d4411232..2a14e3f3 100644 --- a/Tests/SettingsFeatureTests/SettingsFeatureTests.swift +++ b/Tests/SettingsFeatureTests/SettingsFeatureTests.swift @@ -28,8 +28,8 @@ extension DependencyValues { } } -@MainActor class SettingsFeatureTests: XCTestCase { + @MainActor func testUserSettingsBackwardsDecodability() { XCTAssertNoDifference( try JSONDecoder().decode(UserSettings.self, from: Data("{}".utf8)), @@ -61,7 +61,7 @@ class SettingsFeatureTests: XCTestCase { // MARK: - Notifications - // TODO: Fix once we have the TestStore binding test helper + @MainActor func testEnableNotifications_NotDetermined_GrantAuthorization() async { let didRegisterForRemoteNotifications = ActorIsolated(false) @@ -97,7 +97,7 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.enableNotifications = true - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.enableNotifications = true } @@ -108,6 +108,7 @@ class SettingsFeatureTests: XCTestCase { await task.cancel() } + @MainActor func testEnableNotifications_NotDetermined_DenyAuthorization() async { let store = TestStore( initialState: Settings.State() @@ -138,7 +139,7 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.enableNotifications = true - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.enableNotifications = true } @@ -149,6 +150,7 @@ class SettingsFeatureTests: XCTestCase { await task.cancel() } + @MainActor func testNotifications_PreviouslyGranted() async { let store = TestStore( initialState: Settings.State() @@ -179,13 +181,14 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.enableNotifications = false - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.enableNotifications = false } await task.cancel() } + @MainActor func testNotifications_PreviouslyDenied() async { let openedUrl = ActorIsolated(nil) let store = TestStore( @@ -223,7 +226,7 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.enableNotifications = true - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.alert = .userNotificationAuthorizationDenied } @@ -238,6 +241,7 @@ class SettingsFeatureTests: XCTestCase { await task.cancel() } + @MainActor func testNotifications_RemoteSettingsUpdates() async { var userSettings = UserSettings(sendDailyChallengeReminder: false) let didUpdate = LockIsolated(false) @@ -288,7 +292,7 @@ class SettingsFeatureTests: XCTestCase { } userSettings.sendDailyChallengeReminder = false - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.enableNotifications = false $0.userSettings.sendDailyChallengeReminder = false } @@ -300,6 +304,7 @@ class SettingsFeatureTests: XCTestCase { // MARK: - Sounds + @MainActor func testSetMusicVolume() async { let setMusicVolume = ActorIsolated(nil) let store = TestStore( @@ -313,13 +318,14 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.musicVolume = 0.5 - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.musicVolume = 0.5 } await setMusicVolume.withValue { XCTAssertNoDifference($0, 0.5) } } + @MainActor func testSetSoundEffectsVolume() async { let setSoundEffectsVolume = ActorIsolated(nil) let store = TestStore( @@ -335,7 +341,7 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.soundEffectsVolume = 0.5 - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.soundEffectsVolume = 0.5 } @@ -344,6 +350,7 @@ class SettingsFeatureTests: XCTestCase { // MARK: - Appearance + @MainActor func testSetColorScheme() async { let overriddenUserInterfaceStyle = ActorIsolated(nil) let store = TestStore( @@ -359,18 +366,19 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.colorScheme = .light - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.colorScheme = .light } await overriddenUserInterfaceStyle.withValue { XCTAssertNoDifference($0, .light) } userSettings.colorScheme = .system - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.colorScheme = .system } await overriddenUserInterfaceStyle.withValue { XCTAssertNoDifference($0, .unspecified) } } + @MainActor func testSetAppIcon() async { let overriddenIconName = ActorIsolated(nil) let store = TestStore( @@ -386,12 +394,13 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.appIcon = .icon2 - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.appIcon = .icon2 } await overriddenIconName.withValue { XCTAssertNoDifference($0, "icon-2") } } + @MainActor func testUnsetAppIcon() async { let overriddenIconName = ActorIsolated(nil) let store = TestStore( @@ -421,7 +430,7 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.appIcon = nil - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.appIcon = nil } await overriddenIconName.withValue { XCTAssertNil($0) } @@ -431,6 +440,7 @@ class SettingsFeatureTests: XCTestCase { // MARK: - Developer + @MainActor func testSetApiBaseUrl() async { let setBaseUrl = ActorIsolated(nil) let didLogout = ActorIsolated(false) @@ -446,13 +456,14 @@ class SettingsFeatureTests: XCTestCase { var developer = store.state.developer developer.currentBaseUrl = .localhost - await store.send(.set(\.$developer, developer)) { + await store.send(.set(\.developer, developer)) { $0.developer.currentBaseUrl = .localhost } await setBaseUrl.withValue { XCTAssertNoDifference($0, URL(string: "http://localhost:9876")!) } await didLogout.withValue { XCTAssert($0) } } + @MainActor func testToggleEnableGyroMotion() async { let store = TestStore( initialState: Settings.State() @@ -465,15 +476,16 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.enableGyroMotion = false - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.enableGyroMotion = false } userSettings.enableGyroMotion = true - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.enableGyroMotion = true } } + @MainActor func testToggleEnableHaptics() async { let store = TestStore( initialState: Settings.State() @@ -487,11 +499,11 @@ class SettingsFeatureTests: XCTestCase { var userSettings = store.state.userSettings userSettings.enableHaptics = false - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.enableHaptics = false } userSettings.enableHaptics = true - await store.send(.set(\.$userSettings, userSettings)) { + await store.send(.set(\.userSettings, userSettings)) { $0.userSettings.enableHaptics = true } } diff --git a/Tests/SettingsFeatureTests/SettingsPurchaseTests.swift b/Tests/SettingsFeatureTests/SettingsPurchaseTests.swift index a0dcf3e6..fd31f30a 100644 --- a/Tests/SettingsFeatureTests/SettingsPurchaseTests.swift +++ b/Tests/SettingsFeatureTests/SettingsPurchaseTests.swift @@ -22,8 +22,8 @@ fileprivate extension DependencyValues { } } -@MainActor class SettingsPurchaseTests: XCTestCase { + @MainActor func testUpgrade_HappyPath() async throws { let didAddPaymentProductIdentifier = ActorIsolated(nil) let storeKitObserver = AsyncStream @@ -41,7 +41,8 @@ class SettingsPurchaseTests: XCTestCase { $0.apiClient.currentPlayer = { .some(.blobWithoutPurchase) } $0.apiClient.refreshCurrentPlayer = { .blobWithPurchase } $0.storeKit.addPayment = { - await didAddPaymentProductIdentifier.setValue($0.productIdentifier) + let productIdentifier = $0.productIdentifier + await didAddPaymentProductIdentifier.setValue(productIdentifier) } $0.storeKit.fetchProducts = { _ in .init(invalidProductIdentifiers: [], products: [.fullGame]) @@ -77,6 +78,7 @@ class SettingsPurchaseTests: XCTestCase { await task.cancel() } + @MainActor func testRestore_HappyPath() async throws { let didRestoreCompletedTransactions = ActorIsolated(false) let storeKitObserver = AsyncStream @@ -129,6 +131,7 @@ class SettingsPurchaseTests: XCTestCase { await task.cancel() } + @MainActor func testRestore_NoPurchasesPath() async throws { let didRestoreCompletedTransactions = ActorIsolated(false) let storeKitObserver = AsyncStream @@ -174,6 +177,7 @@ class SettingsPurchaseTests: XCTestCase { await task.cancel() } + @MainActor func testRestore_ErrorPath() async throws { let didRestoreCompletedTransactions = ActorIsolated(false) let storeKitObserver = AsyncStream diff --git a/Tests/UpgradeInterstitialFeatureTests/UpgradeInterstitialFeatureTests.swift b/Tests/UpgradeInterstitialFeatureTests/UpgradeInterstitialFeatureTests.swift index d69257ac..63a83f9a 100644 --- a/Tests/UpgradeInterstitialFeatureTests/UpgradeInterstitialFeatureTests.swift +++ b/Tests/UpgradeInterstitialFeatureTests/UpgradeInterstitialFeatureTests.swift @@ -10,10 +10,10 @@ import XCTest @testable import ServerConfigClient -@MainActor class UpgradeInterstitialFeatureTests: XCTestCase { let scheduler = RunLoop.test + @MainActor func testUpgrade() async { await withMainSerialExecutor { let dismissed = LockIsolated(false) @@ -47,7 +47,10 @@ class UpgradeInterstitialFeatureTests: XCTestCase { $0.dismiss = .init { dismissed.setValue(true) } $0.mainRunLoop = .immediate $0.serverConfig.config = { .init() } - $0.storeKit.addPayment = { await paymentAdded.setValue($0.productIdentifier) } + $0.storeKit.addPayment = { + let productIdentifier = $0.productIdentifier + await paymentAdded.setValue(productIdentifier) + } $0.storeKit.observer = { observer.stream } $0.storeKit.fetchProducts = { _ in .init( @@ -84,6 +87,7 @@ class UpgradeInterstitialFeatureTests: XCTestCase { } } + @MainActor func testWaitAndDismiss() async { let dismissed = LockIsolated(false) let store = TestStore( @@ -122,6 +126,7 @@ class UpgradeInterstitialFeatureTests: XCTestCase { XCTAssert(dismissed.value) } + @MainActor func testMaybeLater_Dismissable() async { let dismissed = LockIsolated(false) let store = TestStore(