diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 61e878cc..bbd4e9be 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,8 +17,8 @@ jobs:
run: git lfs pull
- name: Start Postgres
run: brew services start postgresql
- - name: Select Xcode 13.3
- run: sudo xcode-select -s /Applications/Xcode_13.3.app
+ - name: Select Xcode 13.4.1
+ run: sudo xcode-select -s /Applications/Xcode_13.4.1.app
- name: Bootstrap
run: make bootstrap
- name: Run tests
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposableGameCenter.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableGameCenter.xcscheme
new file mode 100644
index 00000000..e2bcfe6d
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableGameCenter.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ComposableStoreKit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableStoreKit.xcscheme
new file mode 100644
index 00000000..bfa40735
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/ComposableStoreKit.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CubePreviewPreview.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CubePreviewPreview.xcscheme
index 98736cd7..2d30adf8 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/CubePreviewPreview.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/CubePreviewPreview.xcscheme
@@ -28,6 +28,16 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/TrailerPreview.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/TrailerPreview.xcscheme
index 17c24f0e..b8014582 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/TrailerPreview.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/TrailerPreview.xcscheme
@@ -31,7 +31,7 @@
Self {
.live(
fetch: {
- apiClient.apiRequest(route: .config(build: build.number()), as: ServerConfig.self)
- .mapError { $0 as Error }
- .eraseToEffect()
+ try await apiClient
+ .apiRequest(route: .config(build: build.number()), as: ServerConfig.self)
}
)
}
diff --git a/App/isowords.xcodeproj/project.pbxproj b/App/isowords.xcodeproj/project.pbxproj
index 4285b9bc..a4d7db16 100644
--- a/App/isowords.xcodeproj/project.pbxproj
+++ b/App/isowords.xcodeproj/project.pbxproj
@@ -1335,7 +1335,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
@@ -1353,6 +1353,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = iOS/iOS.entitlements;
CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = iOS/Info.plist;
@@ -1441,6 +1442,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = AppClip/AppClip.entitlements;
CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
@@ -1463,6 +1465,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = AppClip/AppClip.entitlements;
CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
@@ -1486,6 +1489,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = AppClip/AppClip.entitlements;
CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
@@ -1707,7 +1711,7 @@
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = Previews/LeaderboardsPreview/Info.plist;
- IPHONEOS_DEPLOYMENT_TARGET = 14.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1729,7 +1733,7 @@
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = Previews/LeaderboardsPreview/Info.plist;
- IPHONEOS_DEPLOYMENT_TARGET = 14.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1752,7 +1756,7 @@
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = Previews/LeaderboardsPreview/Info.plist;
- IPHONEOS_DEPLOYMENT_TARGET = 14.4;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1818,7 +1822,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@@ -1874,7 +1878,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 14.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
@@ -1891,6 +1895,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = iOS/iOS.entitlements;
CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = iOS/Info.plist;
@@ -1915,6 +1920,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = iOS/iOS.entitlements;
CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = VFRXY8HC3H;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = iOS/Info.plist;
diff --git a/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 44f296e0..cc5fd627 100644
--- a/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/App/isowords.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -24,8 +24,8 @@
"repositoryURL": "https://github.com/pointfreeco/combine-schedulers",
"state": {
"branch": null,
- "revision": "0ba3a562716efabb585ccb169450c5389107286b",
- "version": "0.6.0"
+ "revision": "f7c8277f05f27a5bfb2f6ecccb0bad126ffcf472",
+ "version": "0.7.0"
}
},
{
@@ -51,8 +51,8 @@
"repositoryURL": "https://github.com/vapor/sql-kit.git",
"state": {
"branch": null,
- "revision": "89b0a0a5f110e77272fb5a775064a31bfc1f155c",
- "version": "3.18.0"
+ "revision": "b7901df0611d3d7e869fa345020b3d0ae817c32d",
+ "version": "3.19.1"
}
},
{
@@ -87,8 +87,8 @@
"repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture",
"state": {
"branch": null,
- "revision": "0a38f2c860a64a005afeb8a6fe186eb2818c9a3f",
- "version": "0.38.3"
+ "revision": "108e3a536fcebb16c4f247ef92c2d7326baf9fe3",
+ "version": "0.39.0"
}
},
{
@@ -150,8 +150,8 @@
"repositoryURL": "https://github.com/apple/swift-metrics.git",
"state": {
"branch": null,
- "revision": "1c1408bf8fc21be93713e897d2badf500ea38419",
- "version": "2.3.1"
+ "revision": "53be78637ecd165d1ddedc4e20de69b8f43ec3b7",
+ "version": "2.3.2"
}
},
{
diff --git a/App/isowords.xcodeproj/xcshareddata/xcschemes/isowords.xcscheme b/App/isowords.xcodeproj/xcshareddata/xcschemes/isowords.xcscheme
index e0125558..b7ac91d7 100644
--- a/App/isowords.xcodeproj/xcshareddata/xcschemes/isowords.xcscheme
+++ b/App/isowords.xcodeproj/xcshareddata/xcschemes/isowords.xcscheme
@@ -290,6 +290,66 @@
ReferencedContainer = "container:..">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(
- as type: A.Type,
- file: StaticString = #file,
- line: UInt = #line
- ) -> Effect {
- self
- .mapError { ApiError(error: $0, file: file, line: line) }
- .flatMap { data -> AnyPublisher in
- do {
- return try Just(jsonDecoder.decode(A.self, from: data))
- .setFailureType(to: ApiError.self)
- .eraseToAnyPublisher()
- } catch let decodingError {
- do {
- return try Fail(
- error: jsonDecoder.decode(ApiError.self, from: data)
- ).eraseToAnyPublisher()
- } catch {
- return Fail(error: ApiError(error: decodingError)).eraseToAnyPublisher()
- }
- }
- }
- .eraseToEffect()
+public func apiDecode(_ type: A.Type, from data: Data) throws -> A {
+ do {
+ return try jsonDecoder.decode(A.self, from: data)
+ } catch let decodingError {
+ let apiError: Error
+ do {
+ apiError = try jsonDecoder.decode(ApiError.self, from: data)
+ } catch {
+ throw decodingError
+ }
+ throw apiError
}
}
diff --git a/Sources/ApiClient/Client.swift b/Sources/ApiClient/Client.swift
index f51fe504..b1042718 100644
--- a/Sources/ApiClient/Client.swift
+++ b/Sources/ApiClient/Client.swift
@@ -4,30 +4,26 @@ import Foundation
import SharedModels
public struct ApiClient {
- public var apiRequest:
- (ServerRoute.Api.Route) -> Effect<(data: Data, response: URLResponse), URLError>
+ public var apiRequest: @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse)
public var authenticate:
- (ServerRoute.AuthenticateRequest) -> Effect
- public var baseUrl: () -> URL
- public var currentPlayer: () -> CurrentPlayerEnvelope?
- public var logout: () -> Effect
- public var refreshCurrentPlayer: () -> Effect
- public var request: (ServerRoute) -> Effect<(data: Data, response: URLResponse), URLError>
- public var setBaseUrl: (URL) -> Effect
+ @Sendable (ServerRoute.AuthenticateRequest) async throws -> CurrentPlayerEnvelope
+ public var baseUrl: @Sendable () -> URL
+ public var currentPlayer: @Sendable() -> CurrentPlayerEnvelope?
+ public var logout: @Sendable () async -> Void
+ public var refreshCurrentPlayer: @Sendable () async throws -> CurrentPlayerEnvelope
+ public var request: @Sendable (ServerRoute) async throws -> (Data, URLResponse)
+ public var setBaseUrl: @Sendable (URL) async -> Void
public init(
- apiRequest: @escaping (ServerRoute.Api.Route) -> Effect<
- (data: Data, response: URLResponse), URLError
- >,
- authenticate: @escaping (ServerRoute.AuthenticateRequest) -> Effect<
- CurrentPlayerEnvelope, ApiError
- >,
- baseUrl: @escaping () -> URL,
- currentPlayer: @escaping () -> CurrentPlayerEnvelope?,
- logout: @escaping () -> Effect,
- refreshCurrentPlayer: @escaping () -> Effect,
- request: @escaping (ServerRoute) -> Effect<(data: Data, response: URLResponse), URLError>,
- setBaseUrl: @escaping (URL) -> Effect
+ apiRequest: @escaping @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse),
+ authenticate: @escaping @Sendable (ServerRoute.AuthenticateRequest) async throws ->
+ CurrentPlayerEnvelope,
+ baseUrl: @escaping @Sendable () -> URL,
+ currentPlayer: @escaping @Sendable () -> CurrentPlayerEnvelope?,
+ logout: @escaping @Sendable () async -> Void,
+ refreshCurrentPlayer: @escaping @Sendable () async throws -> CurrentPlayerEnvelope,
+ request: @escaping @Sendable (ServerRoute) async throws -> (Data, URLResponse),
+ setBaseUrl: @escaping @Sendable (URL) async -> Void
) {
self.apiRequest = apiRequest
self.authenticate = authenticate
@@ -45,8 +41,22 @@ public struct ApiClient {
route: ServerRoute.Api.Route,
file: StaticString = #file,
line: UInt = #line
- ) -> Effect {
- self.apiRequest(route: route, as: Unit.self, file: file, line: line)
+ ) async throws -> (Data, URLResponse) {
+ do {
+ let (data, response) = try await self.apiRequest(route)
+ #if DEBUG
+ print(
+ """
+ API: route: \(route), \
+ status: \((response as? HTTPURLResponse)?.statusCode ?? 0), \
+ receive data: \(String(decoding: data, as: UTF8.self))
+ """
+ )
+ #endif
+ return (data, response)
+ } catch {
+ throw ApiError(error: error, file: file, line: line)
+ }
}
public func apiRequest(
@@ -54,25 +64,35 @@ public struct ApiClient {
as: A.Type,
file: StaticString = #file,
line: UInt = #line
- ) -> Effect {
- self.apiRequest(route)
- .handleEvents(
- receiveOutput: {
- #if DEBUG
- print(
- """
- API: route: \(route), \
- status: \(($0.response as? HTTPURLResponse)?.statusCode ?? 0), \
- receive data: \(String(decoding: $0.data, as: UTF8.self))
- """
- )
- #endif
- }
- )
- .map { data, _ in data }
- .apiDecode(as: A.self, file: file, line: line)
- .print("API")
- .eraseToEffect()
+ ) async throws -> A {
+ let (data, _) = try await self.apiRequest(route: route, file: file, line: line)
+ do {
+ return try apiDecode(A.self, from: data)
+ } catch {
+ throw ApiError(error: error, file: file, line: line)
+ }
+ }
+
+ public func request(
+ route: ServerRoute,
+ file: StaticString = #file,
+ line: UInt = #line
+ ) async throws -> (Data, URLResponse) {
+ do {
+ let (data, response) = try await self.request(route)
+ #if DEBUG
+ print(
+ """
+ API: route: \(route), \
+ status: \((response as? HTTPURLResponse)?.statusCode ?? 0), \
+ receive data: \(String(decoding: data, as: UTF8.self))
+ """
+ )
+ #endif
+ return (data, response)
+ } catch {
+ throw ApiError(error: error, file: file, line: line)
+ }
}
public func request(
@@ -80,25 +100,13 @@ public struct ApiClient {
as: A.Type,
file: StaticString = #file,
line: UInt = #line
- ) -> Effect {
- self.request(route)
- .handleEvents(
- receiveOutput: {
- #if DEBUG
- print(
- """
- API: route: \(route), \
- status: \(($0.response as? HTTPURLResponse)?.statusCode ?? 0), \
- receive data: \(String(decoding: $0.data, as: UTF8.self))
- """
- )
- #endif
- }
- )
- .map { data, _ in data }
- .apiDecode(as: A.self, file: file, line: line)
- .print("API")
- .eraseToEffect()
+ ) async throws -> A {
+ let (data, _) = try await self.request(route: route, file: file, line: line)
+ do {
+ return try apiDecode(A.self, from: data)
+ } catch {
+ throw ApiError(error: error, file: file, line: line)
+ }
}
public struct LeaderboardEnvelope: Codable, Equatable {
@@ -119,51 +127,43 @@ public struct ApiClient {
import XCTestDynamicOverlay
extension ApiClient {
- public static let failing = Self(
- apiRequest: { route in .failing("\(Self.self).apiRequest(\(route)) is unimplemented") },
- authenticate: { _ in .failing("\(Self.self).authenticate is unimplemented") },
- baseUrl: {
- XCTFail("\(Self.self).baseUrl is unimplemented")
- return URL(string: "/")!
- },
- currentPlayer: {
- XCTFail("\(Self.self).currentPlayer is unimplemented")
- return nil
- },
- logout: { .failing("\(Self.self).logout is unimplemented") },
- refreshCurrentPlayer: { .failing("\(Self.self).refreshCurrentPlayer is unimplemented") },
- request: { route in .failing("\(Self.self).request(\(route)) is unimplemented") },
- setBaseUrl: { _ in .failing("ApiClient.setBaseUrl is unimplemented") }
+ public static let unimplemented = Self(
+ apiRequest: XCTUnimplemented("\(Self.self).apiRequest"),
+ authenticate: XCTUnimplemented("\(Self.self).authenticate"),
+ baseUrl: XCTUnimplemented("\(Self.self).baseUrl", placeholder: URL(string: "/")!),
+ currentPlayer: XCTUnimplemented("\(Self.self).currentPlayer"),
+ logout: XCTUnimplemented("\(Self.self).logout"),
+ refreshCurrentPlayer: XCTUnimplemented("\(Self.self).refreshCurrentPlayer"),
+ request: XCTUnimplemented("\(Self.self).request"),
+ setBaseUrl: XCTUnimplemented("\(Self.self).setBaseUrl")
)
public mutating func override(
route matchingRoute: ServerRoute.Api.Route,
- withResponse response: Effect<(data: Data, response: URLResponse), URLError>
+ withResponse response: @escaping @Sendable () async throws -> (Data, URLResponse)
) {
let fulfill = expectation(description: "route")
- self.apiRequest = { [self] route in
+ self.apiRequest = { @Sendable [self] route in
if route == matchingRoute {
fulfill()
- return response
+ return try await response()
} else {
- return self.apiRequest(route)
+ return try await self.apiRequest(route)
}
}
}
public mutating func override(
routeCase matchingRoute: CasePath,
- withResponse response: @escaping (Value) -> Effect<
- (data: Data, response: URLResponse), URLError
- >
+ withResponse response: @escaping @Sendable (Value) async throws -> (Data, URLResponse)
) {
let fulfill = expectation(description: "route")
- self.apiRequest = { [self] route in
+ self.apiRequest = { @Sendable [self] route in
if let value = matchingRoute.extract(from: route) {
fulfill()
- return response(value)
+ return try await response(value)
} else {
- return self.apiRequest(route)
+ return try await self.apiRequest(route)
}
}
}
@@ -172,14 +172,14 @@ public struct ApiClient {
extension ApiClient {
public static let noop = Self(
- apiRequest: { _ in .none },
- authenticate: { _ in .none },
+ apiRequest: { _ in try await Task.never() },
+ authenticate: { _ in try await Task.never() },
baseUrl: { URL(string: "/")! },
currentPlayer: { nil },
- logout: { .none },
- refreshCurrentPlayer: { .none },
- request: { _ in .none },
- setBaseUrl: { _ in .none }
+ logout: {},
+ refreshCurrentPlayer: { try await Task.never() },
+ request: { _ in try await Task.never() },
+ setBaseUrl: { _ in }
)
}
diff --git a/Sources/ApiClient/Helpers.swift b/Sources/ApiClient/Helpers.swift
index 5c96c0c3..cb4442cc 100644
--- a/Sources/ApiClient/Helpers.swift
+++ b/Sources/ApiClient/Helpers.swift
@@ -2,30 +2,21 @@
import ComposableArchitecture
import Foundation
- extension Effect where Output == (data: Data, response: URLResponse), Failure == URLError {
- public static func ok(
- _ value: A,
- encoder: JSONEncoder = .init()
- ) -> Self {
- .init(
- value: (
- try! encoder.encode(value),
- HTTPURLResponse(
- url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
- )
- )
- }
+ public func OK(
+ _ value: A, encoder: JSONEncoder = .init()
+ ) async throws -> (Data, URLResponse) {
+ (
+ try encoder.encode(value),
+ HTTPURLResponse(
+ url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ )
+ }
- public static func ok(
- _ jsonObject: Any
- ) -> Self {
- .init(
- value: (
- try! JSONSerialization.data(withJSONObject: jsonObject, options: []),
- HTTPURLResponse(
- url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
- )
- )
- }
+ public func OK(_ jsonObject: Any) async throws -> (Data, URLResponse) {
+ (
+ try JSONSerialization.data(withJSONObject: jsonObject, options: []),
+ HTTPURLResponse(
+ url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
+ )
}
#endif
diff --git a/Sources/ApiClientLive/Live.swift b/Sources/ApiClientLive/Live.swift
index dc44b74b..79d33601 100644
--- a/Sources/ApiClientLive/Live.swift
+++ b/Sources/ApiClientLive/Live.swift
@@ -4,23 +4,21 @@ import ComposableArchitecture
import Foundation
import ServerRouter
import SharedModels
+import TcaHelpers
-extension ApiClient {
- private static let baseUrlKey = "co.pointfree.isowords.apiClient.baseUrl"
- private static let currentUserEnvelopeKey = "co.pointfree.isowords.apiClient.currentUserEnvelope"
+private let baseUrlKey = "co.pointfree.isowords.apiClient.baseUrl"
+private let currentUserEnvelopeKey = "co.pointfree.isowords.apiClient.currentUserEnvelope"
+extension ApiClient {
public static func live(
baseUrl defaultBaseUrl: URL = URL(string: "http://localhost:9876")!,
sha256: @escaping (Data) -> Data
) -> Self {
+
#if DEBUG
- var baseUrl = UserDefaults.standard.url(forKey: baseUrlKey) ?? defaultBaseUrl {
- didSet {
- UserDefaults.standard.set(baseUrl, forKey: baseUrlKey)
- }
- }
+ let baseUrl = UserDefaults.standard.url(forKey: baseUrlKey) ?? defaultBaseUrl
#else
- var baseUrl = URL(string: "https://www.isowords.xyz")!
+ let baseUrl = URL(string: "https://www.isowords.xyz")!
#endif
let router = ServerRouter(
@@ -31,29 +29,45 @@ extension ApiClient {
sha256: sha256
)
- var currentPlayer = UserDefaults.standard.data(forKey: currentUserEnvelopeKey)
- .flatMap({ try? decoder.decode(CurrentPlayerEnvelope.self, from: $0) })
- {
- didSet {
- UserDefaults.standard.set(
- currentPlayer.flatMap { try? encoder.encode($0) },
- forKey: currentUserEnvelopeKey
+ actor Session {
+ nonisolated let baseUrl: Isolated
+ nonisolated let currentPlayer: Isolated
+ private let router: ServerRouter
+
+ init(baseUrl: URL, router: ServerRouter) {
+ self.baseUrl = Isolated(
+ baseUrl,
+ didSet: { _, newValue in
+ UserDefaults.standard.set(newValue, forKey: baseUrlKey)
+ }
+ )
+ self.router = router
+ self.currentPlayer = Isolated(
+ UserDefaults.standard.data(forKey: currentUserEnvelopeKey)
+ .flatMap({ try? decoder.decode(CurrentPlayerEnvelope.self, from: $0) }),
+ didSet: { _, newValue in
+ UserDefaults.standard.set(
+ newValue.flatMap { try? encoder.encode($0) },
+ forKey: currentUserEnvelopeKey
+ )
+ }
)
}
- }
- return Self(
- apiRequest: { route in
- ApiClientLive.apiRequest(
- accessToken: currentPlayer?.player.accessToken,
- baseUrl: baseUrl,
+ func apiRequest(route: ServerRoute.Api.Route) async throws -> (Data, URLResponse) {
+ try await ApiClientLive.apiRequest(
+ accessToken: self.currentPlayer.value?.player.accessToken,
+ baseUrl: self.baseUrl.value,
route: route,
- router: router
+ router: self.router
)
- },
- authenticate: { request in
- return ApiClientLive.request(
- baseUrl: baseUrl,
+ }
+
+ func authenticate(request: ServerRoute.AuthenticateRequest) async throws
+ -> CurrentPlayerEnvelope
+ {
+ let (data, _) = try await ApiClientLive.request(
+ baseUrl: self.baseUrl.value,
route: .authenticate(
.init(
deviceId: request.deviceId,
@@ -64,46 +78,55 @@ extension ApiClient {
),
router: router
)
- .map { data, _ in data }
- .apiDecode(as: CurrentPlayerEnvelope.self)
- .handleEvents(
- receiveOutput: { newPlayer in
- DispatchQueue.main.async { currentPlayer = newPlayer }
- }
- )
- .eraseToEffect()
- },
- baseUrl: { baseUrl },
- currentPlayer: { currentPlayer },
- logout: {
- .fireAndForget { currentPlayer = nil }
- },
- refreshCurrentPlayer: {
- ApiClientLive.apiRequest(
- accessToken: currentPlayer?.player.accessToken,
- baseUrl: baseUrl,
+ let currentPlayer = try apiDecode(CurrentPlayerEnvelope.self, from: data)
+ self.currentPlayer.value = currentPlayer
+ return currentPlayer
+ }
+
+ func logout() {
+ self.currentPlayer.value = nil
+ }
+
+ func refreshCurrentPlayer() async throws -> CurrentPlayerEnvelope {
+ let (data, _) = try await ApiClientLive.apiRequest(
+ accessToken: self.currentPlayer.value?.player.accessToken,
+ baseUrl: self.baseUrl.value,
route: .currentPlayer,
- router: router
- )
- .map { data, _ in data }
- .apiDecode(as: CurrentPlayerEnvelope.self)
- .handleEvents(
- receiveOutput: { newPlayer in
- DispatchQueue.main.async { currentPlayer = newPlayer }
- }
+ router: self.router
)
- .eraseToEffect()
- },
- request: { route in
- ApiClientLive.request(
- baseUrl: baseUrl,
+ let currentPlayer = try apiDecode(CurrentPlayerEnvelope.self, from: data)
+ self.currentPlayer.value = currentPlayer
+ return currentPlayer
+ }
+
+ func request(route: ServerRoute) async throws -> (Data, URLResponse) {
+ try await ApiClientLive.request(
+ baseUrl: self.baseUrl.value,
route: route,
- router: router
+ router: self.router
)
- },
- setBaseUrl: { url in
- .fireAndForget { baseUrl = url }
}
+
+ func setBaseUrl(_ url: URL) {
+ self.baseUrl.value = url
+ }
+
+ fileprivate func setCurrentPlayer(_ player: CurrentPlayerEnvelope) {
+ self.currentPlayer.value = player
+ }
+ }
+
+ let session = Session(baseUrl: baseUrl, router: router)
+
+ return Self(
+ apiRequest: { try await session.apiRequest(route: $0) },
+ authenticate: { try await session.authenticate(request: $0) },
+ baseUrl: { session.baseUrl.value },
+ currentPlayer: { session.currentPlayer.value },
+ logout: { await session.logout() },
+ refreshCurrentPlayer: { try await session.refreshCurrentPlayer() },
+ request: { try await session.request(route: $0) },
+ setBaseUrl: { await session.setBaseUrl($0) }
)
}
}
@@ -112,15 +135,15 @@ private func request(
baseUrl: URL,
route: ServerRoute,
router: ServerRouter
-) -> Effect<(data: Data, response: URLResponse), URLError> {
- Deferred { () -> Effect<(data: Data, response: URLResponse), URLError> in
- guard var request = try? router.baseURL(baseUrl.absoluteString).request(for: route)
- else { return .init(error: URLError(.badURL)) }
- request.setHeaders()
- return URLSession.shared.dataTaskPublisher(for: request)
- .eraseToEffect()
+) async throws -> (Data, URLResponse) {
+ guard var request = try? router.baseURL(baseUrl.absoluteString).request(for: route)
+ else { throw URLError(.badURL) }
+ request.setHeaders()
+ if #available(iOS 15.0, *) {
+ return try await URLSession.shared.data(for: request)
+ } else {
+ fatalError()
}
- .eraseToEffect()
}
private func apiRequest(
@@ -128,25 +151,22 @@ private func apiRequest(
baseUrl: URL,
route: ServerRoute.Api.Route,
router: ServerRouter
-) -> Effect<(data: Data, response: URLResponse), URLError> {
-
- return Deferred { () -> Effect<(data: Data, response: URLResponse), URLError> in
- guard let accessToken = accessToken
- else { return .init(error: .init(.userAuthenticationRequired)) }
-
- return request(
- baseUrl: baseUrl,
- route: .api(
- .init(
- accessToken: accessToken,
- isDebug: isDebug,
- route: route
- )
- ),
- router: router
- )
- }
- .eraseToEffect()
+) async throws -> (Data, URLResponse) {
+
+ guard let accessToken = accessToken
+ else { throw URLError(.userAuthenticationRequired) }
+
+ return try await request(
+ baseUrl: baseUrl,
+ route: .api(
+ .init(
+ accessToken: accessToken,
+ isDebug: isDebug,
+ route: route
+ )
+ ),
+ router: router
+ )
}
#if DEBUG
diff --git a/Sources/AppFeature/AppDelegate.swift b/Sources/AppFeature/AppDelegate.swift
index afaac797..61ac23fa 100644
--- a/Sources/AppFeature/AppDelegate.swift
+++ b/Sources/AppFeature/AppDelegate.swift
@@ -11,12 +11,13 @@ import SharedModels
import TcaHelpers
import UIKit
import UserNotifications
+import XCTestDynamicOverlay
public enum AppDelegateAction: Equatable {
case didFinishLaunching
- case didRegisterForRemoteNotifications(Result)
+ case didRegisterForRemoteNotifications(TaskResult)
case userNotifications(UserNotificationClient.DelegateEvent)
- case userSettingsLoaded(Result)
+ case userSettingsLoaded(TaskResult)
}
struct AppDelegateEnvironment {
@@ -28,21 +29,21 @@ struct AppDelegateEnvironment {
var fileClient: FileClient
var mainQueue: AnySchedulerOf
var remoteNotifications: RemoteNotificationsClient
- var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect
+ var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void
var userNotifications: UserNotificationClient
#if DEBUG
- static let failing = Self(
- apiClient: .failing,
- audioPlayer: .failing,
- backgroundQueue: .failing("backgroundQueue"),
- build: .failing,
- dictionary: .failing,
- fileClient: .failing,
- mainQueue: .failing("mainQueue"),
- remoteNotifications: .failing,
- setUserInterfaceStyle: { _ in .failing("setUserInterfaceStyle") },
- userNotifications: .failing
+ static let unimplemented = Self(
+ apiClient: .unimplemented,
+ audioPlayer: .unimplemented,
+ backgroundQueue: .unimplemented("backgroundQueue"),
+ build: .unimplemented,
+ dictionary: .unimplemented,
+ fileClient: .unimplemented,
+ mainQueue: .unimplemented("mainQueue"),
+ remoteNotifications: .unimplemented,
+ setUserInterfaceStyle: XCTUnimplemented("\(Self.self).setUserInterfaceStyle"),
+ userNotifications: .unimplemented
)
#endif
}
@@ -52,69 +53,67 @@ let appDelegateReducer = Reducer<
> { state, action, environment in
switch action {
case .didFinishLaunching:
- return .merge(
- // Set notifications delegate
- environment.userNotifications.delegate
- .map(AppDelegateAction.userNotifications),
-
- environment.userNotifications.getNotificationSettings
- .receive(on: environment.mainQueue)
- .flatMap { settings in
- [.notDetermined, .provisional].contains(settings.authorizationStatus)
- ? environment.userNotifications.requestAuthorization(.provisional)
- : settings.authorizationStatus == .authorized
- ? environment.userNotifications.requestAuthorization([.alert, .sound])
- : .none
+ return .run { send in
+ await withThrowingTaskGroup(of: Void.self) { group in
+ group.addTask {
+ for await event in environment.userNotifications.delegate() {
+ await send(.userNotifications(event))
+ }
}
- .ignoreFailure()
- .flatMap { successful in
- successful
- ? Effect.registerForRemoteNotifications(
- remoteNotifications: environment.remoteNotifications,
- scheduler: environment.mainQueue,
- userNotifications: environment.userNotifications
- )
- : .none
+
+ group.addTask {
+ let settings = await environment.userNotifications.getNotificationSettings()
+ switch settings.authorizationStatus {
+ case .authorized:
+ guard
+ try await environment.userNotifications.requestAuthorization([.alert, .sound])
+ else { return }
+ case .notDetermined, .provisional:
+ guard try await environment.userNotifications.requestAuthorization(.provisional)
+ else { return }
+ default:
+ return
+ }
+ await environment.remoteNotifications.register()
}
- .eraseToEffect()
- .fireAndForget(),
- // Preload dictionary
- Effect
- .catching { try environment.dictionary.load(.en) }
- .subscribe(on: environment.backgroundQueue)
- .fireAndForget(),
+ group.addTask {
+ _ = try environment.dictionary.load(.en)
+ }
- .concatenate(
- environment.audioPlayer.load(AudioPlayerClient.Sound.allCases)
- .fireAndForget(),
+ group.addTask {
+ await environment.audioPlayer.load(AudioPlayerClient.Sound.allCases)
+ }
- environment.fileClient.loadUserSettings()
- .map(AppDelegateAction.userSettingsLoaded)
- )
- )
+ group.addTask {
+ await send(
+ .userSettingsLoaded(
+ TaskResult { try await environment.fileClient.loadUserSettings() }
+ )
+ )
+ }
+ }
+ }
case .didRegisterForRemoteNotifications(.failure):
return .none
case let .didRegisterForRemoteNotifications(.success(tokenData)):
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
- return environment.userNotifications.getNotificationSettings
- .flatMap { settings in
- environment.apiClient.apiRequest(
- route: .push(
- .register(
- .init(
- authorizationStatus: .init(rawValue: settings.authorizationStatus.rawValue),
- build: environment.build.number(),
- token: token
- )
+ return .fireAndForget {
+ let settings = await environment.userNotifications.getNotificationSettings()
+ _ = try await environment.apiClient.apiRequest(
+ route: .push(
+ .register(
+ .init(
+ authorizationStatus: .init(rawValue: settings.authorizationStatus.rawValue),
+ build: environment.build.number(),
+ token: token
)
)
)
- }
- .receive(on: environment.mainQueue)
- .fireAndForget()
+ )
+ }
case let .userNotifications(.willPresentNotification(_, completionHandler)):
return .fireAndForget {
@@ -125,25 +124,17 @@ let appDelegateReducer = Reducer<
return .none
case let .userSettingsLoaded(result):
- state = (try? result.get()) ?? state
- return .merge(
- environment.audioPlayer.setGlobalVolumeForSoundEffects(
- state.soundEffectsVolume
- )
- .fireAndForget(),
-
- environment.audioPlayer.setGlobalVolumeForSoundEffects(
+ state = (try? result.value) ?? state
+ return .fireAndForget { [state] in
+ async let setSoundEffects: Void =
+ await environment.audioPlayer.setGlobalVolumeForSoundEffects(state.soundEffectsVolume)
+ async let setMusic: Void = await environment.audioPlayer.setGlobalVolumeForMusic(
environment.audioPlayer.secondaryAudioShouldBeSilencedHint()
? 0
: state.musicVolume
)
- .fireAndForget(),
-
- environment.setUserInterfaceStyle(state.colorScheme.userInterfaceStyle)
- // NB: This is necessary because UIKit needs at least one tick of the run loop before we
- // can set the user interface style.
- .subscribe(on: environment.mainQueue)
- .fireAndForget()
- )
+ async let setUI: Void =
+ await environment.setUserInterfaceStyle(state.colorScheme.userInterfaceStyle)
+ }
}
}
diff --git a/Sources/AppFeature/AppEnvironment.swift b/Sources/AppFeature/AppEnvironment.swift
index b8fcf78d..8678cd46 100644
--- a/Sources/AppFeature/AppEnvironment.swift
+++ b/Sources/AppFeature/AppEnvironment.swift
@@ -36,7 +36,7 @@ public struct AppEnvironment {
public var mainRunLoop: AnySchedulerOf
public var remoteNotifications: RemoteNotificationsClient
public var serverConfig: ServerConfigClient
- public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect
+ public var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void
public var storeKit: StoreKitClient
public var timeZone: () -> TimeZone
public var userDefaults: UserDefaultsClient
@@ -59,7 +59,7 @@ public struct AppEnvironment {
mainRunLoop: AnySchedulerOf,
remoteNotifications: RemoteNotificationsClient,
serverConfig: ServerConfigClient,
- setUserInterfaceStyle: @escaping (UIUserInterfaceStyle) -> Effect,
+ setUserInterfaceStyle: @escaping @Sendable (UIUserInterfaceStyle) async -> Void,
storeKit: StoreKitClient,
timeZone: @escaping () -> TimeZone,
userDefaults: UserDefaultsClient,
@@ -89,33 +89,28 @@ public struct AppEnvironment {
}
#if DEBUG
- public static let failing = Self(
- apiClient: .failing,
- applicationClient: .failing,
- audioPlayer: .failing,
- backgroundQueue: .failing("backgroundQueue"),
- build: .failing,
- database: .failing,
- deviceId: .failing,
- dictionary: .failing,
- feedbackGenerator: .failing,
- fileClient: .failing,
- gameCenter: .failing,
- lowPowerMode: .failing,
- mainQueue: .failing("mainQueue"),
- mainRunLoop: .failing("mainRunLoop"),
- remoteNotifications: .failing,
- serverConfig: .failing,
- setUserInterfaceStyle: { _ in
- .failing("\(Self.self).setUserInterfaceStyle is unimplemented")
- },
- storeKit: .failing,
- timeZone: {
- XCTFail("\(Self.self).timeZone is unimplemented")
- return TimeZone(secondsFromGMT: 0)!
- },
- userDefaults: .failing,
- userNotifications: .failing
+ public static let unimplemented = Self(
+ apiClient: .unimplemented,
+ applicationClient: .unimplemented,
+ audioPlayer: .unimplemented,
+ backgroundQueue: .unimplemented("backgroundQueue"),
+ build: .unimplemented,
+ database: .unimplemented,
+ deviceId: .unimplemented,
+ dictionary: .unimplemented,
+ feedbackGenerator: .unimplemented,
+ fileClient: .unimplemented,
+ gameCenter: .unimplemented,
+ lowPowerMode: .unimplemented,
+ mainQueue: .unimplemented("mainQueue"),
+ mainRunLoop: .unimplemented("mainRunLoop"),
+ remoteNotifications: .unimplemented,
+ serverConfig: .unimplemented,
+ setUserInterfaceStyle: XCTUnimplemented("\(Self.self).setUserInterfaceStyle"),
+ storeKit: .unimplemented,
+ timeZone: XCTUnimplemented("\(Self.self).timeZone"),
+ userDefaults: .unimplemented,
+ userNotifications: .unimplemented
)
public static let noop = Self(
@@ -135,7 +130,7 @@ public struct AppEnvironment {
mainRunLoop: .immediate,
remoteNotifications: .noop,
serverConfig: .noop,
- setUserInterfaceStyle: { _ in .none },
+ setUserInterfaceStyle: { _ in },
storeKit: .noop,
timeZone: { .autoupdatingCurrent },
userDefaults: .noop,
diff --git a/Sources/AppFeature/AppView.swift b/Sources/AppFeature/AppView.swift
index e6afe511..8593442e 100644
--- a/Sources/AppFeature/AppView.swift
+++ b/Sources/AppFeature/AppView.swift
@@ -6,6 +6,7 @@ import ComposableUserNotifications
import CubeCore
import GameFeature
import HomeFeature
+import NotificationHelpers
import OnboardingFeature
import ServerConfig
import ServerRouter
@@ -72,8 +73,8 @@ public enum AppAction: Equatable {
case home(HomeAction)
case onboarding(OnboardingAction)
case paymentTransaction(StoreKitClient.PaymentTransactionObserverEvent)
- case savedGamesLoaded(Result)
- case verifyReceiptResponse(Result)
+ case savedGamesLoaded(TaskResult)
+ case verifyReceiptResponse(TaskResult)
}
extension AppEnvironment {
@@ -205,10 +206,9 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A
}
.onChange(of: \.home.savedGames) { savedGames, _, action, environment in
if case .savedGamesLoaded(.success) = action { return .none }
- return environment.fileClient
- .saveGames(games: savedGames, on: environment.backgroundQueue)
- .receive(on: environment.mainQueue)
- .fireAndForget()
+ return .fireAndForget {
+ try await environment.fileClient.save(games: savedGames)
+ }
}
}
}
@@ -220,52 +220,52 @@ let appReducerCore = Reducer { state, actio
state.onboarding = .init(presentationStyle: .firstLaunch)
}
- return .merge(
- environment.database.migrate.fireAndForget(),
- environment.fileClient.loadSavedGames().map(AppAction.savedGamesLoaded),
-
- environment.userDefaults.installationTime <= 0
- ? environment.userDefaults.setInstallationTime(
+ return .run { send in
+ async let migrate: Void = environment.database.migrate()
+ if environment.userDefaults.installationTime <= 0 {
+ async let setInstallationTime: Void = environment.userDefaults.setInstallationTime(
environment.mainRunLoop.now.date.timeIntervalSinceReferenceDate
)
- .fireAndForget()
- : .none
- )
+ }
+ await send(
+ .savedGamesLoaded(
+ TaskResult { try await environment.fileClient.loadSavedGames() }
+ )
+ )
+ }
case let .appDelegate(.userNotifications(.didReceiveResponse(response, completionHandler))):
- let effect = Effect.fireAndForget(completionHandler)
-
- guard
+ if
let data =
try? JSONSerialization
.data(withJSONObject: response.notification.request.content.userInfo),
let pushNotificationContent = try? JSONDecoder()
.decode(PushNotificationContent.self, from: data)
- else { return effect }
-
- switch pushNotificationContent {
- case .dailyChallengeEndsSoon:
- if let inProgressGame = state.home.savedGames.dailyChallengeUnlimited {
- state.currentGame = GameFeatureState(
- game: GameState(inProgressGame: inProgressGame),
- settings: state.home.settings
- )
- } else {
- // TODO: load/retry
- }
+ {
+ switch pushNotificationContent {
+ case .dailyChallengeEndsSoon:
+ if let inProgressGame = state.home.savedGames.dailyChallengeUnlimited {
+ state.currentGame = GameFeatureState(
+ game: GameState(inProgressGame: inProgressGame),
+ settings: state.home.settings
+ )
+ } else {
+ // TODO: load/retry
+ }
- case .dailyChallengeReport:
- state.game = nil
- state.home.route = .dailyChallenge(.init())
+ case .dailyChallengeReport:
+ state.game = nil
+ state.home.route = .dailyChallenge(.init())
+ }
}
- return effect
+ return .fireAndForget { completionHandler() }
case .appDelegate:
return .none
case .currentGame(.game(.endGameButtonTapped)),
- .currentGame(.game(.gameOver(.onAppear))):
+ .currentGame(.game(.gameOver(.task))):
switch (state.game?.gameContext, state.game?.gameMode) {
case (.dailyChallenge, .unlimited):
@@ -301,19 +301,17 @@ let appReducerCore = Reducer { state, actio
case let .currentGame(.game(.activeGames(.turnBasedGameTapped(matchId)))),
let .home(.activeGames(.turnBasedGameTapped(matchId))):
- return environment.gameCenter.turnBasedMatch.load(matchId)
- .ignoreFailure()
- .map {
- .gameCenter(
- .listener(
- .turnBased(
- .receivedTurnEventForMatch($0, didBecomeActive: true)
- )
- )
+ return .run { send in
+ do {
+ let match = try await environment.gameCenter.turnBasedMatch.load(matchId)
+ await send(
+ .gameCenter(
+ .listener(.turnBased(.receivedTurnEventForMatch(match, didBecomeActive: true)))
+ ),
+ animation: .default
)
- }
- .receive(on: environment.mainQueue.animation())
- .eraseToEffect()
+ } catch {}
+ }
case .currentGame(.game(.exitButtonTapped)),
.currentGame(.game(.gameOver(.delegate(.close)))):
@@ -359,10 +357,9 @@ let appReducerCore = Reducer { state, actio
!= state.home.savedGames.dailyChallengeUnlimited?.dailyChallengeId
{
state.home.savedGames.dailyChallengeUnlimited = nil
- return environment.fileClient
- .saveGames(games: state.home.savedGames, on: environment.backgroundQueue)
- .receive(on: environment.mainQueue)
- .fireAndForget()
+ return .fireAndForget { [savedGames = state.home.savedGames] in
+ try await environment.fileClient.save(games: savedGames)
+ }
}
return .none
@@ -371,18 +368,13 @@ let appReducerCore = Reducer { state, actio
return .none
case .didChangeScenePhase(.active):
- return .merge(
- Effect.registerForRemoteNotifications(
+ return .fireAndForget {
+ async let register: Void = registerForRemoteNotificationsAsync(
remoteNotifications: environment.remoteNotifications,
- scheduler: environment.mainQueue,
userNotifications: environment.userNotifications
)
- .fireAndForget(),
-
- environment.serverConfig.refresh()
- .receive(on: environment.mainQueue)
- .fireAndForget()
- )
+ async let refresh = environment.serverConfig.refresh()
+ }
case .didChangeScenePhase:
return .none
diff --git a/Sources/AppFeature/GameCenterCore.swift b/Sources/AppFeature/GameCenterCore.swift
index 6d3e3780..eabddc4b 100644
--- a/Sources/AppFeature/GameCenterCore.swift
+++ b/Sources/AppFeature/GameCenterCore.swift
@@ -1,7 +1,6 @@
import ClientModels
import ComposableArchitecture
import ComposableGameCenter
-import ComposableGameCenterHelpers
import GameFeature
import GameKit
import GameOverFeature
@@ -11,8 +10,7 @@ import SharedModels
public enum GameCenterAction: Equatable {
case listener(LocalPlayerClient.ListenerEvent)
- case rematchResponse(Result)
- case turnBasedMatchReloaded(Result)
+ case rematchResponse(TaskResult)
}
extension Reducer where State == AppState, Action == AppAction, Environment == AppEnvironment {
@@ -45,25 +43,22 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A
game: game,
settings: state.home.settings
)
- return .merge(
- environment.gameCenter.turnBasedMatchmakerViewController.dismiss
- .fireAndForget(),
- environment.gameCenter.turnBasedMatch
- .saveCurrentTurn(
- match.matchId,
- Data(
- turnBasedMatchData: .init(
- context: context,
- gameState: game,
- playerId: environment.apiClient.currentPlayer()?.player.id
- )
+ return .fireAndForget {
+ await environment.gameCenter.turnBasedMatchmakerViewController.dismiss()
+ try await environment.gameCenter.turnBasedMatch.saveCurrentTurn(
+ match.matchId,
+ Data(
+ turnBasedMatchData: .init(
+ context: context,
+ gameState: game,
+ playerId: environment.apiClient.currentPlayer()?.player.id
)
)
- .fireAndForget()
- )
+ )
+ }
}
- guard var turnBasedMatchData = matchData.turnBasedMatchData else {
+ guard let turnBasedMatchData = matchData.turnBasedMatchData else {
return .none
}
@@ -91,19 +86,17 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A
game: gameState,
settings: state.home.settings
)
- turnBasedMatchData.metadata.lastOpenedAt = environment.mainRunLoop.now.date
- return .merge(
- environment.gameCenter.turnBasedMatchmakerViewController.dismiss
- .fireAndForget(),
-
- gameState.isYourTurn
- ? environment.gameCenter.turnBasedMatch.saveCurrentTurn(
+ return .fireAndForget { [isYourTurn = gameState.isYourTurn, turnBasedMatchData] in
+ await environment.gameCenter.turnBasedMatchmakerViewController.dismiss()
+ if isYourTurn {
+ var turnBasedMatchData = turnBasedMatchData
+ turnBasedMatchData.metadata.lastOpenedAt = environment.mainRunLoop.now.date
+ try await environment.gameCenter.turnBasedMatch.saveCurrentTurn(
match.matchId,
Data(turnBasedMatchData: turnBasedMatchData)
)
- .fireAndForget()
- : .none
- )
+ }
+ }
}
let context = TurnBasedContext(
@@ -119,23 +112,21 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A
lastTurnDate > environment.mainRunLoop.now.date.addingTimeInterval(-60)
else { return .none }
- return environment.gameCenter
- .showNotificationBanner(.init(title: match.message, message: nil))
- .fireAndForget()
+ return .fireAndForget {
+ await environment.gameCenter.showNotificationBanner(
+ .init(title: match.message, message: nil)
+ )
+ }
}
switch action {
case .appDelegate(.didFinishLaunching):
- return environment.gameCenter.localPlayer.authenticate
- .map { $0 == nil }
- .removeDuplicates()
- .flatMap {
- $0
- ? environment.gameCenter.localPlayer.listener.map { .gameCenter(.listener($0)) }
- .cancellable(id: ListenerId(), cancelInFlight: true)
- : .cancel(id: ListenerId())
+ return .run { send in
+ try await environment.gameCenter.localPlayer.authenticate()
+ for await event in environment.gameCenter.localPlayer.listener() {
+ await send(.gameCenter(.listener(event)))
}
- .eraseToEffect()
+ }
case .currentGame(.game(.gameOver(.rematchButtonTapped))):
guard
@@ -145,21 +136,24 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A
state.game = nil
- return environment.gameCenter.turnBasedMatch
- .rematch(turnBasedMatch.match.matchId)
- .receive(on: environment.mainQueue)
- .mapError { $0 as NSError }
- .catchToEffect { .gameCenter(.rematchResponse($0)) }
+ return .task {
+ await .gameCenter(
+ .rematchResponse(
+ TaskResult {
+ try await environment.gameCenter.turnBasedMatch.rematch(
+ turnBasedMatch.match.matchId
+ )
+ }
+ )
+ )
+ }
case let .gameCenter(.listener(.turnBased(.matchEnded(match)))):
- guard state.game?.turnBasedContext?.match.matchId == match.matchId
+ guard
+ state.game?.turnBasedContext?.match.matchId == match.matchId,
+ let turnBasedMatchData = match.matchData?.turnBasedMatchData
else { return .none }
- guard let turnBasedMatchData = match.matchData?.turnBasedMatchData
- else {
- return .none
- }
-
let newGame = GameState(
gameCurrentTime: environment.mainRunLoop.now.date,
localPlayer: environment.gameCenter.localPlayer.localPlayer(),
@@ -168,27 +162,29 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A
)
state.game = newGame
- return environment.database
- .saveGame(.init(gameState: newGame))
- .fireAndForget()
+ return .fireAndForget {
+ try await environment.database.saveGame(.init(gameState: newGame))
+ }
case let .gameCenter(
.listener(.turnBased(.receivedTurnEventForMatch(match, didBecomeActive)))):
return handleTurnBasedMatch(match, didBecomeActive: didBecomeActive)
case let .gameCenter(.listener(.turnBased(.wantsToQuitMatch(match)))):
- return environment.gameCenter.turnBasedMatch
- .endMatchInTurn(
+ return .fireAndForget {
+ try await environment.gameCenter.turnBasedMatch.endMatchInTurn(
.init(
for: match.matchId,
matchData: match.matchData ?? Data(),
localPlayerId: environment.gameCenter.localPlayer.localPlayer().gamePlayerId,
localPlayerMatchOutcome: .quit,
- message:
- "\(environment.gameCenter.localPlayer.localPlayer().displayName) forfeited the match."
+ message: """
+ \(environment.gameCenter.localPlayer.localPlayer().displayName) \
+ forfeited the match.
+ """
)
)
- .fireAndForget()
+ }
case .gameCenter(.listener):
return .none
@@ -197,23 +193,21 @@ extension Reducer where State == AppState, Action == AppAction, Environment == A
let .home(.multiplayer(.pastGames(.pastGame(_, .delegate(.openMatch(turnBasedMatch)))))):
return handleTurnBasedMatch(turnBasedMatch, didBecomeActive: true)
- case let .gameCenter(.turnBasedMatchReloaded(.success(turnBasedMatch))):
- if state.game?.turnBasedContext?.match.matchId == turnBasedMatch.matchId {
- state.game?.turnBasedContext?.match = turnBasedMatch
- }
- return .none
-
case let .home(.activeGames(.turnBasedGameMenuItemTapped(.rematch(matchId)))):
- return environment.gameCenter.turnBasedMatch.rematch(matchId)
- .receive(on: environment.mainQueue)
- .mapError { $0 as NSError }
- .catchToEffect { .gameCenter(.rematchResponse($0)) }
+ return .task {
+ await .gameCenter(
+ .rematchResponse(
+ TaskResult {
+ try await environment.gameCenter.turnBasedMatch.rematch(matchId)
+ }
+ )
+ )
+ }
default:
return .none
}
- })
+ }
+ )
}
}
-
-private struct ListenerId: Hashable {}
diff --git a/Sources/AppFeature/StoreKitCore.swift b/Sources/AppFeature/StoreKitCore.swift
index 67d27941..cbaecd70 100644
--- a/Sources/AppFeature/StoreKitCore.swift
+++ b/Sources/AppFeature/StoreKitCore.swift
@@ -17,66 +17,59 @@ extension Reducer where Action == AppAction, Environment == AppEnvironment {
with: Reducer { _, action, environment in
switch action {
case .appDelegate(.didFinishLaunching):
- return environment.storeKit.observer
- .map(AppAction.paymentTransaction)
-
- case let .paymentTransaction(.updatedTransactions(transactions)):
- let verifiableTransactions = transactions.filter { $0.transactionState.canBeVerified }
- let otherTransactions = transactions.filter { !$0.transactionState.canBeVerified }
-
- let verifyReceiptEffect: Effect
- if verifiableTransactions.isEmpty {
- verifyReceiptEffect = .none
- } else if let appStoreReceiptURL = environment.storeKit.appStoreReceiptURL(),
- let receiptData = try? Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
- {
- verifyReceiptEffect = environment.apiClient.apiRequest(
- route: .verifyReceipt(receiptData),
- as: VerifyReceiptEnvelope.self
- )
- .mapError { $0 as NSError }
- .map { ReceiptFinalizationEnvelope(transactions: transactions, verifyEnvelope: $0) }
- .catchToEffect(AppAction.verifyReceiptResponse)
- } else {
- // TODO: what to do if there is no receipt data?
- verifyReceiptEffect = .none
+ return .run { send in
+ for await event in environment.storeKit.observer() {
+ await send(.paymentTransaction(event))
+ }
}
- let otherTransactionEffects: [Effect] = otherTransactions.map {
- transaction in
- switch transaction.transactionState {
- case .purchasing:
- // TODO: what to do? nothing?
- return .none
+ case let .paymentTransaction(.updatedTransactions(transactions)):
+ return .run { send in
+ let verifiableTransactions = transactions.filter { $0.transactionState.canBeVerified }
+ let otherTransactions = transactions.filter { !$0.transactionState.canBeVerified }
- case .purchased, .restored:
- return .none
+ if
+ !verifiableTransactions.isEmpty,
+ let appStoreReceiptURL = environment.storeKit.appStoreReceiptURL(),
+ let receiptData = try? Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
+ {
+ await send(
+ .verifyReceiptResponse(
+ TaskResult {
+ try await ReceiptFinalizationEnvelope(
+ transactions: transactions,
+ verifyEnvelope: environment.apiClient.apiRequest(
+ route: .verifyReceipt(receiptData),
+ as: VerifyReceiptEnvelope.self
+ )
+ )
+ }
+ )
+ )
+ }
- case .failed:
- return environment.storeKit.finishTransaction(transaction)
- .fireAndForget()
+ for transaction in otherTransactions {
+ switch transaction.transactionState {
+ case .failed:
+ await environment.storeKit.finishTransaction(transaction)
- case .deferred:
- // TODO: Update UI to show await parent approval
- return .none
+ case .deferred, .purchased, .purchasing, .restored:
+ return
- @unknown default:
- return .none
+ @unknown default:
+ return
+ }
}
}
- return .merge([verifyReceiptEffect] + otherTransactionEffects)
-
case let .verifyReceiptResponse(.success(envelope)):
- return .merge(
- envelope.transactions
- .compactMap { transaction in
- envelope.verifyEnvelope.verifiedProductIds
- .contains { $0 == transaction.payment.productIdentifier }
- ? environment.storeKit.finishTransaction(transaction).fireAndForget()
- : nil
- }
- )
+ return .fireAndForget {
+ for transaction in envelope.transactions
+ where envelope.verifyEnvelope.verifiedProductIds
+ .contains(where: { $0 == transaction.payment.productIdentifier }) {
+ await environment.storeKit.finishTransaction(transaction)
+ }
+ }
case .verifyReceiptResponse(.failure):
return .none
@@ -84,6 +77,7 @@ extension Reducer where Action == AppAction, Environment == AppEnvironment {
default:
return .none
}
- })
+ }
+ )
}
}
diff --git a/Sources/AudioPlayerClient/Client.swift b/Sources/AudioPlayerClient/Client.swift
index 80268432..4226d770 100644
--- a/Sources/AudioPlayerClient/Client.swift
+++ b/Sources/AudioPlayerClient/Client.swift
@@ -1,14 +1,14 @@
import ComposableArchitecture
public struct AudioPlayerClient {
- public var load: ([Sound]) -> Effect
- public var loop: (Sound) -> Effect
- public var play: (Sound) -> Effect
- public var secondaryAudioShouldBeSilencedHint: () -> Bool
- public var setGlobalVolumeForMusic: (Float) -> Effect
- public var setGlobalVolumeForSoundEffects: (Float) -> Effect
- public var setVolume: (Sound, Float) -> Effect
- public var stop: (Sound) -> Effect
+ public var load: @Sendable ([Sound]) async -> Void
+ public var loop: @Sendable (Sound) async -> Void
+ public var play: @Sendable (Sound) async -> Void
+ public var secondaryAudioShouldBeSilencedHint: @Sendable () async -> Bool
+ public var setGlobalVolumeForMusic: @Sendable (Float) async -> Void
+ public var setGlobalVolumeForSoundEffects: @Sendable (Float) async -> Void
+ public var setVolume: @Sendable (Sound, Float) async ->Void
+ public var stop: @Sendable (Sound) async -> Void
public struct Sound: Hashable {
public let category: Category
@@ -29,8 +29,7 @@ public struct AudioPlayerClient {
var client = self
client.play = { sound in
guard doNotIncludeSounds.contains(sound)
- else { return self.play(sound) }
- return .none
+ else { return await self.play(sound) }
}
return client
}
@@ -38,14 +37,14 @@ public struct AudioPlayerClient {
extension AudioPlayerClient {
public static let noop = Self(
- load: { _ in .none },
- loop: { _ in .none },
- play: { _ in .none },
+ load: { _ in },
+ loop: { _ in },
+ play: { _ in },
secondaryAudioShouldBeSilencedHint: { false },
- setGlobalVolumeForMusic: { _ in .none },
- setGlobalVolumeForSoundEffects: { _ in .none },
- setVolume: { _, _ in .none },
- stop: { _ in .none }
+ setGlobalVolumeForMusic: { _ in },
+ setGlobalVolumeForSoundEffects: { _ in },
+ setVolume: { _, _ in },
+ stop: { _ in }
)
}
@@ -53,22 +52,19 @@ extension AudioPlayerClient {
import XCTestDynamicOverlay
extension AudioPlayerClient {
- public static let failing = Self(
- load: { _ in .failing("\(Self.self).load is unimplemented") },
- loop: { _ in .failing("\(Self.self).loop is unimplemented") },
- play: { _ in .failing("\(Self.self).play is unimplemented") },
- secondaryAudioShouldBeSilencedHint: {
- XCTFail("\(Self.self).secondaryAudioShouldBeSilencedHint is unimplemented")
- return false
- },
- setGlobalVolumeForMusic: { _ in
- .failing("\(Self.self).setGlobalVolumeForMusic is unimplemented")
- },
- setGlobalVolumeForSoundEffects: { _ in
- .failing("\(Self.self).setGlobalVolumeForSoundEffects is unimplemented")
- },
- setVolume: { _, _ in .failing("\(Self.self).setVolume is unimplemented") },
- stop: { _ in .failing("\(Self.self).stop is unimplemented") }
+ public static let unimplemented = Self(
+ load: XCTUnimplemented("\(Self.self).load"),
+ loop: XCTUnimplemented("\(Self.self).loop"),
+ play: XCTUnimplemented("\(Self.self).play"),
+ secondaryAudioShouldBeSilencedHint: XCTUnimplemented(
+ "\(Self.self).secondaryAudioShouldBeSilencedHint", placeholder: false
+ ),
+ setGlobalVolumeForMusic: XCTUnimplemented("\(Self.self).setGlobalVolumeForMusic"),
+ setGlobalVolumeForSoundEffects: XCTUnimplemented(
+ "\(Self.self).setGlobalVolumeForSoundEffects"
+ ),
+ setVolume: XCTUnimplemented("\(Self.self).setVolume"),
+ stop: XCTUnimplemented("\(Self.self).stop")
)
}
#endif
diff --git a/Sources/AudioPlayerClient/Live.swift b/Sources/AudioPlayerClient/Live.swift
index 70466ed4..9a2a2101 100644
--- a/Sources/AudioPlayerClient/Live.swift
+++ b/Sources/AudioPlayerClient/Live.swift
@@ -1,177 +1,150 @@
import AVFoundation
+import ComposableArchitecture
extension AudioPlayerClient {
public static func live(bundles: [Bundle]) -> Self {
- Self(
- load: { sounds in
- .fireAndForget {
- queue.async {
- let soundsToLoad = sounds.filter { !files.keys.contains($0) }
-
- try? AVAudioSession.sharedInstance().setCategory(.ambient)
- try? AVAudioSession.sharedInstance().setActive(true, options: [])
- for sound in soundsToLoad {
- for bundle in bundles {
- guard let url = bundle.url(forResource: sound.name, withExtension: "mp3")
- else { continue }
- files[sound] = AudioPlayer(category: sound.category, url: url)
- }
- }
- guard !files.isEmpty else { return }
- try? audioEngine.start()
- }
- }
- },
- loop: { sound in
- .fireAndForget {
- queue.async {
- files[sound]?.play(loop: true)
- }
- }
- },
- play: { sound in
- .fireAndForget {
- queue.async {
- files[sound]?.play()
- }
- }
- },
+ let actor = AudioActor(bundles: .init(wrappedValue: bundles))
+ return Self(
+ load: { try? await actor.load(sounds: $0) },
+ loop: { try? await actor.play(sound: $0, loop: true) },
+ play: { try? await actor.play(sound: $0) },
secondaryAudioShouldBeSilencedHint: {
AVAudioSession.sharedInstance().secondaryAudioShouldBeSilencedHint
},
- setGlobalVolumeForMusic: { volume in
- .fireAndForget {
- queue.async {
- musicVolume = volume
- }
- }
- },
- setGlobalVolumeForSoundEffects: { volume in
- .fireAndForget {
- queue.async {
- soundEffectsNode.volume = 0.25 * volume
- }
- }
- },
- setVolume: { sound, volume in
- .fireAndForget {
- queue.async {
- files[sound]?.volume = volume
- }
- }
- },
- stop: { sound in
- .fireAndForget {
- queue.async {
- files[sound]?.stop()
- }
- }
- }
+ setGlobalVolumeForMusic: { await actor.setMusicVolume(to: $0) },
+ setGlobalVolumeForSoundEffects: { await actor.setSoundEffectsVolume(to: $0) },
+ setVolume: { try? await actor.setVolume(of: $0, to: $1) },
+ stop: { try? await actor.stop(sound: $0) }
)
}
-}
-private var files: [AudioPlayerClient.Sound: AudioPlayer] = [:]
-
-private class AudioPlayer {
- enum Source {
- case music(AVAudioPlayer)
- case soundEffect(AVAudioPlayerNode, AVAudioPCMBuffer)
- }
+ private actor AudioActor {
+ enum Failure: Error {
+ case bufferInitializationFailed
+ case soundNotLoaded(AudioPlayerClient.Sound)
+ case soundsNotLoaded([AudioPlayerClient.Sound: Error])
+ }
- let source: Source
- var volume: Float = 1 {
- didSet {
- self.setVolume(self.volume)
+ enum Player {
+ case music(AVAudioPlayer)
+ case soundEffect(AVAudioPlayerNode, AVAudioPCMBuffer)
}
- }
- init?(category: AudioPlayerClient.Sound.Category, url: URL) {
- switch category {
- case .music:
- guard let player = try? AVAudioPlayer(contentsOf: url)
- else { return nil }
- self.source = .music(player)
-
- case .soundEffect:
- guard
- let file = try? AVAudioFile(forReading: url),
- let buffer = AVAudioPCMBuffer(
- pcmFormat: file.processingFormat,
- frameCapacity: AVAudioFrameCount(file.length)
- ),
- (try? file.read(into: buffer)) != nil
- else { return nil }
- let node = AVAudioPlayerNode()
- audioEngine.attach(node)
- audioEngine.connect(node, to: soundEffectsNode, format: nil)
- self.source = .soundEffect(node, buffer)
+ let audioEngine: AVAudioEngine
+ let bundles: [Bundle]
+ var musicVolume: Float = 1.0
+ var players: [Sound: Player] = [:]
+ let soundEffectsNode: AVAudioMixerNode
+
+ init(bundles: UncheckedSendable<[Bundle]>) {
+ let audioEngine = AVAudioEngine()
+ let soundEffectsNode = AVAudioMixerNode()
+ audioEngine.attach(soundEffectsNode)
+ audioEngine.connect(soundEffectsNode, to: audioEngine.mainMixerNode, format: nil)
+ self.audioEngine = audioEngine
+ self.bundles = bundles.wrappedValue
+ self.soundEffectsNode = soundEffectsNode
}
- }
- func play(loop: Bool = false) {
- switch self.source {
- case let .music(player):
- player.currentTime = 0
- player.numberOfLoops = loop ? -1 : 0
- player.volume = musicVolume
- player.play()
-
- case let .soundEffect(node, buffer):
- if !audioEngine.isRunning {
- guard (try? audioEngine.start()) != nil else { return }
- }
+ func load(sounds: [Sound]) throws {
+ let sounds = sounds.filter { !self.players.keys.contains($0) }
+ try AVAudioSession.sharedInstance().setCategory(.ambient)
+ try AVAudioSession.sharedInstance().setActive(true, options: [])
+ var errors: [Sound: Error] = [:]
+ for sound in sounds {
+ for bundle in self.bundles {
+ do {
+ guard let url = bundle.url(forResource: sound.name, withExtension: "mp3")
+ else { continue }
+ switch sound.category {
+ case .music:
+ self.players[sound] = try .music(AVAudioPlayer(contentsOf: url))
- node.stop()
- node.scheduleBuffer(
- buffer,
- at: nil,
- options: loop ? .loops : [],
- completionCallbackType: .dataPlayedBack,
- completionHandler: nil
- )
- node.play(at: nil)
+ case .soundEffect:
+ let file = try AVAudioFile(forReading: url)
+ guard
+ let buffer = AVAudioPCMBuffer(
+ pcmFormat: file.processingFormat,
+ frameCapacity: AVAudioFrameCount(file.length)
+ )
+ else { throw Failure.bufferInitializationFailed }
+ try file.read(into: buffer)
+ let node = AVAudioPlayerNode()
+ audioEngine.attach(node)
+ audioEngine.connect(node, to: soundEffectsNode, format: nil)
+ self.players[sound] = .soundEffect(node, buffer)
+ }
+ } catch {
+ errors[sound] = error
+ }
+ }
+ }
+ guard errors.isEmpty else { throw Failure.soundsNotLoaded(errors) }
}
- }
- private func setVolume(_ volume: Float) {
- switch self.source {
- case let .music(player):
- player.volume = volume
+ func play(sound: Sound, loop: Bool = false) throws {
+ guard let player = self.players[sound] else { throw Failure.soundNotLoaded(sound) }
+
+ switch player {
+ case let .music(player):
+ player.currentTime = 0
+ player.numberOfLoops = loop ? -1 : 0
+ player.volume = self.musicVolume
+ player.play()
- case let .soundEffect(node, _):
- node.volume = volume
+ case let .soundEffect(node, buffer):
+ if !self.audioEngine.isRunning {
+ try audioEngine.start()
+ }
+
+ node.scheduleBuffer(
+ buffer,
+ at: nil,
+ options: loop ? .loops : [],
+ completionCallbackType: .dataPlayedBack,
+ completionHandler: nil
+ )
+ node.play()
+ }
}
- }
- func stop() {
- switch self.source {
- case let .music(player):
- player.setVolume(0, fadeDuration: 2.5)
- queue.asyncAfter(deadline: .now() + 2.5) {
- player.stop()
+ func stop(sound: Sound) throws {
+ guard let player = self.players[sound] else { throw Failure.soundNotLoaded(sound) }
+
+ switch player {
+ case let .music(player):
+ player.setVolume(0, fadeDuration: 2.5)
+ Task {
+ try await Task.sleep(nanoseconds: 2_500 * NSEC_PER_MSEC)
+ player.stop()
+ }
+
+ case let .soundEffect(node, _):
+ node.stop()
}
+ }
+
+ func setVolume(of sound: Sound, to volume: Float) throws {
+ guard let player = self.players[sound] else { throw Failure.soundNotLoaded(sound) }
- case let .soundEffect(node, _):
- node.stop()
+ switch player {
+ case let .music(player):
+ player.volume = volume
+
+ case let .soundEffect(node, _):
+ node.volume = volume
+ }
}
- }
-}
-let audioEngine = AVAudioEngine()
-let soundEffectsNode: AVAudioMixerNode = {
- let node = AVAudioMixerNode()
- audioEngine.attach(node)
- audioEngine.connect(node, to: audioEngine.mainMixerNode, format: nil)
- return node
-}()
-private var musicVolume: Float = 1 {
- didSet {
- files.forEach { _, file in
- if case .music = file.source {
- file.volume = musicVolume
+ func setMusicVolume(to volume: Float) {
+ self.musicVolume = volume
+ for (sound, _) in self.players where sound.category == .music {
+ try? self.setVolume(of: sound, to: volume)
}
}
+
+ func setSoundEffectsVolume(to volume: Float) {
+ self.soundEffectsNode.volume = 0.25 * volume
+ }
}
}
-private let queue = DispatchQueue(label: "Audio Dispatch Queue")
diff --git a/Sources/BottomMenu/ComposableBottomMenu.swift b/Sources/BottomMenu/ComposableBottomMenu.swift
index cb108a78..f3e8f822 100644
--- a/Sources/BottomMenu/ComposableBottomMenu.swift
+++ b/Sources/BottomMenu/ComposableBottomMenu.swift
@@ -76,7 +76,10 @@ extension View {
self.bottomMenu(
item: Binding(
get: {
- viewStore.state?.converted(send: viewStore.send, sendWithAnimation: viewStore.send)
+ viewStore.state?.converted(
+ send: { viewStore.send($0) },
+ sendWithAnimation: { viewStore.send($0, animation: $1) }
+ )
},
set: { state, transaction in
withAnimation(transaction.disablesAnimations ? nil : transaction.animation) {
diff --git a/Sources/Build/Build.swift b/Sources/Build/Build.swift
index 6e0177c1..b4158bd9 100644
--- a/Sources/Build/Build.swift
+++ b/Sources/Build/Build.swift
@@ -36,15 +36,9 @@ public struct Build {
import XCTestDynamicOverlay
extension Build {
- public static let failing = Self(
- gitSha: {
- XCTFail("\(Self.self).gitSha is unimplemented")
- return ""
- },
- number: {
- XCTFail("\(Self.self).number is unimplemented")
- return 0
- }
+ public static let unimplemented = Self(
+ gitSha: XCTUnimplemented("\(Self.self).gitSha"),
+ number: XCTUnimplemented("\(Self.self).number")
)
}
#endif
diff --git a/Sources/ChangelogFeature/ChangelogView.swift b/Sources/ChangelogFeature/ChangelogView.swift
index 1e6f9790..bb18e239 100644
--- a/Sources/ChangelogFeature/ChangelogView.swift
+++ b/Sources/ChangelogFeature/ChangelogView.swift
@@ -30,8 +30,8 @@ public struct ChangelogState: Equatable {
public enum ChangelogAction: Equatable {
case change(id: Build.Number, action: ChangeAction)
- case changelogResponse(Result)
- case onAppear
+ case changelogResponse(TaskResult)
+ case task
case updateButtonTapped
}
@@ -39,7 +39,6 @@ public struct ChangelogEnvironment {
public var apiClient: ApiClient
public var applicationClient: UIApplicationClient
public var build: Build
- public var mainQueue: AnySchedulerOf
public var serverConfig: ServerConfigClient
public var userDefaults: UserDefaultsClient
@@ -47,14 +46,12 @@ public struct ChangelogEnvironment {
apiClient: ApiClient,
applicationClient: UIApplicationClient,
build: Build,
- mainQueue: AnySchedulerOf,
serverConfig: ServerConfigClient,
userDefaults: UserDefaultsClient
) {
self.apiClient = apiClient
self.applicationClient = applicationClient
self.build = build
- self.mainQueue = mainQueue
self.serverConfig = serverConfig
self.userDefaults = userDefaults
}
@@ -106,23 +103,28 @@ public let changelogReducer = Reducer<
state.isRequestInFlight = false
return .none
- case .onAppear:
+ case .task:
state.currentBuild = environment.build.number()
state.isRequestInFlight = true
- return environment.apiClient.apiRequest(
- route: .changelog(build: environment.build.number()),
- as: Changelog.self
- )
- .receive(on: environment.mainQueue)
- .catchToEffect(ChangelogAction.changelogResponse)
+ return .task {
+ await .changelogResponse(
+ TaskResult {
+ try await environment.apiClient.apiRequest(
+ route: .changelog(build: environment.build.number()),
+ as: Changelog.self
+ )
+ }
+ )
+ }
case .updateButtonTapped:
- return environment.applicationClient.open(
- environment.serverConfig.config().appStoreUrl.absoluteURL,
- [:]
- )
- .fireAndForget()
+ return .fireAndForget {
+ _ = await environment.applicationClient.open(
+ environment.serverConfig.config().appStoreUrl.absoluteURL,
+ [:]
+ )
+ }
}
}
)
@@ -184,7 +186,7 @@ public struct ChangelogView: View {
}
.padding()
}
- .onAppear { viewStore.send(.onAppear) }
+ .task { await viewStore.send(.task).finish() }
}
}
}
@@ -205,7 +207,7 @@ public struct ChangelogView: View {
$0.override(
routeCase: /ServerRoute.Api.Route.changelog(build:),
withResponse: { _ in
- .ok(
+ try await OK(
update(Changelog.current) {
$0.changes.append(
Changelog.Change(
@@ -223,7 +225,6 @@ public struct ChangelogView: View {
build: update(.noop) {
$0.number = { 98 }
},
- mainQueue: .immediate,
serverConfig: .noop,
userDefaults: update(.noop) {
$0.integerForKey = { _ in 98 }
diff --git a/Sources/ComposableGameCenter/CrossPlatformSupport.swift b/Sources/ComposableGameCenter/CrossPlatformSupport.swift
index 8fc779b1..c999916c 100644
--- a/Sources/ComposableGameCenter/CrossPlatformSupport.swift
+++ b/Sources/ComposableGameCenter/CrossPlatformSupport.swift
@@ -1,38 +1,14 @@
-#if canImport(AppKit)
- import AppKit
-
- public typealias ViewController = NSViewController
-
- extension ViewController {
- public func present() {
- NSApplication.shared.windows
- .first?
- .beginSheet(NSWindow(contentViewController: self), completionHandler: nil)
- }
-
- public func dismiss() {
- guard
- let sheet = NSApplication.shared.windows.first(where: { $0.contentViewController == self })
- else { return }
- NSApplication.shared.windows
- .first?
- .endSheet(sheet)
- }
- }
-#endif
-
-#if canImport(UIKit)
+#if os(iOS)
import UIKit
- public typealias ViewController = UIViewController
-
@available(iOSApplicationExtension, unavailable)
- extension ViewController {
+ extension UIViewController {
public func present() {
- UIApplication.shared.windows
- .first(where: \.isKeyWindow)?
- .rootViewController?
- .present(self, animated: true)
+ guard
+ let scene = UIKit.UIApplication.shared.connectedScenes.first(where: { $0 is UIWindowScene })
+ as? UIWindowScene
+ else { return }
+ scene.keyWindow?.rootViewController?.present(self, animated: true)
}
public func dismiss() {
diff --git a/Sources/ComposableGameCenter/Interface.swift b/Sources/ComposableGameCenter/Interface.swift
index 9629f058..a3bbdddd 100644
--- a/Sources/ComposableGameCenter/Interface.swift
+++ b/Sources/ComposableGameCenter/Interface.swift
@@ -6,8 +6,8 @@ import Tagged
public struct GameCenterClient {
public var gameCenterViewController: GameCenterViewControllerClient
public var localPlayer: LocalPlayerClient
- public var reportAchievements: ([GKAchievement]) -> Effect
- public var showNotificationBanner: (NotificationBannerRequest) -> Effect
+ public var reportAchievements: @Sendable ([GKAchievement]) async throws -> Void
+ public var showNotificationBanner: @Sendable (NotificationBannerRequest) async -> Void
public var turnBasedMatch: TurnBasedMatchClient
public var turnBasedMatchmakerViewController: TurnBasedMatchmakerViewControllerClient
@@ -23,19 +23,15 @@ public struct GameCenterClient {
}
public struct GameCenterViewControllerClient {
- public var present: Effect
- public var dismiss: Effect
-
- public enum DelegateEvent: Equatable {
- case didFinish
- }
+ public var present: @Sendable () async -> Void
+ public var dismiss: @Sendable () async -> Void
}
public struct LocalPlayerClient {
- public var authenticate: Effect
- public var listener: Effect
- public var localPlayer: () -> LocalPlayer
- public var presentAuthenticationViewController: Effect
+ public var authenticate: @Sendable () async throws -> Void
+ public var listener: @Sendable () -> AsyncStream
+ public var localPlayer: @Sendable () -> LocalPlayer
+ public var presentAuthenticationViewController: @Sendable () async -> Void
public enum ListenerEvent: Equatable {
case challenge(ChallengeEvent)
@@ -73,23 +69,18 @@ public struct LocalPlayerClient {
}
public struct TurnBasedMatchClient {
- public var endMatchInTurn: (EndMatchInTurnRequest) -> Effect
- public var endTurn: (EndTurnRequest) -> Effect
- public var load: (TurnBasedMatch.Id) -> Effect
- public var loadMatches: () -> Effect<[TurnBasedMatch], Error>
- public var participantQuitInTurn:
- (TurnBasedMatch.Id, Data)
- -> Effect
- public var participantQuitOutOfTurn:
- (TurnBasedMatch.Id)
- -> Effect
- public var rematch: (TurnBasedMatch.Id) -> Effect
- public var remove: (TurnBasedMatch) -> Effect
- public var saveCurrentTurn: (TurnBasedMatch.Id, Data) -> Effect
- public var sendReminder: (SendReminderRequest) -> Effect
+ public var endMatchInTurn: @Sendable (EndMatchInTurnRequest) async throws -> Void
+ public var endTurn: @Sendable (EndTurnRequest) async throws -> Void
+ public var load: @Sendable (TurnBasedMatch.Id) async throws -> TurnBasedMatch
+ public var loadMatches: @Sendable () async throws -> [TurnBasedMatch]
+ public var participantQuitInTurn: @Sendable (TurnBasedMatch.Id, Data) async throws -> Void
+ public var participantQuitOutOfTurn: @Sendable (TurnBasedMatch.Id) async throws -> Void
+ public var rematch: @Sendable (TurnBasedMatch.Id) async throws -> TurnBasedMatch
+ public var remove: @Sendable (TurnBasedMatch) async throws -> Void
+ public var saveCurrentTurn: @Sendable (TurnBasedMatch.Id, Data) async throws -> Void
+ public var sendReminder: @Sendable (SendReminderRequest) async throws -> Void
public struct EndMatchInTurnRequest: Equatable {
- // TODO: public var matchOutcomes: [GKTurnBasedMatch.Outcome] or [String: GKTurnBasedMatch.Outcome]
public var localPlayerMatchOutcome: GKTurnBasedMatch.Outcome
public var localPlayerId: Player.Id
public var matchId: TurnBasedMatch.Id
@@ -148,15 +139,10 @@ public struct TurnBasedMatchClient {
}
public struct TurnBasedMatchmakerViewControllerClient {
- public var present: (_ showExistingMatches: Bool) -> Effect
- public var dismiss: Effect
-
- public enum DelegateEvent: Equatable {
- case wasCancelled
- case didFailWithError(NSError)
- }
+ public var present: @Sendable (_ showExistingMatches: Bool) async throws -> Void
+ public var dismiss: @Sendable () async -> Void
- public func present(showExistingMatches: Bool = true) -> Effect {
- self.present(showExistingMatches)
+ public func present(showExistingMatches: Bool = true) async throws {
+ try await self.present(showExistingMatches)
}
}
diff --git a/Sources/ComposableGameCenter/Live.swift b/Sources/ComposableGameCenter/Live.swift
index c1a87a2b..b3eeb6bf 100644
--- a/Sources/ComposableGameCenter/Live.swift
+++ b/Sources/ComposableGameCenter/Live.swift
@@ -1,428 +1,379 @@
-import Combine
-import CombineHelpers
-import ComposableArchitecture
-import GameKit
+#if os(iOS)
+ import Combine
+ import CombineHelpers
+ import ComposableArchitecture
+ import GameKit
-@available(iOSApplicationExtension, unavailable)
-extension GameCenterClient {
- public static var live: Self {
- return Self(
- gameCenterViewController: .live,
- localPlayer: .live,
- reportAchievements: { achievements in
- .future { callback in
- GKAchievement.report(achievements) { error in
- callback(error.map(Result.failure) ?? .success(()))
- }
- }
- },
- showNotificationBanner: { request in
- .future { callback in
- GKNotificationBanner.show(withTitle: request.title, message: request.message) {
- callback(.success(()))
- }
- }
- },
- turnBasedMatch: .live,
- turnBasedMatchmakerViewController: .live
- )
+ @available(iOSApplicationExtension, unavailable)
+ extension GameCenterClient {
+ public static var live: Self {
+ return Self(
+ gameCenterViewController: .live,
+ localPlayer: .live,
+ reportAchievements: { try await GKAchievement.report($0) },
+ showNotificationBanner: {
+ await GKNotificationBanner.show(withTitle: $0.title, message: $0.message)
+ },
+ turnBasedMatch: .live,
+ turnBasedMatchmakerViewController: .live
+ )
+ }
}
-}
-@available(iOSApplicationExtension, unavailable)
-extension GameCenterViewControllerClient {
- public static let live = Self(
- present: .run { subscriber in
- final class Delegate: NSObject, GKGameCenterControllerDelegate {
- let subscriber: Effect.Subscriber
+ @available(iOSApplicationExtension, unavailable)
+ extension GameCenterViewControllerClient {
+ public static var live: Self {
+ actor Presenter {
+ var viewController: GKGameCenterViewController?
+
+ func present() async {
+ final class Delegate: NSObject, GKGameCenterControllerDelegate {
+ let continuation: AsyncStream.Continuation
- init(subscriber: Effect.Subscriber) {
- self.subscriber = subscriber
+ init(continuation: AsyncStream.Continuation) {
+ self.continuation = continuation
+ }
+
+ func gameCenterViewControllerDidFinish(
+ _ gameCenterViewController: GKGameCenterViewController
+ ) {
+ self.continuation.yield()
+ self.continuation.finish()
+ }
+ }
+
+ await self.dismiss()
+ let viewController = await GKGameCenterViewController()
+ self.viewController = viewController
+ _ = await AsyncStream { continuation in
+ Task {
+ await MainActor.run {
+ let delegate = Delegate(continuation: continuation)
+ continuation.onTermination = { _ in
+ _ = delegate
+ }
+ viewController.gameCenterDelegate = delegate
+ viewController.present()
+ }
+ }
+ }
+ .first(where: { _ in true })
}
- func gameCenterViewControllerDidFinish(
- _ gameCenterViewController: GKGameCenterViewController
- ) {
- self.subscriber.send(.didFinish)
- self.subscriber.send(completion: .finished)
+ func dismiss() async {
+ guard let viewController = self.viewController else { return }
+ await viewController.dismiss()
+ self.viewController = nil
}
}
- let viewController = GKGameCenterViewController()
- Self.viewController = viewController
- var delegate: Optional = Delegate(subscriber: subscriber)
- viewController.gameCenterDelegate = delegate
- viewController.present()
+ let presenter = Presenter()
- return AnyCancellable {
- delegate = nil
- viewController.dismiss()
- }
- },
- dismiss: .fireAndForget {
- guard let viewController = Self.viewController else { return }
- viewController.dismiss()
- Self.viewController = nil
+ return Self(
+ present: { await presenter.present() },
+ dismiss: { await presenter.dismiss() }
+ )
}
- )
- private static var viewController: GKGameCenterViewController?
-}
+ private static var viewController: GKGameCenterViewController?
+ }
-@available(iOSApplicationExtension, unavailable)
-extension LocalPlayerClient {
- public static var live: Self {
- var localPlayer: GKLocalPlayer { .local }
+ @available(iOSApplicationExtension, unavailable)
+ extension LocalPlayerClient {
+ public static var live: Self {
+ var localPlayer: GKLocalPlayer { .local }
- return Self(
- authenticate:
- Effect
- .run { subscriber in
- localPlayer.authenticateHandler = { viewController, error in
- subscriber.send(error.map { $0 as NSError })
- if viewController != nil {
- Self.viewController = viewController
- }
- }
- return AnyCancellable {
- Self.viewController?.dismiss()
- Self.viewController = nil
- }
- }
- .shareReplay(1)
- .eraseToEffect(),
- listener:
- Effect
- .run { subscriber in
- class Listener: NSObject, GKLocalPlayerListener {
- let subscriber: Effect.Subscriber
-
- init(subscriber: Effect.Subscriber) {
- self.subscriber = subscriber
- }
-
- func player(
- _ player: GKPlayer, didComplete challenge: GKChallenge,
- issuedByFriend friendPlayer: GKPlayer
- ) {
- self.subscriber.send(
- .challenge(.didComplete(challenge, issuedByFriend: friendPlayer)))
- }
- func player(_ player: GKPlayer, didReceive challenge: GKChallenge) {
- self.subscriber.send(.challenge(.didReceive(challenge)))
- }
- func player(
- _ player: GKPlayer, issuedChallengeWasCompleted challenge: GKChallenge,
- byFriend friendPlayer: GKPlayer
- ) {
- self.subscriber.send(
- .challenge(.issuedChallengeWasCompleted(challenge, byFriend: friendPlayer)))
- }
- func player(_ player: GKPlayer, wantsToPlay challenge: GKChallenge) {
- self.subscriber.send(.challenge(.wantsToPlay(challenge)))
- }
- func player(_ player: GKPlayer, didAccept invite: GKInvite) {
- self.subscriber.send(.invite(.didAccept(invite)))
- }
- func player(
- _ player: GKPlayer, didRequestMatchWithRecipients recipientPlayers: [GKPlayer]
- ) {
- self.subscriber.send(.invite(.didRequestMatchWithRecipients(recipientPlayers)))
- }
- func player(_ player: GKPlayer, didModifySavedGame savedGame: GKSavedGame) {
- self.subscriber.send(.savedGame(.didModifySavedGame(savedGame)))
- }
- func player(_ player: GKPlayer, hasConflictingSavedGames savedGames: [GKSavedGame]) {
- self.subscriber.send(.savedGame(.hasConflictingSavedGames(savedGames)))
- }
- func player(
- _ player: GKPlayer, didRequestMatchWithOtherPlayers playersToInvite: [GKPlayer]
- ) {
- self.subscriber.send(.turnBased(.didRequestMatchWithOtherPlayers(playersToInvite)))
- }
- func player(_ player: GKPlayer, matchEnded match: GKTurnBasedMatch) {
- self.subscriber.send(.turnBased(.matchEnded(.init(rawValue: match))))
- }
- func player(
- _ player: GKPlayer, receivedExchangeCancellation exchange: GKTurnBasedExchange,
- for match: GKTurnBasedMatch
- ) {
- self.subscriber.send(
- .turnBased(.receivedExchangeCancellation(exchange, match: .init(rawValue: match))))
- }
- func player(
- _ player: GKPlayer, receivedExchangeReplies replies: [GKTurnBasedExchangeReply],
- forCompletedExchange exchange: GKTurnBasedExchange, for match: GKTurnBasedMatch
- ) {
- self.subscriber.send(
- .turnBased(.receivedExchangeReplies(replies, match: .init(rawValue: match))))
- }
- func player(
- _ player: GKPlayer, receivedExchangeRequest exchange: GKTurnBasedExchange,
- for match: GKTurnBasedMatch
- ) {
- self.subscriber.send(
- .turnBased(.receivedExchangeRequest(exchange, match: .init(rawValue: match))))
- }
- func player(
- _ player: GKPlayer, receivedTurnEventFor match: GKTurnBasedMatch,
- didBecomeActive: Bool
- ) {
- self.subscriber.send(
- .turnBased(
- .receivedTurnEventForMatch(
- .init(rawValue: match), didBecomeActive: didBecomeActive)))
+ return Self(
+ // TODO: Used to use `shareReplay(1)` here. Bring back using some local `ActorIsolated`?
+ authenticate: {
+ _ = try await AsyncThrowingStream { continuation in
+ localPlayer.authenticateHandler = { viewController, error in
+ if let error = error {
+ continuation.finish(throwing: error)
+ return
+ }
+ continuation.finish()
+ if viewController != nil {
+ Self.viewController = viewController
+ }
}
- func player(_ player: GKPlayer, wantsToQuitMatch match: GKTurnBasedMatch) {
- self.subscriber.send(.turnBased(.wantsToQuitMatch(.init(rawValue: match))))
+ continuation.onTermination = { _ in
+ Task {
+ await Self.viewController?.dismiss()
+ Self.viewController = nil
+ }
}
}
+ .first(where: { true })
+ },
+ listener: {
+ AsyncStream { continuation in
+ class Listener: NSObject, GKLocalPlayerListener {
+ let continuation: AsyncStream.Continuation
- let id = UUID()
- let listener = Listener(subscriber: subscriber)
- Self.listeners[id] = listener
- localPlayer.register(listener)
+ init(continuation: AsyncStream.Continuation) {
+ self.continuation = continuation
+ }
- return AnyCancellable {
- localPlayer.unregisterListener(Self.listeners[id]!)
- Self.listeners[id] = nil
- }
- }
- .eraseToEffect(),
- localPlayer: { .init(rawValue: localPlayer) },
- presentAuthenticationViewController: .run { _ in
- Self.viewController?.present()
- return AnyCancellable {
- Self.viewController?.dismiss()
- Self.viewController = nil
- }
- }
- )
- }
+ func player(
+ _ player: GKPlayer, didComplete challenge: GKChallenge,
+ issuedByFriend friendPlayer: GKPlayer
+ ) {
+ self.continuation.yield(
+ .challenge(.didComplete(challenge, issuedByFriend: friendPlayer)))
+ }
+ func player(_ player: GKPlayer, didReceive challenge: GKChallenge) {
+ self.continuation.yield(.challenge(.didReceive(challenge)))
+ }
+ func player(
+ _ player: GKPlayer, issuedChallengeWasCompleted challenge: GKChallenge,
+ byFriend friendPlayer: GKPlayer
+ ) {
+ self.continuation.yield(
+ .challenge(.issuedChallengeWasCompleted(challenge, byFriend: friendPlayer)))
+ }
+ func player(_ player: GKPlayer, wantsToPlay challenge: GKChallenge) {
+ self.continuation.yield(.challenge(.wantsToPlay(challenge)))
+ }
+ func player(_ player: GKPlayer, didAccept invite: GKInvite) {
+ self.continuation.yield(.invite(.didAccept(invite)))
+ }
+ func player(
+ _ player: GKPlayer, didRequestMatchWithRecipients recipientPlayers: [GKPlayer]
+ ) {
+ self.continuation.yield(.invite(.didRequestMatchWithRecipients(recipientPlayers)))
+ }
+ func player(_ player: GKPlayer, didModifySavedGame savedGame: GKSavedGame) {
+ self.continuation.yield(.savedGame(.didModifySavedGame(savedGame)))
+ }
+ func player(_ player: GKPlayer, hasConflictingSavedGames savedGames: [GKSavedGame]) {
+ self.continuation.yield(.savedGame(.hasConflictingSavedGames(savedGames)))
+ }
+ func player(
+ _ player: GKPlayer, didRequestMatchWithOtherPlayers playersToInvite: [GKPlayer]
+ ) {
+ self.continuation.yield(
+ .turnBased(.didRequestMatchWithOtherPlayers(playersToInvite)))
+ }
+ func player(_ player: GKPlayer, matchEnded match: GKTurnBasedMatch) {
+ self.continuation.yield(.turnBased(.matchEnded(.init(rawValue: match))))
+ }
+ func player(
+ _ player: GKPlayer, receivedExchangeCancellation exchange: GKTurnBasedExchange,
+ for match: GKTurnBasedMatch
+ ) {
+ self.continuation.yield(
+ .turnBased(.receivedExchangeCancellation(exchange, match: .init(rawValue: match)))
+ )
+ }
+ func player(
+ _ player: GKPlayer, receivedExchangeReplies replies: [GKTurnBasedExchangeReply],
+ forCompletedExchange exchange: GKTurnBasedExchange, for match: GKTurnBasedMatch
+ ) {
+ self.continuation.yield(
+ .turnBased(.receivedExchangeReplies(replies, match: .init(rawValue: match))))
+ }
+ func player(
+ _ player: GKPlayer, receivedExchangeRequest exchange: GKTurnBasedExchange,
+ for match: GKTurnBasedMatch
+ ) {
+ self.continuation.yield(
+ .turnBased(.receivedExchangeRequest(exchange, match: .init(rawValue: match))))
+ }
+ func player(
+ _ player: GKPlayer, receivedTurnEventFor match: GKTurnBasedMatch,
+ didBecomeActive: Bool
+ ) {
+ self.continuation.yield(
+ .turnBased(
+ .receivedTurnEventForMatch(
+ .init(rawValue: match), didBecomeActive: didBecomeActive)))
+ }
+ func player(_ player: GKPlayer, wantsToQuitMatch match: GKTurnBasedMatch) {
+ self.continuation.yield(.turnBased(.wantsToQuitMatch(.init(rawValue: match))))
+ }
+ }
- private static var listeners: [UUID: GKLocalPlayerListener] = [:]
- private static var viewController: ViewController?
-}
+ let id = UUID()
+ let listener = Listener(continuation: continuation)
+ Self.listeners[id] = listener
+ localPlayer.register(listener)
-extension TurnBasedMatchClient {
- public static let live = Self(
- endMatchInTurn: { request in
- .future { callback in
- GKTurnBasedMatch.load(withID: request.matchId.rawValue) { match, error in
- guard let match = match else {
- callback(.failure(error ?? invalidStateError))
- return
+ continuation.onTermination = { _ in
+ localPlayer.unregisterListener(Self.listeners[id]!)
+ Self.listeners[id] = nil
+ }
}
- match.message = request.message
- match.participants.forEach { participant in
- if participant.status == .active, let player = participant.player {
- let matchOutcome =
- request.localPlayerMatchOutcome == .tied
- ? .tied
- : player.gamePlayerID == request.localPlayerId.rawValue
- ? request.localPlayerMatchOutcome
- : request.localPlayerMatchOutcome == .won
- ? .lost
- : .won
- participant.matchOutcome = matchOutcome
- if match.currentParticipant == participant {
- match.currentParticipant?.matchOutcome = matchOutcome
+ },
+ localPlayer: { .init(rawValue: localPlayer) },
+ presentAuthenticationViewController: {
+ await Self.viewController?.present()
+ await AsyncStream { continuation in
+ continuation.onTermination = { _ in
+ Task {
+ await Self.viewController?.dismiss()
+ Self.viewController = nil
}
}
}
- match.endMatchInTurn(withMatch: request.matchData) { error in
- callback(error.map(Result.failure) ?? .success(()))
- }
- }
- }
- },
- endTurn: { request in
- .future { callback in
- GKTurnBasedMatch.load(withID: request.matchId.rawValue) { match, error in
- guard let match = match else {
- callback(.failure(error ?? invalidStateError))
- return
- }
- match.message = request.message
- match.endTurn(
- withNextParticipants: match.participants
- .filter { $0.player?.gamePlayerID != match.currentParticipant?.player?.gamePlayerID },
- turnTimeout: GKTurnTimeoutDefault,
- match: request.matchData
- ) { error in callback(error.map(Result.failure) ?? .success(())) }
- }
- }
- },
- load: { matchId in
- .future { callback in
- GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in
- callback(
- match.map { .success(.init(rawValue: $0)) }
- ?? .failure(error ?? invalidStateError)
- )
- }
- }
- },
- loadMatches: {
- .future { callback in
- GKTurnBasedMatch.loadMatches { matches, error in
- callback(
- matches.map { .success($0.map(TurnBasedMatch.init(rawValue:))) }
- ?? .failure(error ?? invalidStateError)
- )
- }
- }
- },
- participantQuitInTurn: { matchId, matchData in
- .future { callback in
- GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in
- guard let match = match else {
- callback(.success(error))
- return
- }
- match.participantQuitInTurn(
- with: .quit,
- nextParticipants: match.participants
- .filter { $0.player?.gamePlayerID != match.currentParticipant?.player?.gamePlayerID },
- turnTimeout: 0,
- match: matchData
- ) { callback(.success($0)) }
- }
- }
- },
- participantQuitOutOfTurn: { matchId in
- .future { callback in
- GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in
- guard let match = match else {
- callback(.success(error))
- return
- }
- match.participantQuitOutOfTurn(with: .quit) {
- callback(.success($0))
- }
+ .first(where: { true })
}
- }
- },
- rematch: { matchId in
- .future { callback in
- GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in
- guard let match = match else {
- callback(.failure(error ?? invalidStateError))
- return
- }
- match.rematch { match, error in
- callback(
- match.map { .success(.init(rawValue: $0)) }
- ?? .failure(error ?? invalidStateError)
- )
+ )
+ }
+
+ private static var listeners: [UUID: GKLocalPlayerListener] = [:]
+ private static var viewController: UIViewController?
+ }
+
+ extension TurnBasedMatchClient {
+ public static let live = Self(
+ endMatchInTurn: { request in
+ let match = try await GKTurnBasedMatch.load(withID: request.matchId.rawValue)
+ match.message = request.message
+ match.participants.forEach { participant in
+ if participant.status == .active, let player = participant.player {
+ let matchOutcome =
+ request.localPlayerMatchOutcome == .tied
+ ? .tied
+ : player.gamePlayerID == request.localPlayerId.rawValue
+ ? request.localPlayerMatchOutcome
+ : request.localPlayerMatchOutcome == .won
+ ? .lost
+ : .won
+ participant.matchOutcome = matchOutcome
+ if match.currentParticipant == participant {
+ match.currentParticipant?.matchOutcome = matchOutcome
+ }
}
}
- }
- },
- remove: { match in
- .future { callback in
+ try await match.endMatchInTurn(withMatch: request.matchData)
+ },
+ endTurn: { request in
+ let match = try await GKTurnBasedMatch.load(withID: request.matchId.rawValue)
+ match.message = request.message
+ try await match.endTurn(
+ withNextParticipants: match.participants
+ .filter { $0.player?.gamePlayerID != match.currentParticipant?.player?.gamePlayerID },
+ turnTimeout: GKTurnTimeoutDefault,
+ match: request.matchData
+ )
+ },
+ load: { matchId in
+ let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue)
+ return try await TurnBasedMatch(rawValue: GKTurnBasedMatch.load(withID: matchId.rawValue))
+ },
+ loadMatches: { try await GKTurnBasedMatch.loadMatches().map(TurnBasedMatch.init) },
+ participantQuitInTurn: { matchId, matchData in
+ let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue)
+ try await match.participantQuitInTurn(
+ with: .quit,
+ nextParticipants: match.participants
+ .filter { $0.player?.gamePlayerID != match.currentParticipant?.player?.gamePlayerID },
+ turnTimeout: 0,
+ match: matchData
+ )
+ },
+ participantQuitOutOfTurn: { matchId in
+ let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue)
+ try await match.participantQuitOutOfTurn(with: .quit)
+ },
+ rematch: { matchId in
+ let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue)
+ return try await TurnBasedMatch(rawValue: match.rematch())
+ },
+ remove: { match in
guard let turnBasedMatch = match.rawValue
else {
struct RawValueWasNil: Error {}
- callback(.failure(RawValueWasNil()))
- return
- }
- turnBasedMatch.remove { error in
- callback(
- error.map(Result.failure)
- ?? .success(())
- )
- }
- }
- },
- saveCurrentTurn: { matchId, matchData in
- .future { callback in
- GKTurnBasedMatch.load(withID: matchId.rawValue) { match, error in
- guard let match = match else {
- callback(.failure(error ?? invalidStateError))
- return
- }
- match.saveCurrentTurn(withMatch: matchData) { error in
- callback(error.map(Result.failure) ?? .success(()))
- }
- }
- }
- },
- sendReminder: { request in
- .future { callback in
- GKTurnBasedMatch.load(withID: request.matchId.rawValue) { match, error in
- guard let match = match else {
- callback(.failure(error ?? invalidStateError))
- return
- }
- match.sendReminder(
- to: request.participantsAtIndices.map { match.participants[$0] },
- localizableMessageKey: request.key,
- arguments: request.arguments
- ) { error in callback(error.map(Result.failure) ?? .success(())) }
+ throw RawValueWasNil()
}
+ try await turnBasedMatch.remove()
+ },
+ saveCurrentTurn: { matchId, matchData in
+ let match = try await GKTurnBasedMatch.load(withID: matchId.rawValue)
+ try await match.saveCurrentTurn(withMatch: matchData)
+ },
+ sendReminder: { request in
+ let match = try await GKTurnBasedMatch.load(withID: request.matchId.rawValue)
+ try await match.sendReminder(
+ to: request.participantsAtIndices.map { match.participants[$0] },
+ localizableMessageKey: request.key,
+ arguments: request.arguments
+ )
}
- }
- )
-}
+ )
+ }
-@available(iOSApplicationExtension, unavailable)
-extension TurnBasedMatchmakerViewControllerClient {
- public static let live = Self(
- present: { showExistingMatches in
- .run { subscriber in
- class Delegate: NSObject, GKTurnBasedMatchmakerViewControllerDelegate {
- let subscriber: Effect.Subscriber
+ @available(iOSApplicationExtension, unavailable)
+ extension TurnBasedMatchmakerViewControllerClient {
+ public static var live: Self {
+ actor Presenter {
+ var viewController: GKTurnBasedMatchmakerViewController?
- init(subscriber: Effect.Subscriber) {
- self.subscriber = subscriber
- }
+ func present(showExistingMatches: Bool) async throws {
+ final class Delegate: NSObject, GKTurnBasedMatchmakerViewControllerDelegate {
+ let continuation: AsyncThrowingStream.Continuation
- func turnBasedMatchmakerViewControllerWasCancelled(
- _ viewController: GKTurnBasedMatchmakerViewController
- ) {
- self.subscriber.send(.wasCancelled)
- self.subscriber.send(completion: .finished)
- }
+ init(continuation: AsyncThrowingStream.Continuation) {
+ self.continuation = continuation
+ }
- func turnBasedMatchmakerViewController(
- _ viewController: GKTurnBasedMatchmakerViewController, didFailWithError error: Error
- ) {
- self.subscriber.send(.didFailWithError(error as NSError))
- self.subscriber.send(completion: .finished)
+ func turnBasedMatchmakerViewControllerWasCancelled(
+ _ viewController: GKTurnBasedMatchmakerViewController
+ ) {
+ self.continuation.finish(throwing: CancellationError())
+ }
+
+ func turnBasedMatchmakerViewController(
+ _ viewController: GKTurnBasedMatchmakerViewController, didFailWithError error: Error
+ ) {
+ self.continuation.finish(throwing: error)
+ }
}
- }
- let matchRequest = GKMatchRequest()
- matchRequest.inviteMessage = "Let’s play isowords!" // TODO: Pass in/localize
- matchRequest.maxPlayers = 2
- matchRequest.minPlayers = 2
- matchRequest.recipientResponseHandler = { player, response in
+ await self.dismiss()
- }
+ let matchRequest = GKMatchRequest()
+ matchRequest.inviteMessage = "Let's play isowords!"
+ matchRequest.maxPlayers = 2
+ matchRequest.minPlayers = 2
- let viewController = GKTurnBasedMatchmakerViewController(matchRequest: matchRequest)
- viewController.showExistingMatches = showExistingMatches
- Self.viewController = viewController
- var delegate: Optional = Delegate(subscriber: subscriber)
- viewController.turnBasedMatchmakerDelegate = delegate
- viewController.present()
+ let viewController: GKTurnBasedMatchmakerViewController = await MainActor.run {
+ let viewController = GKTurnBasedMatchmakerViewController(matchRequest: matchRequest)
+ viewController.showExistingMatches = showExistingMatches
+ return viewController
+ }
+ self.viewController = viewController
- return AnyCancellable {
- delegate = nil
- viewController.dismiss()
- Self.viewController = nil
+ _ = try await AsyncThrowingStream { continuation in
+ Task {
+ await MainActor.run {
+ let delegate = Delegate(continuation: continuation)
+ continuation.onTermination = { _ in
+ _ = delegate
+ Task { await self.dismiss() }
+ }
+ viewController.turnBasedMatchmakerDelegate = delegate
+ viewController.present()
+ }
+ }
+ }
+ .first(where: { _ in true })
+ }
+
+ func dismiss() async {
+ guard let viewController = self.viewController else { return }
+ await viewController.dismiss()
+ self.viewController = nil
}
}
- },
- dismiss: .fireAndForget {
- guard let viewController = Self.viewController else { return }
- viewController.dismiss()
- Self.viewController = nil
- }
- )
- private static var viewController: GKTurnBasedMatchmakerViewController?
-}
+ let presenter = Presenter()
-private let invalidStateError = NSError(domain: "co.pointfree", code: -1)
+ return Self(
+ present: { try await presenter.present(showExistingMatches: $0) },
+ dismiss: { await presenter.dismiss() }
+ )
+ }
+ }
+#endif
diff --git a/Sources/ComposableGameCenter/Mocks.swift b/Sources/ComposableGameCenter/Mocks.swift
index 52f4a28a..135f8271 100644
--- a/Sources/ComposableGameCenter/Mocks.swift
+++ b/Sources/ComposableGameCenter/Mocks.swift
@@ -2,8 +2,8 @@ extension GameCenterClient {
public static let noop = Self(
gameCenterViewController: .noop,
localPlayer: .noop,
- reportAchievements: { _ in .none },
- showNotificationBanner: { _ in .none },
+ reportAchievements: { _ in },
+ showNotificationBanner: { _ in },
turnBasedMatch: .noop,
turnBasedMatchmakerViewController: .noop
)
@@ -11,15 +11,15 @@ extension GameCenterClient {
extension GameCenterViewControllerClient {
public static let noop = Self(
- present: .none,
- dismiss: .none
+ present: {},
+ dismiss: {}
)
}
extension LocalPlayerClient {
public static let noop = Self(
- authenticate: .none,
- listener: .none,
+ authenticate: {},
+ listener: { .finished },
localPlayer: {
LocalPlayer(
isAuthenticated: false,
@@ -29,29 +29,29 @@ extension LocalPlayerClient {
)
)
},
- presentAuthenticationViewController: .none
+ presentAuthenticationViewController: {}
)
}
extension TurnBasedMatchClient {
public static let noop = Self(
- endMatchInTurn: { _ in .none },
- endTurn: { _ in .none },
- load: { _ in .none },
- loadMatches: { .none },
- participantQuitInTurn: { _, _ in .none },
- participantQuitOutOfTurn: { _ in .none },
- rematch: { _ in .none },
- remove: { _ in .none },
- saveCurrentTurn: { _, _ in .none },
- sendReminder: { _ in .none }
+ endMatchInTurn: { _ in },
+ endTurn: { _ in },
+ load: { _ in try await Task.never() },
+ loadMatches: { [] },
+ participantQuitInTurn: { _, _ in },
+ participantQuitOutOfTurn: { _ in },
+ rematch: { _ in try await Task.never() },
+ remove: { _ in },
+ saveCurrentTurn: { _, _ in },
+ sendReminder: { _ in }
)
}
extension TurnBasedMatchmakerViewControllerClient {
public static let noop = Self(
- present: { _ in .none },
- dismiss: .none
+ present: { _ in },
+ dismiss: {}
)
}
@@ -59,61 +59,53 @@ extension TurnBasedMatchmakerViewControllerClient {
import XCTestDynamicOverlay
extension GameCenterClient {
- public static let failing = Self(
- gameCenterViewController: .failing,
- localPlayer: .failing,
- reportAchievements: { _ in .failing("\(Self.self).reportAchievements is unimplemented") },
- showNotificationBanner: { _ in
- .failing("\(Self.self).showNotificationBanner is unimplemented")
- },
- turnBasedMatch: .failing,
- turnBasedMatchmakerViewController: .failing
+ public static let unimplemented = Self(
+ gameCenterViewController: .unimplemented,
+ localPlayer: .unimplemented,
+ reportAchievements: XCTUnimplemented("\(Self.self).reportAchievements"),
+ showNotificationBanner: XCTUnimplemented("\(Self.self).showNotificationBanner"),
+ turnBasedMatch: .unimplemented,
+ turnBasedMatchmakerViewController: .unimplemented
)
}
extension GameCenterViewControllerClient {
- public static let failing = Self(
- present: .failing("\(Self.self).present is unimplemented"),
- dismiss: .failing("\(Self.self).dismiss is unimplemented")
+ public static let unimplemented = Self(
+ present: XCTUnimplemented("\(Self.self).present"),
+ dismiss: XCTUnimplemented("\(Self.self).dismiss")
)
}
extension LocalPlayerClient {
- public static let failing = Self(
- authenticate: .failing("\(Self.self).authenticate is unimplemented"),
- listener: .failing("\(Self.self).listener is unimplemented"),
- localPlayer: {
- XCTFail("\(Self.self).localPlayer is unimplemented")
- return .notAuthenticated
- },
- presentAuthenticationViewController:
- .failing("\(Self.self).presentAuthenticationViewController is unimplemented")
+ public static let unimplemented = Self(
+ authenticate: XCTUnimplemented("\(Self.self).authenticate"),
+ listener: XCTUnimplemented("\(Self.self).listener", placeholder: .finished),
+ localPlayer: XCTUnimplemented("\(Self.self).localPlayer", placeholder: .notAuthenticated),
+ presentAuthenticationViewController: XCTUnimplemented(
+ "\(Self.self).presentAuthenticationViewController"
+ )
)
}
extension TurnBasedMatchClient {
- public static let failing = Self(
- endMatchInTurn: { _ in .failing("\(Self.self).endMatchInTurn is unimplemented") },
- endTurn: { _ in .failing("\(Self.self).endTurn is unimplemented") },
- load: { _ in .failing("\(Self.self).load is unimplemented") },
- loadMatches: { .failing("\(Self.self).loadMatches is unimplemented") },
- participantQuitInTurn: { _, _ in
- .failing("\(Self.self).participantQuitInTurn is unimplemented")
- },
- participantQuitOutOfTurn: { _ in
- .failing("\(Self.self).participantQuitOutOfTurn is unimplemented")
- },
- rematch: { _ in .failing("\(Self.self).rematch is unimplemented") },
- remove: { _ in .failing("\(Self.self).remove is unimplemented") },
- saveCurrentTurn: { _, _ in .failing("\(Self.self).saveCurrentTurn is unimplemented") },
- sendReminder: { _ in .failing("\(Self.self).sendReminder is unimplemented") }
+ public static let unimplemented = Self(
+ endMatchInTurn: XCTUnimplemented("\(Self.self).endMatchInTurn"),
+ endTurn: XCTUnimplemented("\(Self.self).endTurn"),
+ load: XCTUnimplemented("\(Self.self).load"),
+ loadMatches: XCTUnimplemented("\(Self.self).loadMatches"),
+ participantQuitInTurn: XCTUnimplemented("\(Self.self).participantQuitInTurn"),
+ participantQuitOutOfTurn: XCTUnimplemented("\(Self.self).participantQuitOutOfTurn"),
+ rematch: XCTUnimplemented("\(Self.self).rematch"),
+ remove: XCTUnimplemented("\(Self.self).remove"),
+ saveCurrentTurn: XCTUnimplemented("\(Self.self).saveCurrentTurn"),
+ sendReminder: XCTUnimplemented("\(Self.self).sendReminder")
)
}
extension TurnBasedMatchmakerViewControllerClient {
- public static let failing = Self(
- present: { _ in .failing("\(Self.self).present is unimplemented") },
- dismiss: .failing("\(Self.self).dismiss is unimplemented")
+ public static let unimplemented = Self(
+ present: XCTUnimplemented("\(Self.self).present"),
+ dismiss: XCTUnimplemented("\(Self.self).dismiss")
)
}
#endif
diff --git a/Sources/ComposableGameCenterHelpers/ComposableGameCenterHelpers.swift b/Sources/ComposableGameCenterHelpers/ComposableGameCenterHelpers.swift
deleted file mode 100644
index 52799999..00000000
--- a/Sources/ComposableGameCenterHelpers/ComposableGameCenterHelpers.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-import ComposableArchitecture
-import ComposableGameCenter
-
-public func forceQuitMatch(
- match: TurnBasedMatch,
- gameCenter: GameCenterClient
-) -> Effect {
- let localPlayer = gameCenter.localPlayer.localPlayer()
- let currentParticipantIsLocalPlayer =
- match.currentParticipant?.player?.gamePlayerId == localPlayer.gamePlayerId
-
- if currentParticipantIsLocalPlayer {
- return gameCenter.turnBasedMatch
- .endMatchInTurn(
- .init(
- for: match.matchId,
- matchData: match.matchData ?? Data(),
- localPlayerId: localPlayer.gamePlayerId,
- localPlayerMatchOutcome: .quit,
- message: "\(localPlayer.displayName) forfeited the match."
- )
- )
- .fireAndForget()
- } else {
- return gameCenter.turnBasedMatch
- .participantQuitOutOfTurn(match.matchId)
- .fireAndForget()
- }
-}
diff --git a/Sources/ComposableStoreKit/Client.swift b/Sources/ComposableStoreKit/Client.swift
index 5386637e..5f10b86b 100644
--- a/Sources/ComposableStoreKit/Client.swift
+++ b/Sources/ComposableStoreKit/Client.swift
@@ -3,14 +3,14 @@ import ComposableArchitecture
import StoreKit
public struct StoreKitClient {
- public var addPayment: (SKPayment) -> Effect
- public var appStoreReceiptURL: () -> URL?
- public var isAuthorizedForPayments: () -> Bool
- public var fetchProducts: (Set) -> Effect
- public var finishTransaction: (PaymentTransaction) -> Effect
- public var observer: Effect
- public var requestReview: () -> Effect
- public var restoreCompletedTransactions: () -> Effect
+ public var addPayment: @Sendable (SKPayment) async -> Void
+ public var appStoreReceiptURL: @Sendable () -> URL?
+ public var isAuthorizedForPayments: @Sendable () -> Bool
+ public var fetchProducts: @Sendable (Set) async throws -> ProductsResponse
+ public var finishTransaction: @Sendable (PaymentTransaction) async -> Void
+ public var observer: @Sendable () -> AsyncStream
+ public var requestReview: @Sendable () async -> Void
+ public var restoreCompletedTransactions: @Sendable () async -> Void
public enum PaymentTransactionObserverEvent: Equatable {
case removedTransactions([PaymentTransaction])
diff --git a/Sources/ComposableStoreKit/Live.swift b/Sources/ComposableStoreKit/Live.swift
index 36dc5a74..562926b5 100644
--- a/Sources/ComposableStoreKit/Live.swift
+++ b/Sources/ComposableStoreKit/Live.swift
@@ -6,97 +6,84 @@ import StoreKit
extension StoreKitClient {
public static func live() -> Self {
return Self(
- addPayment: { payment in
- .fireAndForget {
- SKPaymentQueue.default().add(payment)
- }
- },
+ addPayment: { SKPaymentQueue.default().add($0) },
appStoreReceiptURL: { Bundle.main.appStoreReceiptURL },
- isAuthorizedForPayments: SKPaymentQueue.canMakePayments,
+ isAuthorizedForPayments: { SKPaymentQueue.canMakePayments() },
fetchProducts: { products in
- .run { subscriber in
+ let stream = AsyncThrowingStream { continuation in
let request = SKProductsRequest(productIdentifiers: products)
- var delegate: ProductRequest? = ProductRequest(subscriber: subscriber)
+ let delegate = ProductRequest(continuation: continuation)
request.delegate = delegate
request.start()
-
- return AnyCancellable {
- request.cancel()
- request.delegate = nil
- delegate = nil
+ continuation.onTermination = { [request = UncheckedSendable(request)] _ in
+ request.value.cancel()
+ _ = delegate
}
}
+ guard let response = try await stream.first(where: { _ in true })
+ else { throw CancellationError() }
+ return response
},
finishTransaction: { transaction in
- .fireAndForget {
- guard let skTransaction = transaction.rawValue else {
- assertionFailure("The rawValue of this transaction should not be nil: \(transaction)")
- return
- }
- SKPaymentQueue.default().finishTransaction(skTransaction)
+ guard let skTransaction = transaction.rawValue else {
+ assertionFailure("The rawValue of this transaction should not be nil: \(transaction)")
+ return
}
+ SKPaymentQueue.default().finishTransaction(skTransaction)
},
- observer: Effect.run { subscriber in
- let observer = Observer(subscriber: subscriber)
- SKPaymentQueue.default().add(observer)
- return AnyCancellable {
- SKPaymentQueue.default().remove(observer)
+ observer: {
+ AsyncStream { continuation in
+ let observer = Observer(continuation: continuation)
+ SKPaymentQueue.default().add(observer)
+ continuation.onTermination = { _ in SKPaymentQueue.default().remove(observer) }
}
- }
- .share()
- .eraseToEffect(),
+ },
requestReview: {
- .fireAndForget {
- #if canImport(UIKit)
- guard let windowScene = UIApplication.shared.windows.first?.windowScene
- else { return }
-
- SKStoreReviewController.requestReview(in: windowScene)
- #endif
- }
+ guard
+ let scene = await UIApplication.shared.connectedScenes
+ .first(where: { $0 is UIWindowScene })
+ as? UIWindowScene
+ else { return }
+ await SKStoreReviewController.requestReview(in: scene)
},
- restoreCompletedTransactions: {
- .fireAndForget {
- SKPaymentQueue.default().restoreCompletedTransactions()
- }
- }
+ restoreCompletedTransactions: { SKPaymentQueue.default().restoreCompletedTransactions() }
)
}
}
private class ProductRequest: NSObject, SKProductsRequestDelegate {
- let subscriber: Effect.Subscriber
+ let continuation: AsyncThrowingStream.Continuation
- init(subscriber: Effect.Subscriber) {
- self.subscriber = subscriber
+ init(continuation: AsyncThrowingStream.Continuation) {
+ self.continuation = continuation
}
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
- self.subscriber.send(
+ self.continuation.yield(
.init(
invalidProductIdentifiers: response.invalidProductIdentifiers,
products: response.products.map(StoreKitClient.Product.init(rawValue:))
)
)
- self.subscriber.send(completion: .finished)
+ self.continuation.finish()
}
func request(_ request: SKRequest, didFailWithError error: Error) {
- self.subscriber.send(completion: .failure(error))
+ self.continuation.finish(throwing: error)
}
}
private class Observer: NSObject, SKPaymentTransactionObserver {
- let subscriber: Effect.Subscriber
+ let continuation: AsyncStream.Continuation
- init(subscriber: Effect.Subscriber) {
- self.subscriber = subscriber
+ init(continuation: AsyncStream.Continuation) {
+ self.continuation = continuation
}
func paymentQueue(
_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]
) {
- self.subscriber.send(
+ self.continuation.yield(
.updatedTransactions(
transactions.map(StoreKitClient.PaymentTransaction.init(rawValue:))
)
@@ -106,7 +93,7 @@ private class Observer: NSObject, SKPaymentTransactionObserver {
func paymentQueue(
_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]
) {
- self.subscriber.send(
+ self.continuation.yield(
.removedTransactions(
transactions.map(StoreKitClient.PaymentTransaction.init(rawValue:))
)
@@ -114,7 +101,7 @@ private class Observer: NSObject, SKPaymentTransactionObserver {
}
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
- self.subscriber.send(
+ self.continuation.yield(
.restoreCompletedTransactionsFinished(
transactions: queue.transactions.map(StoreKitClient.PaymentTransaction.init)
)
@@ -124,6 +111,7 @@ private class Observer: NSObject, SKPaymentTransactionObserver {
func paymentQueue(
_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error
) {
- self.subscriber.send(.restoreCompletedTransactionsFailed(error as NSError))
+ // TODO: Should this use TaskResult instead? TaskFailure?
+ self.continuation.yield(.restoreCompletedTransactionsFailed(error as NSError))
}
}
diff --git a/Sources/ComposableStoreKit/Mocks.swift b/Sources/ComposableStoreKit/Mocks.swift
index f206265b..ef001706 100644
--- a/Sources/ComposableStoreKit/Mocks.swift
+++ b/Sources/ComposableStoreKit/Mocks.swift
@@ -2,14 +2,14 @@ import ComposableArchitecture
extension StoreKitClient {
public static let noop = Self(
- addPayment: { _ in .none },
+ addPayment: { _ in },
appStoreReceiptURL: { nil },
isAuthorizedForPayments: { false },
- fetchProducts: { _ in .none },
- finishTransaction: { _ in .none },
- observer: .none,
- requestReview: { .none },
- restoreCompletedTransactions: { .none }
+ fetchProducts: { _ in try await Task.never() },
+ finishTransaction: { _ in },
+ observer: { AsyncStream { _ in } },
+ requestReview: {},
+ restoreCompletedTransactions: {}
)
}
@@ -17,21 +17,19 @@ extension StoreKitClient {
import XCTestDynamicOverlay
extension StoreKitClient {
- public static let failing = Self(
- addPayment: { _ in .failing("\(Self.self).addPayment is unimplemented") },
- appStoreReceiptURL: {
- XCTFail("\(Self.self).appStoreReceiptURL is unimplemented")
- return nil
- },
- isAuthorizedForPayments: {
- XCTFail("\(Self.self).isAuthorizedForPayments is unimplemented")
- return false
- },
- fetchProducts: { _ in .failing("\(Self.self).fetchProducts is unimplemented") },
- finishTransaction: { _ in .failing("\(Self.self).finishTransaction is unimplemented") },
- observer: .failing("\(Self.self).observer is unimplemented"),
- requestReview: { .failing("\(Self.self).requestReview is unimplemented") },
- restoreCompletedTransactions: { .failing("\(Self.self).fireAndForget is unimplemented") }
+ public static let unimplemented = Self(
+ addPayment: XCTUnimplemented("\(Self.self).addPayment"),
+ appStoreReceiptURL: XCTUnimplemented("\(Self.self).appStoreReceiptURL", placeholder: nil),
+ isAuthorizedForPayments: XCTUnimplemented(
+ "\(Self.self).isAuthorizedForPayments", placeholder: false
+ ),
+ fetchProducts: XCTUnimplemented("\(Self.self).fetchProducts"),
+ finishTransaction: XCTUnimplemented("\(Self.self).finishTransaction"),
+ observer: XCTUnimplemented("\(Self.self).observer", placeholder: .finished),
+ requestReview: XCTUnimplemented("\(Self.self).requestReview"),
+ restoreCompletedTransactions: XCTUnimplemented(
+ "\(Self.self).restoreCompletedTransactions"
+ )
)
}
#endif
diff --git a/Sources/ComposableUserNotifications/Interface.swift b/Sources/ComposableUserNotifications/Interface.swift
index 1ccb5b33..f923e3bb 100644
--- a/Sources/ComposableUserNotifications/Interface.swift
+++ b/Sources/ComposableUserNotifications/Interface.swift
@@ -3,18 +3,20 @@ import ComposableArchitecture
import UserNotifications
public struct UserNotificationClient {
- public var add: (UNNotificationRequest) -> Effect
- public var delegate: Effect
- public var getNotificationSettings: Effect
- public var removeDeliveredNotificationsWithIdentifiers: ([String]) -> Effect
- public var removePendingNotificationRequestsWithIdentifiers: ([String]) -> Effect
- public var requestAuthorization: (UNAuthorizationOptions) -> Effect
+ public var add: @Sendable (UNNotificationRequest) async throws -> Void
+ public var delegate: @Sendable () -> AsyncStream
+ public var getNotificationSettings: @Sendable () async -> Notification.Settings
+ public var removeDeliveredNotificationsWithIdentifiers: @Sendable ([String]) async -> Void
+ public var removePendingNotificationRequestsWithIdentifiers:
+ @Sendable ([String]) async -> Void
+ public var requestAuthorization: @Sendable (UNAuthorizationOptions) async throws -> Bool
public enum DelegateEvent: Equatable {
- case didReceiveResponse(Notification.Response, completionHandler: () -> Void)
+ case didReceiveResponse(Notification.Response, completionHandler: @Sendable () -> Void)
case openSettingsForNotification(Notification?)
case willPresentNotification(
- Notification, completionHandler: (UNNotificationPresentationOptions) -> Void)
+ Notification, completionHandler: @Sendable (UNNotificationPresentationOptions) -> Void
+ )
public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
diff --git a/Sources/ComposableUserNotifications/Live.swift b/Sources/ComposableUserNotifications/Live.swift
index 73c38179..3077a5b7 100644
--- a/Sources/ComposableUserNotifications/Live.swift
+++ b/Sources/ComposableUserNotifications/Live.swift
@@ -4,56 +4,27 @@ import UserNotifications
extension UserNotificationClient {
public static let live = Self(
- add: { request in
- .future { callback in
- UNUserNotificationCenter.current().add(request) { error in
- if let error = error {
- callback(.failure(error))
- } else {
- callback(.success(()))
- }
- }
- }
- },
- delegate:
- Effect
- .run { subscriber in
- var delegate: Optional = Delegate(subscriber: subscriber)
+ add: { try await UNUserNotificationCenter.current().add($0) },
+ delegate: {
+ AsyncStream { continuation in
+ let delegate = Delegate(continuation: continuation)
UNUserNotificationCenter.current().delegate = delegate
- return AnyCancellable {
- delegate = nil
- }
- }
- .share()
- .eraseToEffect(),
- getNotificationSettings: .future { callback in
- UNUserNotificationCenter.current().getNotificationSettings { settings in
- callback(.success(.init(rawValue: settings)))
+ continuation.onTermination = { [delegate] _ in }
}
},
- removeDeliveredNotificationsWithIdentifiers: { identifiers in
- .fireAndForget {
- UNUserNotificationCenter.current()
- .removeDeliveredNotifications(withIdentifiers: identifiers)
- }
+ getNotificationSettings: {
+ await Notification.Settings(
+ rawValue: UNUserNotificationCenter.current().notificationSettings()
+ )
},
- removePendingNotificationRequestsWithIdentifiers: { identifiers in
- .fireAndForget {
- UNUserNotificationCenter.current()
- .removePendingNotificationRequests(withIdentifiers: identifiers)
- }
+ removeDeliveredNotificationsWithIdentifiers: {
+ UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: $0)
},
- requestAuthorization: { options in
- .future { callback in
- UNUserNotificationCenter.current()
- .requestAuthorization(options: options) { granted, error in
- if let error = error {
- callback(.failure(error))
- } else {
- callback(.success(granted))
- }
- }
- }
+ removePendingNotificationRequestsWithIdentifiers: {
+ UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: $0)
+ },
+ requestAuthorization: {
+ try await UNUserNotificationCenter.current().requestAuthorization(options: $0)
}
)
}
@@ -79,10 +50,10 @@ extension UserNotificationClient.Notification.Settings {
extension UserNotificationClient {
fileprivate class Delegate: NSObject, UNUserNotificationCenterDelegate {
- let subscriber: Effect.Subscriber
+ let continuation: AsyncStream.Continuation
- init(subscriber: Effect.Subscriber) {
- self.subscriber = subscriber
+ init(continuation: AsyncStream.Continuation) {
+ self.continuation = continuation
}
func userNotificationCenter(
@@ -90,8 +61,8 @@ extension UserNotificationClient {
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
- self.subscriber.send(
- .didReceiveResponse(.init(rawValue: response), completionHandler: completionHandler)
+ self.continuation.yield(
+ .didReceiveResponse(.init(rawValue: response)) { completionHandler() }
)
}
@@ -99,7 +70,7 @@ extension UserNotificationClient {
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
) {
- self.subscriber.send(
+ self.continuation.yield(
.openSettingsForNotification(notification.map(Notification.init(rawValue:)))
)
}
@@ -110,11 +81,8 @@ extension UserNotificationClient {
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void
) {
- self.subscriber.send(
- .willPresentNotification(
- .init(rawValue: notification),
- completionHandler: completionHandler
- )
+ self.continuation.yield(
+ .willPresentNotification(.init(rawValue: notification)) { completionHandler($0) }
)
}
}
diff --git a/Sources/ComposableUserNotifications/Mocks.swift b/Sources/ComposableUserNotifications/Mocks.swift
index 480fb4e5..725f30e7 100644
--- a/Sources/ComposableUserNotifications/Mocks.swift
+++ b/Sources/ComposableUserNotifications/Mocks.swift
@@ -1,11 +1,11 @@
extension UserNotificationClient {
public static let noop = Self(
- add: { _ in .none },
- delegate: .none,
- getNotificationSettings: .none,
- removeDeliveredNotificationsWithIdentifiers: { _ in .none },
- removePendingNotificationRequestsWithIdentifiers: { _ in .none },
- requestAuthorization: { _ in .none }
+ add: { _ in },
+ delegate: { AsyncStream { _ in } },
+ getNotificationSettings: { Notification.Settings(authorizationStatus: .notDetermined) },
+ removeDeliveredNotificationsWithIdentifiers: { _ in },
+ removePendingNotificationRequestsWithIdentifiers: { _ in },
+ requestAuthorization: { _ in false }
)
}
@@ -13,18 +13,18 @@ extension UserNotificationClient {
import XCTestDynamicOverlay
extension UserNotificationClient {
- public static let failing = Self(
- add: { _ in .failing("\(Self.self).add is not implemented") },
- delegate: .failing("\(Self.self).delegate is not implemented"),
- getNotificationSettings: .failing("\(Self.self).getNotificationSettings is not implemented"),
- removeDeliveredNotificationsWithIdentifiers: { _ in
- .failing("\(Self.self).removeDeliveredNotificationsWithIdentifiers is not implemented")
- },
- removePendingNotificationRequestsWithIdentifiers: { _ in
- .failing("\(Self.self).removePendingNotificationRequestsWithIdentifiers is not implemented")
- },
- requestAuthorization: { _ in .failing("\(Self.self).requestAuthorization is not implemented")
- }
+ public static let unimplemented = Self(
+ add: XCTUnimplemented("\(Self.self).add"),
+ delegate: XCTUnimplemented("\(Self.self).delegate", placeholder: .finished),
+ getNotificationSettings: XCTUnimplemented(
+ "\(Self.self).getNotificationSettings",
+ placeholder: Notification.Settings(authorizationStatus: .notDetermined)
+ ),
+ removeDeliveredNotificationsWithIdentifiers: XCTUnimplemented(
+ "\(Self.self).removeDeliveredNotificationsWithIdentifiers"),
+ removePendingNotificationRequestsWithIdentifiers: XCTUnimplemented(
+ "\(Self.self).removePendingNotificationRequestsWithIdentifiers"),
+ requestAuthorization: XCTUnimplemented("\(Self.self).requestAuthorization")
)
}
#endif
diff --git a/Sources/CubePreview/CubePreviewView.swift b/Sources/CubePreview/CubePreviewView.swift
index 304abd52..cb897717 100644
--- a/Sources/CubePreview/CubePreviewView.swift
+++ b/Sources/CubePreview/CubePreviewView.swift
@@ -59,8 +59,8 @@ public enum CubePreviewAction: BindableAction, Equatable {
case binding(BindingAction)
case cubeScene(CubeSceneView.ViewAction)
case lowPowerModeResponse(Bool)
- case onAppear
case tap
+ case task
}
public struct CubePreviewEnvironment {
@@ -87,7 +87,8 @@ public let cubePreviewReducer = Reducer<
CubePreviewAction,
CubePreviewEnvironment
> { state, action, environment in
- struct SelectionId: Hashable {}
+
+ enum SelectionID {}
switch action {
case .binding:
@@ -100,79 +101,70 @@ public let cubePreviewReducer = Reducer<
state.isOnLowPowerMode = isOn
return .none
- case .onAppear:
- var effects: [Effect] = [
- environment.lowPowerMode.start
- .prefix(1)
- .map(CubePreviewAction.lowPowerModeResponse)
- .eraseToEffect(),
-
- Effect.none
- .delay(for: 1, scheduler: environment.mainQueue)
- .eraseToEffect(),
- ]
-
- var accumulatedSelectedFaces: [IndexedCubeFace] = []
- let move = state.moves[state.moveIndex]
- switch move.type {
+ case .tap:
+ state.nub.location = .offScreenRight
+ switch state.moves[state.moveIndex].type {
case let .playedWord(faces):
- for (faceIndex, face) in faces.enumerated() {
- accumulatedSelectedFaces.append(face)
- let moveDuration = Double.random(in: (0.6...0.8))
-
- effects.append(
- Effect(value: .set(\.$nub.location, .face(face)))
- .receive(
- on: environment.mainQueue
- .animate(withDuration: moveDuration, options: .curveEaseInOut)
- )
- .eraseToEffect()
+ state.selectedCubeFaces = faces
+ case .removedCube:
+ break
+ }
+ return .cancel(id: SelectionID.self)
+
+ case .task:
+ return .run { [move = state.moves[state.moveIndex]] send in
+ await send(
+ .lowPowerModeResponse(
+ await environment.lowPowerMode.start().first(where: { _ in true }) ?? false
)
+ )
- effects.append(
- Effect.merge(
- // Press the nub on the first character
- faceIndex == 0 ? Effect(value: .set(\.$nub.isPressed, true)) : .none,
+ try await environment.mainQueue.sleep(for: .seconds(1))
- // Select the faces that have been tapped so far
- Effect(value: .set(\.$selectedCubeFaces, accumulatedSelectedFaces))
+ var accumulatedSelectedFaces: [IndexedCubeFace] = []
+ switch move.type {
+ case let .playedWord(faces):
+ for (faceIndex, face) in faces.enumerated() {
+ accumulatedSelectedFaces.append(face)
+ let moveDuration = Double.random(in: (0.6...0.8))
+
+ // Move the nub to the face
+ await send(
+ .set(\.$nub.location, .face(face)),
+ animateWithDuration: moveDuration,
+ delay: 0, options: .curveEaseInOut
)
- .delay(
- for: .seconds(
- faceIndex == 0
- ? moveDuration
- : 0.5 * moveDuration
- ),
- scheduler: environment.mainQueue.animation()
+
+ // Pause a bit to allow the nub to animate to the face
+ try await environment.mainQueue.sleep(
+ for: .seconds(faceIndex == 0 ? moveDuration : 0.5 * moveDuration)
)
- .eraseToEffect()
- )
- }
- effects.append(
- Effect(value: .set(\.$nub.isPressed, false))
- )
- effects.append(
- Effect(value: .set(\.$nub.location, .offScreenRight))
- .receive(on: environment.mainQueue.animate(withDuration: 1))
- .eraseToEffect()
- )
- case let .removedCube(index):
- break
- }
+ // Press the nub on the first character
+ if faceIndex == 0 {
+ await send(.set(\.$nub.isPressed, true), animation: .default)
+ }
- return Effect.concatenate(effects)
- .cancellable(id: SelectionId())
+ // Select the faces that have been tapped so far
+ await send(.set(\.$selectedCubeFaces, accumulatedSelectedFaces), animation: .default)
+ }
- case .tap:
- state.nub.location = .offScreenRight
- switch state.moves[state.moveIndex].type {
- case let .playedWord(faces):
- state.selectedCubeFaces = faces
- case .removedCube:
- break
+ // Un-press the nub once finished selecting all faces
+ await send(.set(\.$nub.isPressed, false))
+
+ // Move the nub off the screen
+ await send(
+ .set(\.$nub.location, .offScreenRight),
+ animateWithDuration: 1,
+ delay: 0,
+ options: .curveEaseInOut
+ )
+
+ case let .removedCube(index):
+ break
+ }
}
- return .cancel(id: SelectionId())
+ .cancellable(id: SelectionID.self)
}
}
.binding()
@@ -190,7 +182,6 @@ public let cubePreviewReducer = Reducer<
puzzle: \.cubes,
selectedWord: \.selectedCubeFaces
)
-// TODO: cancel effects on dismiss
public struct CubePreviewView: View {
@Environment(\.deviceState) var deviceState
@@ -253,9 +244,7 @@ public struct CubePreviewView: View {
action: CubePreviewAction.cubeScene
)
)
- .onAppear {
- self.viewStore.send(.onAppear)
- }
+ .task { await self.viewStore.send(.task).finish() }
}
.background(
self.viewStore.isAnimationReduced
diff --git a/Sources/DailyChallengeFeature/DailyChallengeResults.swift b/Sources/DailyChallengeFeature/DailyChallengeResults.swift
index cd1af4bc..d9eaec8a 100644
--- a/Sources/DailyChallengeFeature/DailyChallengeResults.swift
+++ b/Sources/DailyChallengeFeature/DailyChallengeResults.swift
@@ -23,27 +23,23 @@ public struct DailyChallengeResultsState: Equatable {
public enum DailyChallengeResultsAction: Equatable {
case leaderboardResults(LeaderboardResultsAction)
case loadHistory
- case fetchHistoryResponse(Result)
+ case fetchHistoryResponse(TaskResult)
}
public struct DailyChallengeResultsEnvironment {
public var apiClient: ApiClient
- public var mainQueue: AnySchedulerOf
public init(
- apiClient: ApiClient,
- mainQueue: AnySchedulerOf
+ apiClient: ApiClient
) {
self.apiClient = apiClient
- self.mainQueue = mainQueue
}
}
#if DEBUG
extension DailyChallengeResultsEnvironment {
- public static let failing = Self(
- apiClient: .failing,
- mainQueue: .failing("mainQueue")
+ public static let unimplemented = Self(
+ apiClient: .unimplemented
)
}
#endif
@@ -56,12 +52,7 @@ public let dailyChallengeResultsReducer = Reducer<
.pullback(
state: \DailyChallengeResultsState.leaderboardResults,
action: /DailyChallengeResultsAction.leaderboardResults,
- environment: {
- .init(
- loadResults: $0.apiClient.loadDailyChallengeResults(gameMode:timeScope:),
- mainQueue: $0.mainQueue
- )
- }
+ environment: { .init(loadResults: $0.apiClient.loadDailyChallengeResults) }
),
.init { state, action, environment in
@@ -86,7 +77,7 @@ public let dailyChallengeResultsReducer = Reducer<
guard
state.leaderboardResults.isTimeScopeMenuVisible
else { return .none }
- return .init(value: .loadHistory)
+ return .task { .loadHistory }
case .leaderboardResults:
return .none
@@ -96,21 +87,18 @@ public let dailyChallengeResultsReducer = Reducer<
state.history = nil
}
- struct CancelId: Hashable {}
- return environment.apiClient.apiRequest(
- route: .dailyChallenge(
- .results(
- .history(
- gameMode: state.leaderboardResults.gameMode,
- language: .en
+ enum CancelID {}
+ return .task { [gameMode = state.leaderboardResults.gameMode] in
+ await .fetchHistoryResponse(
+ TaskResult {
+ try await environment.apiClient.apiRequest(
+ route: .dailyChallenge(.results(.history(gameMode: gameMode, language: .en))),
+ as: DailyChallengeHistoryResponse.self
)
- )
- ),
- as: DailyChallengeHistoryResponse.self
- )
- .receive(on: environment.mainQueue)
- .catchToEffect(DailyChallengeResultsAction.fetchHistoryResponse)
- .cancellable(id: CancelId(), cancelInFlight: true)
+ }
+ )
+ }
+ .cancellable(id: CancelID.self, cancelInFlight: true)
}
}
)
@@ -175,23 +163,25 @@ public struct DailyChallengeResultsView: View {
}
extension ApiClient {
+ @Sendable
func loadDailyChallengeResults(
gameMode: GameMode,
timeScope gameNumber: DailyChallenge.GameNumber?
- ) -> Effect {
- self.apiRequest(
- route: .dailyChallenge(
- .results(
- .fetch(
- gameMode: gameMode,
- gameNumber: gameNumber,
- language: .en
+ ) async throws -> ResultEnvelope {
+ try await ResultEnvelope(
+ self.apiRequest(
+ route: .dailyChallenge(
+ .results(
+ .fetch(
+ gameMode: gameMode,
+ gameNumber: gameNumber,
+ language: .en
+ )
)
- )
- ),
- as: FetchDailyChallengeResultsResponse.self
+ ),
+ as: FetchDailyChallengeResultsResponse.self
+ )
)
- .map(ResultEnvelope.init)
}
}
diff --git a/Sources/DailyChallengeFeature/DailyChallengeView.swift b/Sources/DailyChallengeFeature/DailyChallengeView.swift
index cb3fa3f1..994e34df 100644
--- a/Sources/DailyChallengeFeature/DailyChallengeView.swift
+++ b/Sources/DailyChallengeFeature/DailyChallengeView.swift
@@ -60,13 +60,13 @@ public enum DailyChallengeAction: Equatable {
case dailyChallengeResults(DailyChallengeResultsAction)
case delegate(DelegateAction)
case dismissAlert
- case fetchTodaysDailyChallengeResponse(Result<[FetchTodaysDailyChallengeResponse], ApiError>)
+ case fetchTodaysDailyChallengeResponse(TaskResult<[FetchTodaysDailyChallengeResponse]>)
case gameButtonTapped(GameMode)
- case onAppear
case notificationButtonTapped
case notificationsAuthAlert(NotificationsAuthAlertAction)
case setNavigation(tag: DailyChallengeState.Route.Tag?)
- case startDailyChallengeResponse(Result)
+ case startDailyChallengeResponse(TaskResult)
+ case task
case userNotificationSettingsResponse(UserNotificationClient.Notification.Settings)
public enum DelegateAction: Equatable {
@@ -106,7 +106,7 @@ public let dailyChallengeReducer = Reducer<
._pullback(
state: (\DailyChallengeState.route).appending(path: /DailyChallengeState.Route.results),
action: /DailyChallengeAction.dailyChallengeResults,
- environment: { .init(apiClient: $0.apiClient, mainQueue: $0.mainQueue) }
+ environment: { .init(apiClient: $0.apiClient) }
),
notificationsAuthAlertReducer
@@ -123,7 +123,9 @@ public let dailyChallengeReducer = Reducer<
}
),
- .init { state, action, environment in
+ Reducer<
+ DailyChallengeState, DailyChallengeAction, DailyChallengeEnvironment
+ > { state, action, environment in
switch action {
case .dailyChallengeResults:
return .none
@@ -164,29 +166,18 @@ public let dailyChallengeReducer = Reducer<
state.gameModeIsLoading = challenge.dailyChallenge.gameMode
- return startDailyChallenge(
- challenge,
- apiClient: environment.apiClient,
- date: { environment.mainRunLoop.now.date },
- fileClient: environment.fileClient,
- mainRunLoop: environment.mainRunLoop
- )
- .catchToEffect(DailyChallengeAction.startDailyChallengeResponse)
-
- case .onAppear:
- return .merge(
- environment.apiClient.apiRequest(
- route: .dailyChallenge(.today(language: .en)),
- as: [FetchTodaysDailyChallengeResponse].self
+ return .task {
+ await .startDailyChallengeResponse(
+ TaskResult {
+ try await startDailyChallengeAsync(
+ challenge,
+ apiClient: environment.apiClient,
+ date: { environment.mainRunLoop.now.date },
+ fileClient: environment.fileClient
+ )
+ }
)
- .receive(on: environment.mainRunLoop.animation())
- .catchToEffect(DailyChallengeAction.fetchTodaysDailyChallengeResponse),
-
- environment.userNotifications.getNotificationSettings
- .receive(on: environment.mainRunLoop)
- .map(DailyChallengeAction.userNotificationSettingsResponse)
- .eraseToEffect()
- )
+ }
case .notificationButtonTapped:
state.notificationsAuthAlert = .init()
@@ -212,19 +203,49 @@ public let dailyChallengeReducer = Reducer<
state.route = nil
return .none
- case let .startDailyChallengeResponse(.failure(.alreadyPlayed(endsAt))):
+ case let .startDailyChallengeResponse(.failure(DailyChallengeError.alreadyPlayed(endsAt))):
state.alert = .alreadyPlayed(nextStartsAt: endsAt)
state.gameModeIsLoading = nil
return .none
- case let .startDailyChallengeResponse(.failure(.couldNotFetch(nextStartsAt))):
+ case let .startDailyChallengeResponse(.failure(DailyChallengeError.couldNotFetch(nextStartsAt))):
state.alert = .couldNotFetchDaily(nextStartsAt: nextStartsAt)
state.gameModeIsLoading = nil
return .none
+ case .startDailyChallengeResponse(.failure):
+ return .none
+
case let .startDailyChallengeResponse(.success(inProgressGame)):
state.gameModeIsLoading = nil
- return .init(value: .delegate(.startGame(inProgressGame)))
+ return .task { .delegate(.startGame(inProgressGame)) }
+
+ case .task:
+ return .run { send in
+ await withTaskGroup(of: Void.self) { group in
+ group.addTask {
+ await send(
+ .userNotificationSettingsResponse(
+ environment.userNotifications.getNotificationSettings()
+ )
+ )
+ }
+
+ group.addTask {
+ await send(
+ .fetchTodaysDailyChallengeResponse(
+ TaskResult {
+ try await environment.apiClient.apiRequest(
+ route: .dailyChallenge(.today(language: .en)),
+ as: [FetchTodaysDailyChallengeResponse].self
+ )
+ }
+ ),
+ animation: .default
+ )
+ }
+ }
+ }
case let .userNotificationSettingsResponse(settings):
state.userNotificationSettings = settings
@@ -395,7 +416,7 @@ public struct DailyChallengeView: View {
.foregroundColor((self.colorScheme == .dark ? .isowordsBlack : .dailyChallenge))
.background(self.colorScheme == .dark ? Color.dailyChallenge : .isowordsBlack)
}
- .onAppear { self.viewStore.send(.onAppear) }
+ .task { await self.viewStore.send(.task).finish() }
.alert(self.store.scope(state: \.alert), dismiss: .dismissAlert)
.navigationStyle(
backgroundColor: self.colorScheme == .dark ? .isowordsBlack : .dailyChallenge,
@@ -513,8 +534,9 @@ private struct RingEffect: GeometryEffect {
remoteNotifications: .noop,
userNotifications: .noop
)
- environment.userNotifications.getNotificationSettings = .init(
- value: .init(authorizationStatus: .notDetermined))
+ environment.userNotifications.getNotificationSettings = {
+ .init(authorizationStatus: .notDetermined)
+ }
return Preview {
NavigationView {
diff --git a/Sources/DailyChallengeHelpers/DailyChallengeHelpers.swift b/Sources/DailyChallengeHelpers/DailyChallengeHelpers.swift
index 9f509046..0efc5241 100644
--- a/Sources/DailyChallengeHelpers/DailyChallengeHelpers.swift
+++ b/Sources/DailyChallengeHelpers/DailyChallengeHelpers.swift
@@ -11,43 +11,40 @@ public enum DailyChallengeError: Error, Equatable {
case couldNotFetch(nextStartsAt: Date)
}
-public func startDailyChallenge(
+public func startDailyChallengeAsync(
_ challenge: FetchTodaysDailyChallengeResponse,
apiClient: ApiClient,
date: @escaping () -> Date,
- fileClient: FileClient,
- mainRunLoop: AnySchedulerOf
-) -> Effect {
- guard challenge.yourResult.rank == nil else {
- return Effect(error: .alreadyPlayed(endsAt: challenge.dailyChallenge.endsAt))
+ fileClient: FileClient
+) async throws -> InProgressGame {
+ guard challenge.yourResult.rank == nil
+ else {
+ throw DailyChallengeError.alreadyPlayed(endsAt: challenge.dailyChallenge.endsAt)
}
- return Effect.concatenate(
- challenge.dailyChallenge.gameMode == .unlimited
- ? fileClient
- .loadSavedGames()
- .tryMap { try $0.get() }
- .ignoreFailure(setFailureType: DailyChallengeError.self)
- .compactMap(\.dailyChallengeUnlimited)
- .eraseToEffect()
- : .none,
- apiClient
- .apiRequest(
- route: .dailyChallenge(
- .start(
- gameMode: challenge.dailyChallenge.gameMode,
- language: challenge.dailyChallenge.language
- )
+ guard
+ challenge.dailyChallenge.gameMode == .unlimited,
+ let game = try? await fileClient.loadSavedGames().dailyChallengeUnlimited
+ else {
+ do {
+ return try await InProgressGame(
+ response: apiClient.apiRequest(
+ route: .dailyChallenge(
+ .start(
+ gameMode: challenge.dailyChallenge.gameMode,
+ language: challenge.dailyChallenge.language
+ )
+ ),
+ as: StartDailyChallengeResponse.self
),
- as: StartDailyChallengeResponse.self
+ date: date()
)
- .map { InProgressGame(response: $0, date: date()) }
- .mapError { err in .couldNotFetch(nextStartsAt: challenge.dailyChallenge.endsAt) }
- .eraseToEffect()
- )
- .prefix(1)
- .receive(on: mainRunLoop)
- .eraseToEffect()
+
+ } catch {
+ throw DailyChallengeError.couldNotFetch(nextStartsAt: challenge.dailyChallenge.endsAt)
+ }
+ }
+ return game
}
extension InProgressGame {
diff --git a/Sources/DatabaseClient/Mocks.swift b/Sources/DatabaseClient/Mocks.swift
index d2bc65bc..3dfda885 100644
--- a/Sources/DatabaseClient/Mocks.swift
+++ b/Sources/DatabaseClient/Mocks.swift
@@ -4,75 +4,75 @@ import XCTestDynamicOverlay
#if DEBUG
extension DatabaseClient {
- public static let failing = Self(
+ public static let unimplemented = Self(
completeDailyChallenge: { _, _ in
- .failing("\(Self.self).completeDailyChallenge is unimplemented")
+ .unimplemented("\(Self.self).completeDailyChallenge")
},
createTodaysDailyChallenge: { _ in
- .failing("\(Self.self).createTodaysDailyChallenge is unimplemented")
+ .unimplemented("\(Self.self).createTodaysDailyChallenge")
},
fetchActiveDailyChallengeArns: {
- .failing("\(Self.self).fetchActiveDailyChallengeArns is unimplemented")
+ .unimplemented("\(Self.self).fetchActiveDailyChallengeArns")
},
- fetchAppleReceipt: { _ in .failing("\(Self.self).fetchAppleReceipt is unimplemented") },
+ fetchAppleReceipt: { _ in .unimplemented("\(Self.self).fetchAppleReceipt") },
fetchDailyChallengeById: { _ in
- .failing("\(Self.self).fetchDailyChallengeById is unimplemented")
+ .unimplemented("\(Self.self).fetchDailyChallengeById")
},
fetchDailyChallengeHistory: { _ in
- .failing("\(Self.self).fetchDailyChallengeHistory is unimplemented")
+ .unimplemented("\(Self.self).fetchDailyChallengeHistory")
},
fetchDailyChallengeReport: { _ in
- .failing("\(Self.self).fetchDailyChallengeReport is unimplemented")
+ .unimplemented("\(Self.self).fetchDailyChallengeReport")
},
fetchDailyChallengeResult: { _ in
- .failing("\(Self.self).fetchDailyChallengeResult is unimplemented")
+ .unimplemented("\(Self.self).fetchDailyChallengeResult")
},
fetchDailyChallengeResults: { _ in
- .failing("\(Self.self).fetchDailyChallengeResults is unimplemented")
+ .unimplemented("\(Self.self).fetchDailyChallengeResults")
},
fetchLeaderboardSummary: { _ in
- .failing("\(Self.self).fetchLeaderboardSummary is unimplemented")
+ .unimplemented("\(Self.self).fetchLeaderboardSummary")
},
fetchLeaderboardWeeklyRanks: { _, _ in
- .failing("\(Self.self).fetchLeaderboardWeeklyRanks is unimplemented")
+ .unimplemented("\(Self.self).fetchLeaderboardWeeklyRanks")
},
fetchLeaderboardWeeklyWord: { _, _ in
- .failing("\(Self.self).fetchLeaderboardWeeklyWord is unimplemented")
+ .unimplemented("\(Self.self).fetchLeaderboardWeeklyWord")
},
fetchPlayerByAccessToken: { _ in
- .failing("\(Self.self).fetchPlayerByAccessToken is unimplemented")
+ .unimplemented("\(Self.self).fetchPlayerByAccessToken")
},
- fetchPlayerByDeviceId: { _ in .failing("\(Self.self).fetchPlayerByDeviceId is unimplemented")
+ fetchPlayerByDeviceId: { _ in .unimplemented("\(Self.self).fetchPlayerByDeviceId")
},
fetchPlayerByGameCenterLocalPlayerId: { _ in
- .failing("\(Self.self).fetchPlayerByGameCenterLocalPlayerId is unimplemented")
+ .unimplemented("\(Self.self).fetchPlayerByGameCenterLocalPlayerId")
},
fetchRankedLeaderboardScores: { _ in
- .failing("\(Self.self).fetchRankedLeaderboardScores is unimplemented")
+ .unimplemented("\(Self.self).fetchRankedLeaderboardScores")
},
- fetchSharedGame: { _ in .failing("\(Self.self).fetchSharedGame is unimplemented") },
+ fetchSharedGame: { _ in .unimplemented("\(Self.self).fetchSharedGame") },
fetchTodaysDailyChallenges: { _ in
- .failing("\(Self.self).fetchTodaysDailyChallenges is unimplemented")
+ .unimplemented("\(Self.self).fetchTodaysDailyChallenges")
},
fetchVocabLeaderboard: { _, _, _ in
- .failing("\(Self.self).fetchVocabLeaderboard is unimplemented")
+ .unimplemented("\(Self.self).fetchVocabLeaderboard")
},
fetchVocabLeaderboardWord: { _ in
- .failing("\(Self.self).fetchVocabLeaderboardWord is unimplemented")
+ .unimplemented("\(Self.self).fetchVocabLeaderboardWord")
},
- insertPlayer: { _ in .failing("\(Self.self).insertPlayer is unimplemented") },
- insertPushToken: { _ in .failing("\(Self.self).insertPushToken is unimplemented") },
- insertSharedGame: { _, _ in .failing("\(Self.self).insertSharedGame is unimplemented") },
- migrate: { .failing("\(Self.self).migrate is unimplemented") },
- shutdown: { .failing("\(Self.self).shutdown is unimplemented") },
- startDailyChallenge: { _, _ in .failing("\(Self.self).startDailyChallenge is unimplemented")
+ insertPlayer: { _ in .unimplemented("\(Self.self).insertPlayer") },
+ insertPushToken: { _ in .unimplemented("\(Self.self).insertPushToken") },
+ insertSharedGame: { _, _ in .unimplemented("\(Self.self).insertSharedGame") },
+ migrate: { .unimplemented("\(Self.self).migrate") },
+ shutdown: { .unimplemented("\(Self.self).shutdown") },
+ startDailyChallenge: { _, _ in .unimplemented("\(Self.self).startDailyChallenge")
},
submitLeaderboardScore: { _ in
- .failing("\(Self.self).submitLeaderboardScore is unimplemented")
+ .unimplemented("\(Self.self).submitLeaderboardScore")
},
- updateAppleReceipt: { _, _ in .failing("\(Self.self).updateAppleReceipt is unimplemented") },
- updatePlayer: { _ in .failing("\(Self.self).updatePlayer is unimplemented") },
- updatePushSetting: { _, _, _ in .failing("\(Self.self).updatePushSetting is unimplemented") }
+ updateAppleReceipt: { _, _ in .unimplemented("\(Self.self).updateAppleReceipt") },
+ updatePlayer: { _ in .unimplemented("\(Self.self).updatePlayer") },
+ updatePushSetting: { _, _, _ in .unimplemented("\(Self.self).updatePushSetting") }
)
}
#endif
diff --git a/Sources/DatabaseLive/DatabaseLive.swift b/Sources/DatabaseLive/DatabaseLive.swift
index e25207c0..53f33d3a 100644
--- a/Sources/DatabaseLive/DatabaseLive.swift
+++ b/Sources/DatabaseLive/DatabaseLive.swift
@@ -662,15 +662,12 @@ extension DatabaseClient {
migrate: { () -> EitherIO in
let database = pool.database(logger: Logger(label: "Postgres"))
return sequence([
- database.run(
- #"CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "heroku_ext""#
- ),
- database.run(
- #"CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "heroku_ext""#
- ),
- database.run(
- #"CREATE EXTENSION IF NOT EXISTS "citext" WITH SCHEMA "heroku_ext""#
- ),
+ database.run(#"CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "heroku_ext""#)
+ .catch { _ in database.run(#"CREATE EXTENSION IF NOT EXISTS "pgcrypto""#) },
+ database.run(#"CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "heroku_ext""#)
+ .catch { _ in database.run(#"CREATE EXTENSION IF NOT EXISTS "uuid-ossp""#) },
+ database.run(#"CREATE EXTENSION IF NOT EXISTS "citext" WITH SCHEMA "heroku_ext""#)
+ .catch { _ in database.run(#"CREATE EXTENSION IF NOT EXISTS "citext""#) },
database.run(
"""
CREATE OR REPLACE FUNCTION CURRENT_DAILY_CHALLENGE_NUMBER() RETURNS integer AS $$
diff --git a/Sources/DemoFeature/Demo.swift b/Sources/DemoFeature/Demo.swift
index b1b994c6..a5afc797 100644
--- a/Sources/DemoFeature/Demo.swift
+++ b/Sources/DemoFeature/Demo.swift
@@ -144,7 +144,7 @@ public let demoReducer = Reducer.combine
mainRunLoop: $0.mainRunLoop,
remoteNotifications: .noop,
serverConfig: .noop,
- setUserInterfaceStyle: { _ in .none },
+ setUserInterfaceStyle: { _ in },
storeKit: .noop,
userDefaults: .noop,
userNotifications: .noop
@@ -160,11 +160,9 @@ public let demoReducer = Reducer.combine
return .none
case .fullVersionButtonTapped:
- return environment.applicationClient.open(
- ServerConfig().appStoreUrl,
- [:]
- )
- .fireAndForget()
+ return .fireAndForget {
+ _ = await environment.applicationClient.open(ServerConfig().appStoreUrl, [:])
+ }
case .game(.gameOver(.submitGameResponse(.success))):
state.appStoreOverlayIsPresented = true
@@ -178,8 +176,9 @@ public let demoReducer = Reducer.combine
return .none
case .onAppear:
- return environment.audioPlayer.load(AudioPlayerClient.Sound.allCases)
- .fireAndForget()
+ return .fireAndForget {
+ await environment.audioPlayer.load(AudioPlayerClient.Sound.allCases)
+ }
case .onboarding(.delegate(.getStarted)):
state.step = .game(
@@ -199,10 +198,11 @@ public let demoReducer = Reducer.combine
}
}
)
-.onChange(of: { $0.game?.gameOver != nil }) { isGameOver, state, _, environment in
- Effect(value: .gameOverDelay)
- .delay(for: 2, scheduler: environment.mainQueue)
- .eraseToEffect()
+.onChange(of: { $0.game?.gameOver != nil }) { _, _, _, environment in
+ .task {
+ try await environment.mainQueue.sleep(for: .seconds(2))
+ return .gameOverDelay
+ }
}
public struct DemoView: View {
diff --git a/Sources/DeviceId/DeviceId.swift b/Sources/DeviceId/DeviceId.swift
index 750572c9..bb9997b9 100644
--- a/Sources/DeviceId/DeviceId.swift
+++ b/Sources/DeviceId/DeviceId.swift
@@ -25,11 +25,8 @@ extension DeviceIdentifier {
import XCTestDynamicOverlay
extension DeviceIdentifier {
- public static let failing = Self(
- id: {
- XCTFail("\(Self.self).id is unimplemented")
- return UUID()
- }
+ public static let unimplemented = Self(
+ id: XCTUnimplemented("\(Self.self).id", placeholder: UUID())
)
public static let noop = Self(
diff --git a/Sources/DictionaryClient/Mocks.swift b/Sources/DictionaryClient/Mocks.swift
index ec3e043a..956070d3 100644
--- a/Sources/DictionaryClient/Mocks.swift
+++ b/Sources/DictionaryClient/Mocks.swift
@@ -15,24 +15,12 @@ extension DictionaryClient {
import XCTestDynamicOverlay
extension DictionaryClient {
- public static let failing = Self(
- contains: { _, _ in
- XCTFail("\(Self.self).contains is unimplemented")
- return false
- },
- load: { _ in
- XCTFail("\(Self.self).load is unimplemented")
- return false
- },
- lookup: { _, _ in
- XCTFail("\(Self.self).lookup is unimplemented")
- return nil
- },
- randomCubes: { _ in
- XCTFail("\(Self.self).randomCubes is unimplemented")
- return .mock
- },
- unload: { _ in XCTFail("\(Self.self).unload is unimplemented") }
+ public static let unimplemented = Self(
+ contains: XCTUnimplemented("\(Self.self).contains", placeholder: false),
+ load: XCTUnimplemented("\(Self.self).load", placeholder: false),
+ lookup: XCTUnimplemented("\(Self.self).lookup"),
+ randomCubes: XCTUnimplemented("\(Self.self).randomCubes", placeholder: .mock),
+ unload: XCTUnimplemented("\(Self.self).unload")
)
}
#endif
diff --git a/Sources/FeedbackGeneratorClient/Client.swift b/Sources/FeedbackGeneratorClient/Client.swift
index c835e0b4..5b106ae2 100644
--- a/Sources/FeedbackGeneratorClient/Client.swift
+++ b/Sources/FeedbackGeneratorClient/Client.swift
@@ -1,6 +1,6 @@
import ComposableArchitecture
public struct FeedbackGeneratorClient {
- public var prepare: () -> Effect
- public var selectionChanged: () -> Effect
+ public var prepare: @Sendable () async -> Void
+ public var selectionChanged: @Sendable () async -> Void
}
diff --git a/Sources/FeedbackGeneratorClient/Live.swift b/Sources/FeedbackGeneratorClient/Live.swift
index b13439d2..e15f0c1a 100644
--- a/Sources/FeedbackGeneratorClient/Live.swift
+++ b/Sources/FeedbackGeneratorClient/Live.swift
@@ -1,36 +1,12 @@
import ComposableArchitecture
-
-#if canImport(UIKit)
- import UIKit
-#endif
-#if canImport(AppKit)
- import AppKit
-#endif
+import UIKit
extension FeedbackGeneratorClient {
public static var live: Self {
- #if canImport(UIKit)
- let generator = UISelectionFeedbackGenerator()
- return Self(
- prepare: {
- .fireAndForget { generator.prepare() }
- },
- selectionChanged: {
- .fireAndForget { generator.selectionChanged() }
- }
- )
- #else
- let generator = NSHapticFeedbackManager.defaultPerformer
- return Self(
- prepare: {
- .fireAndForget {}
- },
- selectionChanged: {
- .fireAndForget {
- generator.perform(.levelChange, performanceTime: .default)
- }
- }
- )
- #endif
+ let generator = UISelectionFeedbackGenerator()
+ return Self(
+ prepare: { await generator.prepare() },
+ selectionChanged: { await generator.selectionChanged() }
+ )
}
}
diff --git a/Sources/FeedbackGeneratorClient/Mocks.swift b/Sources/FeedbackGeneratorClient/Mocks.swift
index 820d8dca..20e37984 100644
--- a/Sources/FeedbackGeneratorClient/Mocks.swift
+++ b/Sources/FeedbackGeneratorClient/Mocks.swift
@@ -1,15 +1,15 @@
-import XCTestDebugSupport
+import XCTestDynamicOverlay
extension FeedbackGeneratorClient {
#if DEBUG
- public static let failing = Self(
- prepare: { .failing("\(Self.self).prepare is unimplemented") },
- selectionChanged: { .failing("\(Self.self).selectionChanged is unimplemented") }
+ public static let unimplemented = Self(
+ prepare: XCTUnimplemented("\(Self.self).prepare"),
+ selectionChanged: XCTUnimplemented("\(Self.self).selectionChanged")
)
#endif
public static let noop = Self(
- prepare: { .none },
- selectionChanged: { .none }
+ prepare: {},
+ selectionChanged: {}
)
}
diff --git a/Sources/FileClient/Client.swift b/Sources/FileClient/Client.swift
index f4525f58..d2eefb69 100644
--- a/Sources/FileClient/Client.swift
+++ b/Sources/FileClient/Client.swift
@@ -4,27 +4,15 @@ import ComposableArchitecture
import Foundation
public struct FileClient {
- public var delete: (String) -> Effect
- public var load: (String) -> Effect
- public var save: (String, Data) -> Effect
+ public var delete: @Sendable (String) async throws -> Void
+ public var load: @Sendable (String) async throws -> Data
+ public var save: @Sendable (String, Data) async throws -> Void
- public func load(
- _ type: A.Type, from fileName: String
- ) -> Effect, Never> {
- self.load(fileName)
- .decode(type: A.self, decoder: JSONDecoder())
- .mapError { $0 as NSError }
- .catchToEffect()
+ public func load(_ type: A.Type, from fileName: String) async throws -> A {
+ try await JSONDecoder().decode(A.self, from: self.load(fileName))
}
- public func save(
- _ data: A, to fileName: String, on queue: AnySchedulerOf
- ) -> Effect {
- Just(data)
- .subscribe(on: queue)
- .encode(encoder: JSONEncoder())
- .flatMap { data in self.save(fileName, data) }
- .ignoreFailure()
- .eraseToEffect()
+ public func save(_ data: A, to fileName: String) async throws -> Void {
+ try await self.save(fileName, JSONEncoder().encode(data))
}
}
diff --git a/Sources/FileClient/FileClientEffects.swift b/Sources/FileClient/FileClientEffects.swift
index 0d87fc0d..d2ab8bbb 100644
--- a/Sources/FileClient/FileClientEffects.swift
+++ b/Sources/FileClient/FileClientEffects.swift
@@ -3,14 +3,12 @@ import Combine
import ComposableArchitecture
extension FileClient {
- public func loadSavedGames() -> Effect, Never> {
- self.load(SavedGamesState.self, from: savedGamesFileName)
+ public func loadSavedGames() async throws -> SavedGamesState {
+ try await self.load(SavedGamesState.self, from: savedGamesFileName)
}
- public func saveGames(
- games: SavedGamesState, on queue: AnySchedulerOf
- ) -> Effect {
- self.save(games, to: savedGamesFileName, on: queue)
+ public func save(games: SavedGamesState) async throws {
+ try await self.save(games, to: savedGamesFileName)
}
}
diff --git a/Sources/FileClient/Live.swift b/Sources/FileClient/Live.swift
index 92a20b5b..a0a9d2a5 100644
--- a/Sources/FileClient/Live.swift
+++ b/Sources/FileClient/Live.swift
@@ -7,35 +7,20 @@ extension FileClient {
.first!
return Self(
- delete: { fileName in
- .fireAndForget {
- try? FileManager.default.removeItem(
- at:
- documentDirectory
- .appendingPathComponent(fileName)
- .appendingPathExtension("json")
- )
- }
+ delete: {
+ try FileManager.default.removeItem(
+ at: documentDirectory.appendingPathComponent($0).appendingPathExtension("json")
+ )
},
- load: { fileName in
- .catching {
- try Data(
- contentsOf:
- documentDirectory
- .appendingPathComponent(fileName)
- .appendingPathExtension("json")
- )
- }
+ load: {
+ try Data(
+ contentsOf: documentDirectory.appendingPathComponent($0).appendingPathExtension("json")
+ )
},
- save: { fileName, data in
- .fireAndForget {
- _ = try? data.write(
- to:
- documentDirectory
- .appendingPathComponent(fileName)
- .appendingPathExtension("json")
- )
- }
+ save: {
+ try $1.write(
+ to: documentDirectory.appendingPathComponent($0).appendingPathExtension("json")
+ )
}
)
}
diff --git a/Sources/FileClient/Mocks.swift b/Sources/FileClient/Mocks.swift
index 469d1e1b..e2417f43 100644
--- a/Sources/FileClient/Mocks.swift
+++ b/Sources/FileClient/Mocks.swift
@@ -5,30 +5,27 @@ import XCTestDynamicOverlay
extension FileClient {
public static let noop = Self(
- delete: { _ in .none },
- load: { _ in .none },
- save: { _, _ in .none }
+ delete: { _ in },
+ load: { _ in throw CancellationError() },
+ save: { _, _ in }
)
#if DEBUG
- public static let failing = Self(
- delete: { .failing("\(Self.self).delete(\($0)) is unimplemented") },
- load: { .failing("\(Self.self).load(\($0)) is unimplemented") },
- save: { file, _ in .failing("\(Self.self).save(\(file)) is unimplemented") }
+ public static let unimplemented = Self(
+ delete: XCTUnimplemented("\(Self.self).deleteAsync"),
+ load: XCTUnimplemented("\(Self.self).loadAsync"),
+ save: XCTUnimplemented("\(Self.self).saveAsync")
)
#endif
- public mutating func override(
- load file: String, _ data: Effect
- )
- where A: Encodable {
+ public mutating func override(load file: String, _ data: A) {
let fulfill = expectation(description: "FileClient.load(\(file))")
- self.load = { [self] in
+ self.load = { @Sendable [self] in
if $0 == file {
fulfill()
- return data.tryMap { try JSONEncoder().encode($0) }.eraseToEffect()
+ return try JSONEncoder().encode(data)
} else {
- return self.load($0)
+ return try await load($0)
}
}
}
diff --git a/Sources/GameCore/Drawer.swift b/Sources/GameCore/Drawer.swift
index 5527ae6b..099468c7 100644
--- a/Sources/GameCore/Drawer.swift
+++ b/Sources/GameCore/Drawer.swift
@@ -3,17 +3,27 @@ import ComposableArchitecture
extension Reducer where State == GameState, Action == GameAction, Environment == GameEnvironment {
static let activeGamesTray = Self { state, action, environment in
- let activeGameEffects = Effect.merge(
- environment.gameCenter.turnBasedMatch.loadMatches()
- .receive(on: environment.mainQueue.animation())
- .mapError { $0 as NSError }
- .catchToEffect(GameAction.matchesLoaded),
- environment.fileClient.loadSavedGames()
- .subscribe(on: environment.backgroundQueue)
- .receive(on: environment.mainQueue.animation())
- .eraseToEffect()
- .map(GameAction.savedGamesLoaded)
- )
+ let activeGameEffects = Effect.run { send in
+ await withThrowingTaskGroup(of: Void.self) { group in
+ group.addTask {
+ await send(
+ .matchesLoaded(
+ TaskResult { try await environment.gameCenter.turnBasedMatch.loadMatches() }
+ ),
+ animation: .default
+ )
+ }
+
+ group.addTask {
+ await send(
+ .savedGamesLoaded(
+ TaskResult { try await environment.fileClient.loadSavedGames() }
+ ),
+ animation: .default
+ )
+ }
+ }
+ }
switch action {
case .cancelButtonTapped,
@@ -52,7 +62,7 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
)
return .none
- case .onAppear:
+ case .task:
return activeGameEffects
case let .savedGamesLoaded(.success(savedGames)):
diff --git a/Sources/GameCore/GameCore.swift b/Sources/GameCore/GameCore.swift
index 15389a53..9082f9da 100644
--- a/Sources/GameCore/GameCore.swift
+++ b/Sources/GameCore/GameCore.swift
@@ -7,7 +7,6 @@ import CasePaths
import ClientModels
import ComposableArchitecture
import ComposableGameCenter
-import ComposableGameCenterHelpers
import ComposableStoreKit
import ComposableUserNotifications
import CubeCore
@@ -161,11 +160,11 @@ public enum GameAction: Equatable {
case gameLoaded
case gameOver(GameOverAction)
case lowPowerModeChanged(Bool)
- case matchesLoaded(Result<[TurnBasedMatch], NSError>)
+ case matchesLoaded(TaskResult<[TurnBasedMatch]>)
case menuButtonTapped
- case onAppear
+ case task
case pan(UIGestureRecognizer.State, PanData?)
- case savedGamesLoaded(Result)
+ case savedGamesLoaded(TaskResult)
case settingsButtonTapped
case submitButtonTapped(reaction: Move.Reaction?)
case tap(UIGestureRecognizer.State, IndexedCubeFace?)
@@ -182,7 +181,7 @@ public enum GameAction: Equatable {
public enum GameCenterAction: Equatable {
case listener(LocalPlayerClient.ListenerEvent)
- case turnBasedMatchResponse(Result)
+ case turnBasedMatchResponse(TaskResult)
}
}
@@ -202,7 +201,7 @@ public struct GameEnvironment {
public var mainRunLoop: AnySchedulerOf
public var remoteNotifications: RemoteNotificationsClient
public var serverConfig: ServerConfigClient
- public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect
+ public var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void
public var storeKit: StoreKitClient
public var userDefaults: UserDefaultsClient
public var userNotifications: UserNotificationClient
@@ -223,7 +222,7 @@ public struct GameEnvironment {
mainRunLoop: AnySchedulerOf,
remoteNotifications: RemoteNotificationsClient,
serverConfig: ServerConfigClient,
- setUserInterfaceStyle: @escaping (UIUserInterfaceStyle) -> Effect,
+ setUserInterfaceStyle: @escaping @Sendable (UIUserInterfaceStyle) async -> Void,
storeKit: StoreKitClient,
userDefaults: UserDefaultsClient,
userNotifications: UserNotificationClient
@@ -254,10 +253,6 @@ public struct GameEnvironment {
}
}
-private enum InterstitialId {}
-private enum LowPowerModeId {}
-private enum TimerId {}
-
public func gameReducer(
state: StatePath,
action: CasePath,
@@ -316,8 +311,26 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState {
else { return .none }
return .merge(
- forceQuitMatch(match: match, gameCenter: environment.gameCenter)
- .fireAndForget(),
+ .fireAndForget {
+ let localPlayer = environment.gameCenter.localPlayer.localPlayer()
+ let currentParticipantIsLocalPlayer =
+ match.currentParticipant?.player?.gamePlayerId == localPlayer.gamePlayerId
+
+ if currentParticipantIsLocalPlayer {
+ try await environment.gameCenter.turnBasedMatch.endMatchInTurn(
+ .init(
+ for: match.matchId,
+ matchData: match.matchData ?? Data(),
+ localPlayerId: localPlayer.gamePlayerId,
+ localPlayerMatchOutcome: .quit,
+ message: "\(localPlayer.displayName) forfeited the match."
+ )
+ )
+ } else {
+ try await environment.gameCenter.turnBasedMatch
+ .participantQuitOutOfTurn(match.matchId)
+ }
+ },
state.gameOver(environment: environment)
)
@@ -347,8 +360,7 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState {
return state.gameOver(environment: environment)
case .exitButtonTapped:
- return Effect.gameTearDownEffects(audioPlayer: environment.audioPlayer)
- .fireAndForget()
+ return .none
case .forfeitGameButtonTapped:
state.alert = .init(
@@ -357,7 +369,8 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState {
"""
Forfeiting will end the game and your opponent will win. Are you sure you want to \
forfeit?
- """),
+ """
+ ),
primaryButton: .default(.init("Don’t forfeit"), action: .send(.dontForfeitButtonTapped)),
secondaryButton: .destructive(.init("Yes, forfeit"), action: .send(.forfeitButtonTapped))
)
@@ -368,13 +381,14 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState {
case .gameLoaded:
state.isGameLoaded = true
- return Effect
- .timer(id: TimerId.self, every: 1, on: environment.mainRunLoop)
- .map { GameAction.timerTick($0.date) }
+ return .run { send in
+ for await instant in environment.mainRunLoop.timer(interval: .seconds(1)) {
+ await send(.timerTick(instant.date))
+ }
+ }
case .gameOver(.delegate(.close)):
- return Effect.gameTearDownEffects(audioPlayer: environment.audioPlayer)
- .fireAndForget()
+ return .none
case let .gameOver(.delegate(.startGame(inProgressGame))):
state = .init(inProgressGame: inProgressGame)
@@ -394,13 +408,45 @@ where StatePath: TcaHelpers.Path, StatePath.Value == GameState {
state.bottomMenu = .gameMenu(state: state)
return .none
- case .onAppear:
+ case .task:
guard !state.isGameOver else { return .none }
state.gameCurrentTime = environment.date()
- return .onAppearEffects(
- environment: environment,
- gameContext: state.gameContext
- )
+
+ return .run { [gameContext = state.gameContext] send in
+ await withThrowingTaskGroup(of: Void.self) { group in
+ group.addTask {
+ for await isLowPower in await environment.lowPowerMode.start() {
+ await send(.lowPowerModeChanged(isLowPower))
+ }
+ }
+
+ if gameContext.isTurnBased {
+ group.addTask {
+ let playedGamesCount = await environment.userDefaults
+ .incrementMultiplayerOpensCount()
+ let isFullGamePurchased = environment.apiClient.currentPlayer()?.appleReceipt != nil
+ guard
+ !isFullGamePurchased,
+ shouldShowInterstitial(
+ gamePlayedCount: playedGamesCount,
+ gameContext: .init(gameContext: gameContext),
+ serverConfig: environment.serverConfig.config()
+ )
+ else { return }
+ try await environment.mainRunLoop.sleep(for: .seconds(3))
+ await send(.delayedShowUpgradeInterstitial, animation: .default)
+ }
+ }
+
+ group.addTask {
+ try await environment.mainQueue.sleep(for: 0.5)
+ await send(.gameLoaded)
+ }
+ }
+ for music in AudioPlayerClient.Sound.allMusic {
+ await environment.audioPlayer.stop(music)
+ }
+ }
case .pan(.began, _):
state.isPanning = true
@@ -643,7 +689,7 @@ extension GameState {
// Don't show menu for timed games.
guard self.gameMode != .timed
- else { return .init(value: .confirmRemoveCube(index)) }
+ else { return .task { .confirmRemoveCube(index) } }
let isTurnEndingRemoval: Bool
if let turnBasedMatch = self.turnBasedContext,
@@ -657,7 +703,8 @@ extension GameState {
}
self.bottomMenu = .removeCube(
- index: index, state: self, isTurnEndingRemoval: isTurnEndingRemoval)
+ index: index, state: self, isTurnEndingRemoval: isTurnEndingRemoval
+ )
return .none
}
@@ -687,8 +734,6 @@ extension GameState {
with reaction: Move.Reaction?,
environment: GameEnvironment
) -> Effect {
- let soundEffects: Effect
-
let move = Move(
playedAt: environment.mainRunLoop.now.date,
playerIndex: self.turnBasedContext?.localPlayerIndex,
@@ -705,30 +750,23 @@ extension GameState {
previousMoves: self.moves
)
- if result != nil {
- self.moves.append(move)
- soundEffects = .merge(
- self
- .selectedWord.compactMap { !self.cubes[$0.index].isInPlay ? $0.index : nil }
- .map { index in
- environment.audioPlayer
- .play(.cubeRemove)
- .deferred(
- for: .milliseconds(removeCubeDelay(index: index)),
- scheduler: environment.mainQueue
- )
- .fireAndForget()
- }
- )
- } else {
- soundEffects = .none
- }
+ defer { self.selectedWord = [] }
- self.selectedWord = []
+ guard result != nil else { return .none }
- return
- soundEffects
- .fireAndForget()
+ self.moves.append(move)
+
+ return .fireAndForget { [self] in
+ await withThrowingTaskGroup(of: Void.self) { group in
+ for face in self.selectedWord where !self.cubes[face.index].isInPlay {
+ group.addTask {
+ try await environment.mainQueue
+ .sleep(for: .milliseconds(removeCubeDelay(index: face.index)))
+ await environment.audioPlayer.play(.cubeRemove)
+ }
+ }
+ }
+ }
}
mutating func gameOver(environment: GameEnvironment) -> Effect {
@@ -739,14 +777,11 @@ extension GameState {
isDemo: self.isDemo
)
- let saveGameEffect: Effect = environment.database
- .saveGame(.init(gameState: self))
- .receive(on: environment.mainQueue)
- .fireAndForget()
-
switch self.gameContext {
case .dailyChallenge, .shared, .solo:
- return saveGameEffect
+ return .fireAndForget { [self] in
+ try await environment.database.saveGame(.init(gameState: self))
+ }
case let .turnBased(turnBasedMatch):
self.gameOver?.turnBasedContext = turnBasedMatch
@@ -904,28 +939,29 @@ func menuTitle(state: GameState) -> TextState {
}
#if DEBUG
+ import XCTestDynamicOverlay
+
extension GameEnvironment {
- public static let failing = Self(
- apiClient: .failing,
- applicationClient: .failing,
- audioPlayer: .failing,
- backgroundQueue: .failing("backgroundQueue"),
- build: .failing,
- database: .failing,
- dictionary: .failing,
- feedbackGenerator: .failing,
- fileClient: .failing,
- gameCenter: .failing,
- lowPowerMode: .failing,
- mainQueue: .failing("mainQueue"),
- mainRunLoop: .failing("mainRunLoop"),
- remoteNotifications: .failing,
- serverConfig: .failing,
- setUserInterfaceStyle: { _ in .failing("\(Self.self).setUserInterfaceStyle is unimplemented")
- },
- storeKit: .failing,
- userDefaults: .failing,
- userNotifications: .failing
+ public static let unimplemented = Self(
+ apiClient: .unimplemented,
+ applicationClient: .unimplemented,
+ audioPlayer: .unimplemented,
+ backgroundQueue: .unimplemented("backgroundQueue"),
+ build: .unimplemented,
+ database: .unimplemented,
+ dictionary: .unimplemented,
+ feedbackGenerator: .unimplemented,
+ fileClient: .unimplemented,
+ gameCenter: .unimplemented,
+ lowPowerMode: .unimplemented,
+ mainQueue: .unimplemented("mainQueue"),
+ mainRunLoop: .unimplemented("mainRunLoop"),
+ remoteNotifications: .unimplemented,
+ serverConfig: .unimplemented,
+ setUserInterfaceStyle: XCTUnimplemented("\(Self.self).setUserInterfaceStyle"),
+ storeKit: .unimplemented,
+ userDefaults: .unimplemented,
+ userNotifications: .unimplemented
)
public static let noop = Self(
@@ -944,7 +980,7 @@ func menuTitle(state: GameState) -> TextState {
mainRunLoop: .immediate,
remoteNotifications: .noop,
serverConfig: .noop,
- setUserInterfaceStyle: { _ in .none },
+ setUserInterfaceStyle: { _ in },
storeKit: .noop,
userDefaults: .noop,
userNotifications: .noop
@@ -952,44 +988,6 @@ func menuTitle(state: GameState) -> TextState {
}
#endif
-extension Effect where Output == GameAction, Failure == Never {
- static func onAppearEffects(
- environment: GameEnvironment,
- gameContext: ClientModels.GameContext
- ) -> Self {
- .merge(
- environment.lowPowerMode.start
- .receive(on: environment.mainQueue)
- .eraseToEffect()
- .map(GameAction.lowPowerModeChanged)
- .cancellable(id: LowPowerModeId.self),
-
- Effect(value: .gameLoaded)
- .delay(for: 0.5, scheduler: environment.mainQueue)
- .eraseToEffect(),
-
- gameContext.isTurnBased
- ? Effect.showUpgradeInterstitial(
- gameContext: .init(gameContext: gameContext),
- isFullGamePurchased: environment.apiClient.currentPlayer()?.appleReceipt != nil,
- serverConfig: environment.serverConfig.config(),
- playedGamesCount: {
- environment.userDefaults.incrementMultiplayerOpensCount()
- .setFailureType(to: Error.self)
- .eraseToEffect()
- }
- )
- .filter { $0 }
- .delay(for: 3, scheduler: environment.mainRunLoop.animation())
- .map { _ in GameAction.delayedShowUpgradeInterstitial }
- .ignoreFailure()
- .eraseToEffect()
- .cancellable(id: InterstitialId.self)
- : .none
- )
- }
-}
-
extension UpgradeInterstitialFeature.GameContext {
fileprivate init(gameContext: ClientModels.GameContext) {
switch gameContext {
@@ -1005,20 +1003,6 @@ extension UpgradeInterstitialFeature.GameContext {
}
}
-extension Effect where Output == Never, Failure == Never {
- public static func gameTearDownEffects(audioPlayer: AudioPlayerClient) -> Self {
- .merge(
- .cancel(id: InterstitialId.self),
- .cancel(id: ListenerId.self),
- .cancel(id: LowPowerModeId.self),
- .cancel(id: TimerId.self),
- Effect
- .merge(AudioPlayerClient.Sound.allMusic.map(audioPlayer.stop))
- .fireAndForget()
- )
- }
-}
-
extension Reducer where State == GameState, Action == GameAction, Environment == GameEnvironment {
static let removingCubesWithDoubleTap: Self = Self { state, action, _ in
guard
@@ -1084,19 +1068,13 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
isDemo: state.isDemo,
turnBasedContext: state.turnBasedContext
)
- return .merge(
- environment.gameCenter.turnBasedMatch.remove(match)
- .fireAndForget(),
-
- environment.feedbackGenerator
- .selectionChanged()
- .fireAndForget()
- )
+ return .fireAndForget {
+ await environment.feedbackGenerator.selectionChanged()
+ try await environment.gameCenter.turnBasedMatch.remove(match)
+ }
}
- return environment.feedbackGenerator
- .selectionChanged()
- .fireAndForget()
+ return .fireAndForget { await environment.feedbackGenerator.selectionChanged() }
case let .gameCenter(.turnBasedMatchResponse(.success(match))):
guard
@@ -1119,12 +1097,14 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
case .gameOver(.delegate(.close)),
.exitButtonTapped:
- return .cancel(id: ListenerId.self)
+ return .none
- case .onAppear:
- return environment.gameCenter.localPlayer.listener
- .map { .gameCenter(.listener($0)) }
- .cancellable(id: ListenerId.self)
+ case .task:
+ return .run { send in
+ for await event in environment.gameCenter.localPlayer.listener() {
+ await send(.gameCenter(.listener(event)))
+ }
+ }
case .submitButtonTapped,
.wordSubmitButton(.delegate(.confirmSubmit)),
@@ -1141,81 +1121,73 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
playerId: environment.apiClient.currentPlayer()?.player.id
)
let matchData = Data(turnBasedMatchData: turnBasedMatchData)
- let reloadMatch = environment.gameCenter.turnBasedMatch.load(turnBasedContext.match.matchId)
- .mapError { $0 as NSError }
- .catchToEffect { GameAction.gameCenter(.turnBasedMatchResponse($0)) }
- if state.isGameOver {
- let completedGame = CompletedGame(gameState: state)
- guard
- let completedMatch = CompletedMatch(
- completedGame: completedGame,
- turnBasedContext: turnBasedContext
- )
- else { return .none }
-
- return .concatenate(
- environment.gameCenter.turnBasedMatch
- .endMatchInTurn(
- .init(
- for: turnBasedContext.match.matchId,
- matchData: matchData,
- localPlayerId: turnBasedContext.localPlayer.gamePlayerId,
- localPlayerMatchOutcome: completedMatch.yourOutcome,
- message: "Game over! Let’s see how you did!"
- )
- )
- .fireAndForget(),
-
- reloadMatch,
-
- environment.database.saveGame(completedGame)
- .fireAndForget()
- )
- } else {
- switch move.type {
- case .removedCube:
- let shouldEndTurn =
- state.moves.count > 1
- && state.moves[state.moves.count - 2].playerIndex == turnBasedContext.localPlayerIndex
-
- return .concatenate(
- shouldEndTurn
- ? environment.gameCenter.turnBasedMatch
- .endTurn(
- .init(
- for: turnBasedContext.match.matchId,
- matchData: matchData,
- message: "\(turnBasedContext.localPlayer.displayName) removed cubes!"
+ return .task { [state] in
+ return await .gameCenter(
+ .turnBasedMatchResponse(
+ TaskResult {
+ if state.isGameOver {
+ let completedGame = CompletedGame(gameState: state)
+ if
+ let completedMatch = CompletedMatch(
+ completedGame: completedGame,
+ turnBasedContext: turnBasedContext
)
- )
- .fireAndForget()
-
- : environment.gameCenter.turnBasedMatch
- .saveCurrentTurn(turnBasedContext.match.matchId, matchData)
- .fireAndForget(),
- reloadMatch
- )
- case let .playedWord(cubeFaces):
- let word = state.cubes.string(from: cubeFaces)
- let score = SharedModels.score(word)
- let reaction = (move.reactions?.values.first).map { " \($0.rawValue)" } ?? ""
-
- return .concatenate(
- environment.gameCenter.turnBasedMatch
- .endTurn(
- .init(
- for: turnBasedContext.match.matchId,
- matchData: matchData,
- message:
- "\(turnBasedContext.localPlayer.displayName) played \(word)! (+\(score)\(reaction))"
- )
- )
- .fireAndForget(),
-
- reloadMatch
+ {
+ try await environment.gameCenter.turnBasedMatch.endMatchInTurn(
+ .init(
+ for: turnBasedContext.match.matchId,
+ matchData: matchData,
+ localPlayerId: turnBasedContext.localPlayer.gamePlayerId,
+ localPlayerMatchOutcome: completedMatch.yourOutcome,
+ message: "Game over! Let’s see how you did!"
+ )
+ )
+ try await environment.database.saveGame(completedGame)
+ }
+ } else {
+ switch move.type {
+ case .removedCube:
+ let shouldEndTurn =
+ state.moves.count > 1
+ && state.moves[state.moves.count - 2].playerIndex
+ == turnBasedContext.localPlayerIndex
+
+ if shouldEndTurn {
+ try await environment.gameCenter.turnBasedMatch.endTurn(
+ .init(
+ for: turnBasedContext.match.matchId,
+ matchData: matchData,
+ message: "\(turnBasedContext.localPlayer.displayName) removed cubes!"
+ )
+ )
+ } else {
+ try await environment.gameCenter.turnBasedMatch
+ .saveCurrentTurn(turnBasedContext.match.matchId, matchData)
+ }
+
+ case let .playedWord(cubeFaces):
+ let word = state.cubes.string(from: cubeFaces)
+ let score = SharedModels.score(word)
+ let reaction = (move.reactions?.values.first).map { " \($0.rawValue)" } ?? ""
+
+ try await environment.gameCenter.turnBasedMatch.endTurn(
+ .init(
+ for: turnBasedContext.match.matchId,
+ matchData: matchData,
+ message: """
+ \(turnBasedContext.localPlayer.displayName) played \(word)! \
+ (+\(score)\(reaction))
+ """
+ )
+ )
+ }
+ }
+ return try await environment.gameCenter.turnBasedMatch
+ .load(turnBasedContext.match.matchId)
+ }
)
- }
+ )
}
default:
return .none
@@ -1245,5 +1217,3 @@ extension CompletedGame {
)
}
}
-
-enum ListenerId {}
diff --git a/Sources/GameCore/SoundsCore.swift b/Sources/GameCore/SoundsCore.swift
index 60d984ef..ea392368 100644
--- a/Sources/GameCore/SoundsCore.swift
+++ b/Sources/GameCore/SoundsCore.swift
@@ -9,27 +9,24 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
.combined(
with: .init { state, action, environment in
switch action {
- case .onAppear:
- let soundEffect: Effect
- if state.gameMode == .timed {
- soundEffect = environment.audioPlayer
- .play(
- state.isDemo
- ? .timedGameBgLoop1
- : [.timedGameBgLoop1, .timedGameBgLoop2].randomElement()!
- )
+ case .task:
+ return .fireAndForget { [gameMode = state.gameMode, isDemo = state.isDemo] in
+ if gameMode == .timed {
+ await environment.audioPlayer
+ .play(
+ isDemo
+ ? .timedGameBgLoop1
+ : [.timedGameBgLoop1, .timedGameBgLoop2].randomElement()!
+ )
- } else {
- soundEffect = environment.audioPlayer
- .loop([.unlimitedGameBgLoop1, .unlimitedGameBgLoop2].randomElement()!)
+ } else {
+ await environment.audioPlayer
+ .loop([.unlimitedGameBgLoop1, .unlimitedGameBgLoop2].randomElement()!)
+ }
}
- return
- soundEffect
- .fireAndForget()
case .confirmRemoveCube:
- return environment.audioPlayer.play(.cubeRemove)
- .fireAndForget()
+ return .fireAndForget { await environment.audioPlayer.play(.cubeRemove) }
default:
return .none
@@ -37,27 +34,20 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
}
)
.onChange(of: { $0.gameOver == nil }) { _, _, _, environment in
- .merge(
- Effect
- .merge(
- AudioPlayerClient.Sound.allMusic
- .filter { $0 != .gameOverMusicLoop }
- .map(environment.audioPlayer.stop)
- )
- .fireAndForget(),
-
- .cancel(id: CubeShakingId())
- )
+ .fireAndForget {
+ await Task.cancel(id: CubeShakingID.self)
+ for music in AudioPlayerClient.Sound.allMusic where music != .gameOverMusicLoop {
+ await environment.audioPlayer.stop(music)
+ }
+ }
}
.onChange(of: \.secondsPlayed) { secondsPlayed, state, _, environment in
if secondsPlayed == state.gameMode.seconds - 10 {
- return environment.audioPlayer.play(.timed10SecWarning)
- .fireAndForget()
+ return .fireAndForget { await environment.audioPlayer.play(.timed10SecWarning) }
} else if secondsPlayed >= state.gameMode.seconds - 5
&& secondsPlayed <= state.gameMode.seconds
{
- return environment.audioPlayer.play(.timedCountdownTone)
- .fireAndForget()
+ return .fireAndForget { await environment.audioPlayer.play(.timedCountdownTone) }
} else {
return .none
}
@@ -72,19 +62,17 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
switch action {
case .submitButtonTapped, .wordSubmitButton(.delegate(.confirmSubmit)):
- return environment.audioPlayer.play(.invalidWord)
- .fireAndForget()
+ return .fireAndForget { await environment.audioPlayer.play(.invalidWord) }
default:
- return environment.audioPlayer.play(.cubeDeselect)
- .fireAndForget()
+ return .fireAndForget { await environment.audioPlayer.play(.cubeDeselect) }
}
}
.onChange(of: \.selectedWord) { previousSelection, selectedWord, state, _, environment in
guard !selectedWord.isEmpty
else {
state.cubeStartedShakingAt = nil
- return .cancel(id: CubeShakingId())
+ return .cancel(id: CubeShakingID.self)
}
let previousWord = state.cubes.string(from: previousSelection)
@@ -101,21 +89,17 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
if cubeIsShaking {
state.cubeStartedShakingAt = state.cubeStartedShakingAt ?? environment.date()
- return cubeWasShaking
- ? .none
- : Effect.timer(
- id: CubeShakingId(),
- every: .seconds(2),
- on: environment.mainQueue
- )
- .flatMap { _ in environment.audioPlayer.play(.cubeShake) }
- .merge(with: environment.audioPlayer.play(.cubeShake))
- .eraseToEffect()
- .fireAndForget()
+ return cubeWasShaking ? .none : .fireAndForget {
+ await environment.audioPlayer.play(.cubeShake)
+ for await _ in environment.mainQueue.timer(interval: .seconds(2)) {
+ await environment.audioPlayer.play(.cubeShake)
+ }
+ }
+ .cancellable(id: CubeShakingID.self)
} else {
state.cubeStartedShakingAt = nil
- return .cancel(id: CubeShakingId())
+ return .cancel(id: CubeShakingID.self)
}
}
.onChange(of: \.moves.last) { lastMove, state, _, environment in
@@ -132,8 +116,9 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
.remainder
)
- return environment.audioPlayer.play(AudioPlayerClient.Sound.allSubmits[firstIndex])
- .fireAndForget()
+ return .fireAndForget {
+ await environment.audioPlayer.play(AudioPlayerClient.Sound.allSubmits[firstIndex])
+ }
}
.selectionSounds(
audioPlayer: \.audioPlayer,
@@ -149,7 +134,7 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
}
}
-private struct CubeShakingId: Hashable {}
+private enum CubeShakingID {}
extension GameState {
func hasBeenPlayed(word: String) -> Bool {
diff --git a/Sources/GameCore/TurnBased.swift b/Sources/GameCore/TurnBased.swift
index 8a798211..4422381d 100644
--- a/Sources/GameCore/TurnBased.swift
+++ b/Sources/GameCore/TurnBased.swift
@@ -26,7 +26,7 @@ extension Reducer where State == GameState, Action == GameAction, Environment ==
.lowPowerModeChanged,
.matchesLoaded,
.menuButtonTapped,
- .onAppear,
+ .task,
.savedGamesLoaded,
.settingsButtonTapped,
.timerTick,
diff --git a/Sources/GameCore/Views/GameView.swift b/Sources/GameCore/Views/GameView.swift
index 56c652f2..a4fa00c2 100644
--- a/Sources/GameCore/Views/GameView.swift
+++ b/Sources/GameCore/Views/GameView.swift
@@ -184,6 +184,6 @@ public struct GameView: View where Content: View {
dismiss: .dismiss
)
}
- .onAppear { self.viewStore.send(.onAppear) }
+ .task { await self.viewStore.send(.task).finish() }
}
}
diff --git a/Sources/GameCore/Views/WordSubmitButton.swift b/Sources/GameCore/Views/WordSubmitButton.swift
index 16a3565e..313e79b4 100644
--- a/Sources/GameCore/Views/WordSubmitButton.swift
+++ b/Sources/GameCore/Views/WordSubmitButton.swift
@@ -66,7 +66,7 @@ let wordSubmitReducer = Reducer<
WordSubmitButtonFeatureState, WordSubmitButtonAction, WordSubmitEnvironment
> { state, action, environment in
- struct SubmitButtonPressedDelayId: Hashable {}
+ enum SubmitButtonPressedDelayID {}
guard state.isYourTurn
else { return .none }
@@ -74,67 +74,45 @@ let wordSubmitReducer = Reducer<
switch action {
case .backgroundTapped:
state.wordSubmitButton.areReactionsOpen = false
- return environment.audioPlayer.play(.uiSfxEmojiClose)
- .fireAndForget()
+ return .fireAndForget { await environment.audioPlayer.play(.uiSfxEmojiClose) }
case .delayedSubmitButtonPressed:
state.wordSubmitButton.areReactionsOpen = true
- return .merge(
- environment.feedbackGenerator.selectionChanged()
- .fireAndForget(),
-
- environment.audioPlayer.play(.uiSfxEmojiOpen)
- .fireAndForget()
- )
+ return .fireAndForget {
+ await environment.feedbackGenerator.selectionChanged()
+ await environment.audioPlayer.play(.uiSfxEmojiOpen)
+ }
case .delegate:
return .none
case let .reactionButtonTapped(reaction):
state.wordSubmitButton.areReactionsOpen = false
- return .merge(
- environment.feedbackGenerator.selectionChanged()
- .fireAndForget(),
-
- environment.audioPlayer.play(.uiSfxEmojiSend)
- .fireAndForget(),
-
- Effect(value: .delegate(.confirmSubmit(reaction: reaction)))
- )
+ return .task {
+ await environment.feedbackGenerator.selectionChanged()
+ await environment.audioPlayer.play(.uiSfxEmojiSend)
+ return .delegate(.confirmSubmit(reaction: reaction))
+ }
case .submitButtonPressed:
guard state.isTurnBasedMatch
else { return .none }
- let closeSound: Effect
if state.wordSubmitButton.areReactionsOpen {
state.wordSubmitButton.isClosing = true
- closeSound = environment.audioPlayer.play(.uiSfxEmojiClose)
- } else {
- closeSound = .none
}
state.wordSubmitButton.areReactionsOpen = false
state.wordSubmitButton.isSubmitButtonPressed = true
- let longPressEffect: Effect
- if state.isSelectedWordValid {
- longPressEffect = Effect(value: .delayedSubmitButtonPressed)
- .delay(for: 0.5, scheduler: environment.mainQueue)
- .eraseToEffect()
- .cancellable(id: SubmitButtonPressedDelayId(), cancelInFlight: true)
- } else {
- longPressEffect = .none
+ return .task { [isClosing = state.wordSubmitButton.isClosing] in
+ await environment.feedbackGenerator.selectionChanged()
+ if isClosing {
+ await environment.audioPlayer.play(.uiSfxEmojiClose)
+ }
+ try await environment.mainQueue.sleep(for: 0.5)
+ return .delayedSubmitButtonPressed
}
-
- return .merge(
- longPressEffect,
-
- closeSound
- .fireAndForget(),
-
- environment.feedbackGenerator.selectionChanged()
- .fireAndForget()
- )
+ .cancellable(id: SubmitButtonPressedDelayID.self, cancelInFlight: true)
case .submitButtonReleased:
guard state.isTurnBasedMatch
@@ -143,19 +121,19 @@ let wordSubmitReducer = Reducer<
let wasClosing = state.wordSubmitButton.isClosing
state.wordSubmitButton.isClosing = false
state.wordSubmitButton.isSubmitButtonPressed = false
- return .merge(
- .cancel(id: SubmitButtonPressedDelayId()),
- wasClosing || state.wordSubmitButton.areReactionsOpen
- ? .none
- : Effect(value: .delegate(.confirmSubmit(reaction: nil)))
- )
+ return .run { [areReactionsOpen = state.wordSubmitButton.areReactionsOpen] send in
+ await Task.cancel(id: SubmitButtonPressedDelayID.self)
+ guard !wasClosing && !areReactionsOpen
+ else { return }
+ await send(.delegate(.confirmSubmit(reaction: nil)))
+ }
case .submitButtonTapped:
guard !state.isTurnBasedMatch
else { return .none }
- return Effect(value: .delegate(.confirmSubmit(reaction: nil)))
+ return .task { .delegate(.confirmSubmit(reaction: nil)) }
}
}
@@ -236,10 +214,8 @@ public struct WordSubmitButton: View {
? Color.isowordsBlack.opacity(0.4)
: nil
)
- .animation(.default)
- .onTapGesture {
- self.viewStore.send(.backgroundTapped, animation: .default)
- }
+ .animation(.default, value: self.viewStore.wordSubmitButton.areReactionsOpen)
+ .onTapGesture { self.viewStore.send(.backgroundTapped, animation: .default) }
}
}
@@ -268,7 +244,8 @@ struct ReactionsView: View {
.opacity(self.viewStore.areReactionsOpen ? 1 : 0)
.offset(x: offset.x, y: offset.y)
.animation(
- Animation.default.delay(Double(idx) / Double(self.viewStore.favoriteReactions.count * 10))
+ .default.delay(Double(idx) / Double(self.viewStore.favoriteReactions.count * 10)),
+ value: self.viewStore.areReactionsOpen
)
}
}
diff --git a/Sources/GameFeature/GameFeature.swift b/Sources/GameFeature/GameFeature.swift
index 8c797049..0caad779 100644
--- a/Sources/GameFeature/GameFeature.swift
+++ b/Sources/GameFeature/GameFeature.swift
@@ -19,9 +19,9 @@ public struct GameFeatureState: Equatable {
}
public enum GameFeatureAction: Equatable {
+ case dismissSettings
case game(GameAction)
case settings(SettingsAction)
- case onDisappear
}
public let gameFeatureReducer = Reducer
@@ -61,11 +61,7 @@ public let gameFeatureReducer = Reducer: View where Content: View {
isAnimationReduced: viewStore.state,
store: store.scope(state: { $0 }, action: GameFeatureAction.game)
)
- .onDisappear { viewStore.send(.onDisappear) }
}
}
)
@@ -39,12 +38,7 @@ public struct GameFeatureView: View where Content: View {
// using an .alert/.sheet modifier, then the child view’s alert/sheet will never appear:
// https://gist.github.com/mbrandonw/82ece7c62afb370a875fd1db2f9a236e
EmptyView()
- .sheet(
- isPresented: viewStore.binding(
- get: { $0 },
- send: .settings(.onDismiss)
- )
- ) {
+ .sheet(isPresented: viewStore.binding(send: .dismissSettings)) {
NavigationView {
SettingsView(
store: self.store.scope(
@@ -124,7 +118,7 @@ public struct GameFeatureView: View where Content: View {
mainRunLoop: .main,
remoteNotifications: .noop,
serverConfig: .noop,
- setUserInterfaceStyle: { _ in .none },
+ setUserInterfaceStyle: { _ in },
storeKit: .live(),
userDefaults: .noop,
userNotifications: .live
diff --git a/Sources/GameOverFeature/GameOverView.swift b/Sources/GameOverFeature/GameOverView.swift
index 3be54a1d..ba82986a 100644
--- a/Sources/GameOverFeature/GameOverView.swift
+++ b/Sources/GameOverFeature/GameOverView.swift
@@ -76,17 +76,17 @@ public struct GameOverState: Equatable {
public enum GameOverAction: Equatable {
case closeButtonTapped
- case dailyChallengeResponse(Result<[FetchTodaysDailyChallengeResponse], ApiError>)
+ case dailyChallengeResponse(TaskResult<[FetchTodaysDailyChallengeResponse]>)
case delayedOnAppear
case delayedShowUpgradeInterstitial
case delegate(DelegateAction)
case gameButtonTapped(GameMode)
- case onAppear
case notificationsAuthAlert(NotificationsAuthAlertAction)
case rematchButtonTapped
case showConfetti
- case startDailyChallengeResponse(Result)
- case submitGameResponse(Result)
+ case startDailyChallengeResponse(TaskResult)
+ case task
+ case submitGameResponse(TaskResult)
case upgradeInterstitial(UpgradeInterstitialAction)
case userNotificationSettingsResponse(UserNotificationClient.Notification.Settings)
@@ -134,19 +134,39 @@ public struct GameOverEnvironment {
}
#if DEBUG
- public static let failing = Self(
- apiClient: .failing,
- audioPlayer: .failing,
- database: .failing,
- fileClient: .failing,
- mainRunLoop: .failing,
- remoteNotifications: .failing,
- serverConfig: .failing,
- storeKit: .failing,
- userDefaults: .failing,
- userNotifications: .failing
+ public static let unimplemented = Self(
+ apiClient: .unimplemented,
+ audioPlayer: .unimplemented,
+ database: .unimplemented,
+ fileClient: .unimplemented,
+ mainRunLoop: .unimplemented,
+ remoteNotifications: .unimplemented,
+ serverConfig: .unimplemented,
+ storeKit: .unimplemented,
+ userDefaults: .unimplemented,
+ userNotifications: .unimplemented
)
#endif
+
+ func requestReviewAsync() async throws {
+ let stats = try await self.database.fetchStats()
+ let hasRequestedReviewBefore =
+ self.userDefaults.doubleForKey(lastReviewRequestTimeIntervalKey) != 0
+ let timeSinceLastReviewRequest =
+ self.mainRunLoop.now.date.timeIntervalSince1970
+ - self.userDefaults.doubleForKey(lastReviewRequestTimeIntervalKey)
+ let weekInSeconds: Double = 60 * 60 * 24 * 7
+
+ if stats.gamesPlayed >= 3
+ && (!hasRequestedReviewBefore || timeSinceLastReviewRequest >= weekInSeconds)
+ {
+ await self.storeKit.requestReview()
+ await self.userDefaults.setDouble(
+ self.mainRunLoop.now.date.timeIntervalSince1970,
+ lastReviewRequestTimeIntervalKey
+ )
+ }
+ }
}
public let gameOverReducer = Reducer.combine(
@@ -179,13 +199,6 @@ public let gameOverReducer = Reducer {
- // Effect(value: .showConfetti)
- // .delay(for: 1, scheduler: environment.mainQueue)
- // .eraseToEffect()
- // }
-
switch action {
case .closeButtonTapped:
guard
@@ -193,10 +206,10 @@ public let gameOverReducer = Reducer 0
- else {
- return Effect(value: .delegate(.close))
- .receive(on: ImmediateScheduler.shared.animation(.default))
- .eraseToEffect()
- }
-
- let submitGameEffect: Effect
- if state.isDemo {
- submitGameEffect = environment.apiClient.request(
- route: .demo(
- .submitGame(
- .init(
- gameMode: state.completedGame.gameMode,
- score: state.completedGame.currentScore
- )
- )
- ),
- as: LeaderboardScoreResult.self
- )
- .receive(on: environment.mainRunLoop.animation(.default))
- .map(SubmitGameResponse.solo)
- .catchToEffect(GameOverAction.submitGameResponse)
- } else if let request = ServerRoute.Api.Route.Games.SubmitRequest(
- completedGame: state.completedGame)
- {
- submitGameEffect = environment.apiClient.apiRequest(
- route: .games(.submit(request)),
- as: SubmitGameResponse.self
- )
- .receive(on: environment.mainRunLoop.animation(.default))
- .catchToEffect(GameOverAction.submitGameResponse)
- } else {
- submitGameEffect = .none
- }
-
- // let turnBasedConfettiEffect = state.turnBasedContext?.localParticipant?.matchOutcome == .won
- // ? showConfetti
- // : .none
-
- return .merge(
- Effect(value: .delayedOnAppear)
- .delay(for: 2, scheduler: environment.mainRunLoop)
- .eraseToEffect(),
-
- submitGameEffect,
-
- // turnBasedConfettiEffect,
-
- Effect.showUpgradeInterstitial(
- gameContext: .init(gameContext: state.completedGame.gameContext),
- isFullGamePurchased: environment.apiClient.currentPlayer()?.appleReceipt != nil,
- serverConfig: environment.serverConfig.config(),
- playedGamesCount: {
- environment.database.playedGamesCount(
- .init(gameContext: state.completedGame.gameContext)
- )
- }
- )
- .flatMap { showUpgrade in
- showUpgrade
- ? Effect(value: GameOverAction.delayedShowUpgradeInterstitial)
- .delay(for: 1, scheduler: environment.mainRunLoop.animation(.easeIn))
- .eraseToEffect()
- : Effect.none
- }
- .ignoreFailure()
- .eraseToEffect(),
-
- environment.userNotifications.getNotificationSettings
- .receive(on: environment.mainRunLoop)
- .map(GameOverAction.userNotificationSettingsResponse)
- .eraseToEffect(),
-
- environment.audioPlayer.loop(.gameOverMusicLoop)
- .fireAndForget(),
-
- environment.audioPlayer.play(.transitionIn)
- .fireAndForget()
- )
-
case .notificationsAuthAlert(.delegate(.close)):
state.notificationsAuthAlert = nil
- return .merge(
- Effect(value: .delegate(.close))
- .receive(on: ImmediateScheduler.shared.animation())
- .eraseToEffect(),
- .reviewRequestEffect(environment: environment)
- )
+ return .run { send in
+ try? await environment.requestReviewAsync()
+ await send(.delegate(.close), animation: .default)
+ }
case .notificationsAuthAlert(.delegate(.didChooseNotificationSettings)):
- return Effect(value: .delegate(.close))
- .receive(on: ImmediateScheduler.shared.animation())
- .eraseToEffect()
+ return .task { .delegate(.close) }.animation()
case .notificationsAuthAlert:
return .none
@@ -354,9 +283,6 @@ public let gameOverReducer = Reducer 0
+ else {
+ await send(.delegate(.close), animation: .default)
+ return
+ }
+
+ await environment.audioPlayer.play(.transitionIn)
+ await environment.audioPlayer.loop(.gameOverMusicLoop)
+
+ await withThrowingTaskGroup(of: Void.self) { group in
+ group.addTask {
+ if isDemo {
+ let request = ServerRoute.Demo.SubmitRequest(
+ gameMode: completedGame.gameMode,
+ score: completedGame.currentScore
+ )
+ await send(
+ .submitGameResponse(
+ TaskResult {
+ try await .solo(
+ environment.apiClient.request(
+ route: .demo(.submitGame(request)),
+ as: LeaderboardScoreResult.self
+ )
+ )
+ }
+ ),
+ animation: .default
+ )
+ } else if let request = ServerRoute.Api.Route.Games.SubmitRequest(
+ completedGame: completedGame
+ ) {
+ await send(
+ .submitGameResponse(
+ TaskResult {
+ try await environment.apiClient.apiRequest(
+ route: .games(.submit(request)),
+ as: SubmitGameResponse.self
+ )
+ }
+ ),
+ animation: .default
+ )
+ }
+ }
+
+ group.addTask {
+ try await environment.mainRunLoop.sleep(for: .seconds(1))
+ let playedGamesCount = try await environment.database
+ .playedGamesCount(.init(gameContext: completedGame.gameContext))
+ let isFullGamePurchased =
+ environment.apiClient.currentPlayer()?.appleReceipt != nil
+ guard
+ !isFullGamePurchased,
+ shouldShowInterstitial(
+ gamePlayedCount: playedGamesCount,
+ gameContext: .init(gameContext: completedGame.gameContext),
+ serverConfig: environment.serverConfig.config()
+ )
+ else { return }
+ await send(.delayedShowUpgradeInterstitial, animation: .easeIn)
+ }
+
+ group.addTask {
+ try await environment.mainRunLoop.sleep(for: .seconds(2))
+ await send(.delayedOnAppear)
+ }
+
+ group.addTask {
+ await send(
+ .userNotificationSettingsResponse(
+ environment.userNotifications.getNotificationSettings()
+ )
+ )
+ }
+ }
+ }
+
case .upgradeInterstitial(.delegate(.close)),
.upgradeInterstitial(.delegate(.fullGamePurchased)):
state.upgradeInterstitial = nil
@@ -543,10 +545,9 @@ public struct GameOverView: View {
foregroundColor: self.colorScheme == .dark ? .isowordsBlack : self.color
)
)
- .padding([.bottom], .grid(self.viewStore.isDemo ? 30 : 0))
+ .padding(.bottom, .grid(self.viewStore.isDemo ? 30 : 0))
}
- .padding([.vertical], .grid(12))
-
+ .padding(.vertical, .grid(12))
}
IfLetStore(
@@ -565,7 +566,7 @@ public struct GameOverView: View {
(self.colorScheme == .dark ? .isowordsBlack : self.color)
.ignoresSafeArea()
)
- .onAppear { self.viewStore.send(.onAppear) }
+ .task { await self.viewStore.send(.task).finish() }
.notificationsAlert(
store: self.store.scope(
state: \.notificationsAuthAlert,
@@ -593,9 +594,9 @@ public struct GameOverView: View {
}
?? Text("Loading your rank!")
}
- .animation(nil)
+ .animation(.default, value: result)
.adaptiveFont(.matter, size: 52)
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
.minimumScaleFactor(0.01)
.lineLimit(2)
.multilineTextAlignment(.center)
@@ -636,8 +637,8 @@ public struct GameOverView: View {
Text("\(self.viewStore.yourWords.count)")
}
}
- .adaptivePadding([.leading, .trailing])
- .animation(nil)
+ .adaptivePadding(.horizontal)
+ .animation(.default, value: result)
self.wordList
@@ -672,7 +673,7 @@ public struct GameOverView: View {
.disabled(self.viewStore.gameModeIsLoading != nil)
}
}
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
}
}
.adaptiveFont(.matterMedium, size: 16)
@@ -686,7 +687,7 @@ public struct GameOverView: View {
+ Text(praise(mode: self.viewStore.gameMode, score: self.viewStore.yourScore))
}
.adaptiveFont(.matter, size: 52)
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
.minimumScaleFactor(0.01)
.lineLimit(2)
.multilineTextAlignment(.center)
@@ -707,17 +708,20 @@ public struct GameOverView: View {
let rank = (/GameOverState.RankSummary.leaderboard)
.extract(from: self.viewStore.summary)?[timeScope]
Text(
- "\((rank?.rank ?? 0) as NSNumber, formatter: ordinalFormatter) of \(rank?.outOf ?? 0)"
+ """
+ \((rank?.rank ?? 0) as NSNumber, formatter: ordinalFormatter) of \
+ \(rank?.outOf ?? 0)
+ """
)
.redacted(reason: rank == nil ? .placeholder : [])
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
- .animation(nil)
+ .animation(.default, value: self.viewStore.summary)
}
.adaptiveFont(.matterMedium, size: 16)
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
.overlay(
self.viewStore.showConfetti
? Confetti(
@@ -733,7 +737,7 @@ public struct GameOverView: View {
VStack(spacing: self.adaptiveSize.pad(8)) {
Text("Play again")
.adaptiveFont(.matterMedium, size: 16)
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
.frame(maxWidth: .infinity, alignment: .leading)
LazyVGrid(
@@ -762,13 +766,13 @@ public struct GameOverView: View {
action: { self.viewStore.send(.gameButtonTapped(.unlimited), animation: .default) }
)
}
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
}
}
}
}
- struct DividerId: Hashable {}
+ struct DividerID: Hashable {}
@State var containerWidth: CGFloat = 0
@State var dividerOffset: CGFloat = 0
@@ -830,7 +834,7 @@ public struct GameOverView: View {
.clipShape(Circle())
}
.frame(maxWidth: .infinity)
- .padding([.leading, .trailing], .grid(2))
+ .padding(.horizontal, .grid(2))
Divider()
.frame(height: 2)
@@ -848,13 +852,13 @@ public struct GameOverView: View {
.padding(.top, self.viewStore.words.first?.isYourWord == .some(true) ? 0 : .grid(6))
.padding(.grid(2))
}
- .padding([.top, .bottom])
+ .padding(.vertical)
.frame(maxWidth: .infinity)
Divider()
.frame(width: 2)
.background((self.colorScheme == .dark ? self.color : .isowordsBlack).opacity(0.2))
- .id(DividerId())
+ .id(DividerID())
VStack(alignment: .leading) {
HStack {
@@ -880,7 +884,7 @@ public struct GameOverView: View {
}
}
.frame(maxWidth: .infinity)
- .padding([.leading, .trailing], .grid(2))
+ .padding(.horizontal, .grid(2))
Divider()
.frame(height: 2)
@@ -905,14 +909,14 @@ public struct GameOverView: View {
.padding(.top, self.viewStore.words.first?.isYourWord == .some(true) ? .grid(6) : 0)
.padding(.grid(2))
}
- .padding([.top, .bottom])
+ .padding(.vertical)
.frame(maxWidth: .infinity)
}
.fixedSize()
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
.frame(width: UIScreen.main.bounds.size.width)
.offset(x: (containerWidth / 2) - self.dividerOffset + (self.dragOffset / 2))
- .padding([.top, .bottom])
+ .padding(.vertical)
.gesture(
DragGesture()
.onChanged { self.dragOffset = $0.translation.width }
@@ -951,7 +955,7 @@ public struct GameOverView: View {
VStack(spacing: self.adaptiveSize.pad(12)) {
Text("Your words")
.adaptiveFont(.matterMedium, size: 16)
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
.frame(maxWidth: .infinity, alignment: .leading)
ScrollView(.horizontal, showsIndicators: false) {
@@ -964,7 +968,7 @@ public struct GameOverView: View {
)
}
}
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
}
}
}
@@ -1003,7 +1007,7 @@ private struct WordView: View {
.offset(x: 8, y: -8)
}
.frame(maxWidth: .infinity, alignment: self.word.isYourWord ? .trailing : .leading)
- .padding([.leading, .trailing], .grid(1))
+ .padding(.horizontal, .grid(1))
.fixedSize()
}
@@ -1040,35 +1044,6 @@ extension CompletedMatch {
}
}
-extension Effect where Output == GameOverAction, Failure == Never {
- static func reviewRequestEffect(environment: GameOverEnvironment) -> Self {
- let hasRequestedReviewBefore =
- environment.userDefaults
- .doubleForKey(lastReviewRequestTimeIntervalKey) != 0
- let timeSinceLastReviewRequest =
- environment.mainRunLoop.now.date.timeIntervalSince1970
- - environment.userDefaults.doubleForKey(lastReviewRequestTimeIntervalKey)
- let weekInSeconds: Double = 60 * 60 * 24 * 7
-
- return environment.database.fetchStats
- .ignoreFailure()
- .flatMap { stats in
- stats.gamesPlayed >= 3
- && (!hasRequestedReviewBefore || timeSinceLastReviewRequest >= weekInSeconds)
- ? Effect.merge(
- environment.userDefaults.setDouble(
- environment.mainRunLoop.now.date.timeIntervalSince1970,
- lastReviewRequestTimeIntervalKey
- )
- .fireAndForget(),
- environment.storeKit.requestReview().fireAndForget()
- )
- : Effect.none
- }
- .eraseToEffect()
- }
-}
-
extension UpgradeInterstitialFeature.GameContext {
init(gameContext: CompletedGame.GameContext) {
switch gameContext {
diff --git a/Sources/HapticsCore/HapticsCore.swift b/Sources/HapticsCore/HapticsCore.swift
index dc89f252..53206e7c 100644
--- a/Sources/HapticsCore/HapticsCore.swift
+++ b/Sources/HapticsCore/HapticsCore.swift
@@ -12,7 +12,8 @@ extension Reducer {
of: trigger,
perform: { _, state, _, environment in
guard isEnabled(state) else { return .none }
- return feedbackGenerator(environment).selectionChanged().fireAndForget()
- })
+ return .fireAndForget { await feedbackGenerator(environment).selectionChanged() }
+ }
+ )
}
}
diff --git a/Sources/HomeFeature/Home.swift b/Sources/HomeFeature/Home.swift
index 8546c189..79b47793 100644
--- a/Sources/HomeFeature/Home.swift
+++ b/Sources/HomeFeature/Home.swift
@@ -8,7 +8,6 @@ import Combine
import CombineHelpers
import ComposableArchitecture
import ComposableGameCenter
-import ComposableGameCenterHelpers
import ComposableStoreKit
import ComposableUserNotifications
import DailyChallengeFeature
@@ -68,7 +67,7 @@ public struct HomeState: Equatable {
public var changelog: ChangelogState?
public var dailyChallenges: [FetchTodaysDailyChallengeResponse]?
public var hasChangelog: Bool
- @BindableState public var hasPastTurnBasedGames: Bool
+ public var hasPastTurnBasedGames: Bool
public var nagBanner: NagBannerState?
public var route: HomeRoute?
public var savedGames: SavedGamesState {
@@ -118,30 +117,34 @@ public struct HomeState: Equatable {
self.turnBasedMatches = turnBasedMatches
self.weekInReview = weekInReview
}
+
+ var hasActiveGames: Bool {
+ self.savedGames.dailyChallengeUnlimited != nil
+ || self.savedGames.unlimited != nil
+ || !self.turnBasedMatches.isEmpty
+ }
}
-public enum HomeAction: BindableAction, Equatable {
+public enum HomeAction: Equatable {
+ case activeMatchesResponse(TaskResult)
case activeGames(ActiveGamesAction)
case authenticationResponse(CurrentPlayerEnvelope)
- case binding(BindingAction)
case changelog(ChangelogAction)
case cubeButtonTapped
case dailyChallenge(DailyChallengeAction)
- case dailyChallengeResponse(Result<[FetchTodaysDailyChallengeResponse], ApiError>)
+ case dailyChallengeResponse(TaskResult<[FetchTodaysDailyChallengeResponse]>)
case dismissChangelog
case gameButtonTapped(GameButtonAction)
case howToPlayButtonTapped
case leaderboard(LeaderboardAction)
- case matchesLoaded(Result<[ActiveTurnBasedMatch], NSError>)
case multiplayer(MultiplayerAction)
case nagBannerFeature(NagBannerFeatureAction)
- case onAppear
- case onDisappear
case serverConfigResponse(ServerConfig)
case setNavigation(tag: HomeRoute.Tag?)
case settings(SettingsAction)
case solo(SoloAction)
- case weekInReviewResponse(Result)
+ case task
+ case weekInReviewResponse(TaskResult)
public enum GameButtonAction: Equatable {
case dailyChallenge
@@ -150,6 +153,11 @@ public enum HomeAction: BindableAction, Equatable {
}
}
+public struct ActiveMatchResponse: Equatable {
+ public let matches: [ActiveTurnBasedMatch]
+ public let hasPastTurnBasedGames: Bool
+}
+
public struct HomeEnvironment {
public var apiClient: ApiClient
public var applicationClient: UIApplicationClient
@@ -166,7 +174,7 @@ public struct HomeEnvironment {
public var mainRunLoop: AnySchedulerOf
public var remoteNotifications: RemoteNotificationsClient
public var serverConfig: ServerConfigClient
- public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect
+ public var setUserInterfaceStyle: @Sendable (UIUserInterfaceStyle) async -> Void
public var storeKit: StoreKitClient
public var timeZone: () -> TimeZone
public var userDefaults: UserDefaultsClient
@@ -188,7 +196,7 @@ public struct HomeEnvironment {
mainRunLoop: AnySchedulerOf,
remoteNotifications: RemoteNotificationsClient,
serverConfig: ServerConfigClient,
- setUserInterfaceStyle: @escaping (UIUserInterfaceStyle) -> Effect,
+ setUserInterfaceStyle: @escaping @Sendable (UIUserInterfaceStyle) async -> Void,
storeKit: StoreKitClient,
timeZone: @escaping () -> TimeZone,
userDefaults: UserDefaultsClient,
@@ -235,7 +243,7 @@ public struct HomeEnvironment {
mainRunLoop: .immediate,
remoteNotifications: .noop,
serverConfig: .noop,
- setUserInterfaceStyle: { _ in .none },
+ setUserInterfaceStyle: { _ in },
storeKit: .noop,
timeZone: { TimeZone(secondsFromGMT: 0)! },
userDefaults: .noop,
@@ -254,7 +262,6 @@ public let homeReducer = Reducer.combine
apiClient: $0.apiClient,
applicationClient: $0.applicationClient,
build: $0.build,
- mainQueue: $0.mainQueue,
serverConfig: $0.serverConfig,
userDefaults: $0.userDefaults
)
@@ -353,30 +360,59 @@ public let homeReducer = Reducer.combine
.init { state, action, environment in
switch action {
+ case let .activeMatchesResponse(.success(response)):
+ state.hasPastTurnBasedGames = response.hasPastTurnBasedGames
+ state.turnBasedMatches = response.matches
+ return .none
+
+ case .activeMatchesResponse(.failure):
+ return .none
+
case let .activeGames(.turnBasedGameMenuItemTapped(.deleteMatch(matchId))):
- return .concatenate(
- environment.gameCenter.turnBasedMatch.load(matchId)
- .flatMap { match in
- forceQuitMatch(match: match, gameCenter: environment.gameCenter)
+ return .run { send in
+ let localPlayer = environment.gameCenter.localPlayer.localPlayer()
+
+ do {
+ let match = try await environment.gameCenter.turnBasedMatch.load(matchId)
+ let currentParticipantIsLocalPlayer =
+ match.currentParticipant?.player?.gamePlayerId == localPlayer.gamePlayerId
+
+ if currentParticipantIsLocalPlayer {
+ try await environment.gameCenter.turnBasedMatch
+ .endMatchInTurn(
+ .init(
+ for: match.matchId,
+ matchData: match.matchData ?? Data(),
+ localPlayerId: localPlayer.gamePlayerId,
+ localPlayerMatchOutcome: .quit,
+ message: "\(localPlayer.displayName) forfeited the match."
+ )
+ )
+ } else {
+ try await environment.gameCenter.turnBasedMatch
+ .participantQuitOutOfTurn(match.matchId)
}
- .fireAndForget(),
+ } catch {}
- loadMatches(
- gameCenter: environment.gameCenter,
- backgroundQueue: environment.backgroundQueue,
- mainRunLoop: environment.mainRunLoop
- ),
+ await send(
+ .activeMatchesResponse(
+ TaskResult {
+ try await environment.gameCenter
+ .loadActiveMatches(now: environment.mainRunLoop.now.date)
+ }
+ ),
+ animation: .default
+ )
- environment.audioPlayer.play(.uiSfxActionDestructive)
- .fireAndForget()
- )
+ await environment.audioPlayer.play(.uiSfxActionDestructive)
+ }
case let .activeGames(.turnBasedGameMenuItemTapped(.rematch(matchId))):
return .none
case let .activeGames(.turnBasedGameMenuItemTapped(.sendReminder(matchId, otherPlayerIndex))):
- return environment.gameCenter.turnBasedMatch
- .sendReminder(
+ return .fireAndForget {
+ try await environment.gameCenter.turnBasedMatch.sendReminder(
.init(
for: matchId,
to: [otherPlayerIndex.rawValue],
@@ -384,7 +420,7 @@ public let homeReducer = Reducer.combine
arguments: []
)
)
- .fireAndForget()
+ }
case .activeGames:
return .none
@@ -409,9 +445,6 @@ public let homeReducer = Reducer.combine
return .none
- case .binding:
- return .none
-
case .changelog:
return .none
@@ -443,44 +476,9 @@ public let homeReducer = Reducer.combine
case .leaderboard:
return .none
- case .matchesLoaded(.failure):
- return .none
-
- case let .matchesLoaded(.success(matches)):
- state.turnBasedMatches = matches
- return .none
-
case .multiplayer:
return .none
- case .onDisappear:
- return .cancel(id: ListenerId())
-
- case .onAppear:
- return .merge(
- onAppearEffects(environment: environment),
-
- environment.gameCenter.localPlayer.listener
- .cancellable(id: ListenerId(), cancelInFlight: true)
- .filter {
- switch $0 {
- case .turnBased(.matchEnded),
- .turnBased(.receivedTurnEventForMatch):
- return true
- default:
- return false
- }
- }
- .flatMap { _ in
- loadMatches(
- gameCenter: environment.gameCenter,
- backgroundQueue: environment.backgroundQueue,
- mainRunLoop: environment.mainRunLoop
- )
- }
- .eraseToEffect()
- )
-
case let .serverConfigResponse(serverConfig):
state.hasChangelog = serverConfig.newestBuild > environment.build.number()
return .none
@@ -526,6 +524,13 @@ public let homeReducer = Reducer.combine
case .solo:
return .none
+ case .task:
+ return .run { send in
+ async let authenticate: Void = authenticate(send: send, environment: environment)
+ await listenForGameCenterEvents(send: send, environment: environment)
+ }
+ .animation()
+
case .weekInReviewResponse(.failure):
return .none
@@ -535,7 +540,95 @@ public let homeReducer = Reducer.combine
}
}
)
-.binding()
+
+private func authenticate(send: Send, environment: HomeEnvironment) async {
+ do {
+ try? await environment.gameCenter.localPlayer.authenticate()
+
+ let localPlayer = environment.gameCenter.localPlayer.localPlayer()
+ let currentPlayerEnvelope = try await environment.apiClient.authenticate(
+ .init(
+ deviceId: .init(rawValue: environment.deviceId.id()),
+ displayName: localPlayer.isAuthenticated ? localPlayer.displayName : nil,
+ gameCenterLocalPlayerId: localPlayer.isAuthenticated
+ ? .init(rawValue: localPlayer.gamePlayerId.rawValue)
+ : nil,
+ timeZone: environment.timeZone().identifier
+ )
+ )
+ await send(.authenticationResponse(dump(currentPlayerEnvelope)))
+
+ async let serverConfigResponse: Void = send(
+ .serverConfigResponse(environment.serverConfig.refresh())
+ )
+
+ async let dailyChallengeResponse: Void = send(
+ .dailyChallengeResponse(
+ TaskResult {
+ try await environment.apiClient.apiRequest(
+ route: .dailyChallenge(.today(language: .en)),
+ as: [FetchTodaysDailyChallengeResponse].self
+ )
+ }
+ )
+ )
+ async let weekInReviewResponse: Void = send(
+ .weekInReviewResponse(
+ TaskResult {
+ try await environment.apiClient.apiRequest(
+ route: .leaderboard(.weekInReview(language: .en)),
+ as: FetchWeekInReviewResponse.self
+ )
+ }
+ )
+ )
+ async let activeMatchesResponse: Void = send(
+ .activeMatchesResponse(
+ TaskResult {
+ try await environment.gameCenter
+ .loadActiveMatches(now: environment.mainRunLoop.now.date)
+ }
+ )
+ )
+ _ = try await (
+ serverConfigResponse,
+ dailyChallengeResponse,
+ weekInReviewResponse,
+ activeMatchesResponse
+ )
+ } catch {}
+}
+
+private func listenForGameCenterEvents(send: Send, environment: HomeEnvironment) async {
+ for await event in environment.gameCenter.localPlayer.listener() {
+ switch event {
+ case .turnBased(.matchEnded),
+ .turnBased(.receivedTurnEventForMatch):
+ await send(
+ .activeMatchesResponse(
+ TaskResult {
+ try await environment.gameCenter
+ .loadActiveMatches(now: environment.mainRunLoop.now.date)
+ }
+ )
+ )
+ default:
+ break
+ }
+ }
+}
+
+extension GameCenterClient {
+ fileprivate func loadActiveMatches(
+ now: Date
+ ) async throws -> ActiveMatchResponse {
+ let localPlayer = self.localPlayer.localPlayer()
+ let matches = try await self.turnBasedMatch.loadMatches()
+ let activeMatches = matches.activeMatches(for: localPlayer, at: now)
+ let hasPastTurnBasedGames = matches.contains { $0.status == .ended }
+ return ActiveMatchResponse(matches: activeMatches, hasPastTurnBasedGames: hasPastTurnBasedGames)
+ }
+}
public struct HomeView: View {
struct ViewState: Equatable {
@@ -599,7 +692,7 @@ public struct HomeView: View {
}
.font(.system(size: 24))
.foregroundColor(self.colorScheme == .dark ? .hex(0xF2E29F) : .isowordsBlack)
- .adaptivePadding([.leading, .trailing])
+ .adaptivePadding(.horizontal)
DailyChallengeHeaderView(store: self.store)
.screenEdgePadding(.horizontal)
@@ -628,7 +721,7 @@ public struct HomeView: View {
LeaderboardLinkView(store: self.store)
.screenEdgePadding(.horizontal)
}
- .adaptivePadding([.top, .bottom], .grid(4))
+ .adaptivePadding(.vertical, .grid(4))
.background(
self.colorScheme == .dark
? AnyView(Color.isowordsBlack)
@@ -693,125 +786,10 @@ public struct HomeView: View {
)
}
)
- .onAppear { self.viewStore.send(.onAppear) }
- .onDisappear { self.viewStore.send(.onDisappear) }
- }
-}
-
-extension HomeState {
- var hasActiveGames: Bool {
- self.savedGames.dailyChallengeUnlimited != nil
- || self.savedGames.unlimited != nil
- || !self.turnBasedMatches.isEmpty
+ .task { await self.viewStore.send(.task).finish() }
}
}
-func onAppearEffects(environment: HomeEnvironment) -> Effect {
- var serverAuthentication: Effect {
- environment.apiClient.authenticate(
- .init(
- deviceId: .init(rawValue: environment.deviceId.id()),
- displayName: environment.gameCenter.localPlayer.localPlayer().isAuthenticated
- ? environment.gameCenter.localPlayer.localPlayer().displayName
- : nil,
- gameCenterLocalPlayerId: environment.gameCenter.localPlayer.localPlayer().isAuthenticated
- ? .init(rawValue: environment.gameCenter.localPlayer.localPlayer().gamePlayerId.rawValue)
- : nil,
- timeZone: environment.timeZone().identifier
- )
- )
- .ignoreFailure()
- .flatMap { envelope in
- Just(HomeAction.authenticationResponse(envelope))
- .merge(
- with: environment.serverConfig.refresh()
- .ignoreFailure()
- .receive(on: environment.mainQueue)
- .map(HomeAction.serverConfigResponse)
- )
- }
- .eraseToEffect()
- }
-
- let serverAuthenticateAndLoadData = serverAuthentication.flatMap { authentication in
- Effect.merge(
- Effect(value: authentication),
-
- environment.apiClient
- .apiRequest(
- route: .dailyChallenge(.today(language: .en)),
- as: [FetchTodaysDailyChallengeResponse].self
- )
- .catchToEffect(HomeAction.dailyChallengeResponse),
-
- environment.apiClient
- .apiRequest(
- route: .leaderboard(.weekInReview(language: .en)),
- as: FetchWeekInReviewResponse.self
- )
- .catchToEffect(HomeAction.weekInReviewResponse)
- )
- }
-
- return
- environment.gameCenter.localPlayer.authenticate
- .flatMap { _ in
- Publishers.Merge(
- serverAuthenticateAndLoadData,
-
- loadMatches(
- gameCenter: environment.gameCenter,
- backgroundQueue: environment.backgroundQueue,
- mainRunLoop: environment.mainRunLoop
- )
- )
- }
- .receive(on: environment.mainQueue.animation())
- .eraseToEffect()
- .cancellable(id: AuthenticationId(), cancelInFlight: true)
-}
-
-private func loadMatches(
- gameCenter: GameCenterClient,
- backgroundQueue: AnySchedulerOf,
- mainRunLoop: AnySchedulerOf
-) -> Effect {
-
- return gameCenter.turnBasedMatch.loadMatches()
- .receive(on: backgroundQueue)
- .mapError { $0 as NSError }
- .catchToEffect()
- .flatMap { result in
- Effect.merge(
- Effect(
- value: .set(
- \.$hasPastTurnBasedGames,
- (try? result.get())?.contains { $0.status == .ended } == .some(true)
- )
- )
- .receive(on: mainRunLoop)
- .eraseToEffect(),
-
- Effect(
- value: .matchesLoaded(
- result.map {
- $0.activeMatches(
- for: gameCenter.localPlayer.localPlayer(),
- at: mainRunLoop.now.date
- )
- }
- )
- )
- .receive(on: mainRunLoop.animation())
- .eraseToEffect()
- )
- }
- .eraseToEffect()
-}
-
-private struct ListenerId: Hashable {}
-private struct AuthenticationId: Hashable {}
-
private struct CubeIconView: View {
let action: () -> Void
let shake: Bool
@@ -829,7 +807,7 @@ private struct CubeIconView: View {
Image(systemName: "cube.fill")
.font(.system(size: 24))
.modifier(ShakeEffect(animatableData: CGFloat(self.shake ? 1 : 0)))
- .animation(.easeInOut(duration: 1))
+ .animation(.easeInOut(duration: 1), value: self.shake)
}
}
}
@@ -950,7 +928,7 @@ private struct ShakeEffect: GeometryEffect {
mainRunLoop: .main,
remoteNotifications: .noop,
serverConfig: .noop,
- setUserInterfaceStyle: { _ in .none },
+ setUserInterfaceStyle: { _ in },
storeKit: .noop,
timeZone: { .autoupdatingCurrent },
userDefaults: .live(),
diff --git a/Sources/IntegrationTestHelpers/IntegrationTestHelpers.swift b/Sources/IntegrationTestHelpers/IntegrationTestHelpers.swift
index 44d1ac8f..49988a64 100644
--- a/Sources/IntegrationTestHelpers/IntegrationTestHelpers.swift
+++ b/Sources/IntegrationTestHelpers/IntegrationTestHelpers.swift
@@ -7,6 +7,7 @@ import HttpPipeline
import Prelude
import ServerRouter
import SharedModels
+import TcaHelpers
import TestHelpers
extension ApiClient {
@@ -14,21 +15,34 @@ extension ApiClient {
middleware: @escaping Middleware,
router: ServerRouter
) {
+ // TODO: Fix sync interfaces or migrate fully to async
var currentPlayer: CurrentPlayerEnvelope?
-
var baseUrl = URL(string: "/")!
- self.init(
- apiRequest: { route in
+ actor Session {
+ nonisolated let baseUrl: Isolated
+ nonisolated let currentPlayer = Isolated(nil)
+ private let middleware: Middleware
+ private let router: ServerRouter
+
+ init(
+ baseUrl: URL,
+ middleware: @escaping Middleware,
+ router: ServerRouter
+ ) {
+ self.baseUrl = Isolated(baseUrl)
+ self.middleware = middleware
+ self.router = router
+ }
+
+ func apiRequest(route: ServerRoute.Api.Route) async throws -> (Data, URLResponse) {
guard
let request = try? router.request(
for: .api(.init(accessToken: .init(rawValue: .deadbeef), isDebug: true, route: route))
),
let url = request.url
- else {
- return Fail(error: URLError.init(.badURL))
- .eraseToEffect()
- }
+ else { throw URLError.init(.badURL) }
+
let conn = middleware(connection(from: request)).perform()
let response = HTTPURLResponse(
@@ -38,41 +52,44 @@ extension ApiClient {
headerFields: Dictionary(
uniqueKeysWithValues: conn.response.headers.map { ($0.name, $0.value) })
)!
- return Just((conn.data, response))
- .setFailureType(to: URLError.self)
- .eraseToEffect()
- },
- authenticate: { authRequest in
- guard let request = try? router.request(for: .authenticate(authRequest))
- else {
- return Fail(error: ApiError(error: URLError(.badURL)))
- .eraseToEffect()
- }
+ return (conn.data, response)
+ }
+
+ func authenticate(request: ServerRoute.AuthenticateRequest) async throws
+ -> CurrentPlayerEnvelope
+ {
+ do {
+ guard let request = try? self.router.request(for: .authenticate(request))
+ else { throw URLError(.badURL) }
- return Effect.catching {
- try JSONDecoder().decode(
+ let envelope = try JSONDecoder().decode(
CurrentPlayerEnvelope.self,
from: middleware(connection(from: request)).perform().data
)
+ // Why aren't we assigning the envelope here?
+ self.currentPlayer.value = .init(appleReceipt: nil, player: .blob)
+ return envelope
+ } catch {
+ throw ApiError(error: error)
}
- .mapError { ApiError(error: $0) }
- .handleEvents(
- receiveOutput: { _ in currentPlayer = .init(appleReceipt: nil, player: .blob) }
- )
- .eraseToEffect()
- },
- baseUrl: { baseUrl },
- currentPlayer: { currentPlayer },
- logout: { .fireAndForget { currentPlayer = nil } },
- refreshCurrentPlayer: { currentPlayer.map(Effect.init(value:)) ?? .none },
- request: { route in
+ }
+
+ func logout() {
+ self.currentPlayer.value = nil
+ }
+
+ func refreshCurrentPlayer() async throws -> CurrentPlayerEnvelope {
+ guard let currentPlayer = self.currentPlayer.value
+ else { throw URLError(.unknown) }
+ return currentPlayer
+ }
+
+ func request(route: ServerRoute) async throws -> (Data, URLResponse) {
guard
- let request = try? router.request(for: route),
+ let request = try? self.router.request(for: route),
let url = request.url
- else {
- return Fail(error: URLError.init(.badURL))
- .eraseToEffect()
- }
+ else { throw URLError.init(.badURL) }
+
let conn = middleware(connection(from: request)).perform()
let response = HTTPURLResponse(
@@ -82,15 +99,29 @@ extension ApiClient {
headerFields: Dictionary(
uniqueKeysWithValues: conn.response.headers.map { ($0.name, $0.value) })
)!
- return Just((conn.data, response))
- .setFailureType(to: URLError.self)
- .eraseToEffect()
- },
- setBaseUrl: { url in
- .fireAndForget {
- baseUrl = url
- }
+ return (conn.data, response)
}
+
+ func setBaseUrl(_ url: URL) {
+ self.baseUrl.value = url
+ }
+
+ fileprivate func setCurrentPlayer(_ player: CurrentPlayerEnvelope) {
+ self.currentPlayer.value = player
+ }
+ }
+
+ let session = Session(baseUrl: baseUrl, middleware: middleware, router: router)
+
+ self.init(
+ apiRequest: { try await session.apiRequest(route: $0) },
+ authenticate: { try await session.authenticate(request: $0) },
+ baseUrl: { session.baseUrl.value },
+ currentPlayer: { session.currentPlayer.value },
+ logout: { await session.logout() },
+ refreshCurrentPlayer: { try await session.refreshCurrentPlayer() },
+ request: { try await session.request(route: $0) },
+ setBaseUrl: { await session.setBaseUrl($0) }
)
}
}
diff --git a/Sources/LeaderboardFeature/Leaderboard.swift b/Sources/LeaderboardFeature/Leaderboard.swift
index d086e451..2dbfb15e 100644
--- a/Sources/LeaderboardFeature/Leaderboard.swift
+++ b/Sources/LeaderboardFeature/Leaderboard.swift
@@ -66,7 +66,7 @@ public struct LeaderboardState: Equatable {
public enum LeaderboardAction: Equatable {
case cubePreview(CubePreviewAction)
case dismissCubePreview
- case fetchWordResponse(Result)
+ case fetchWordResponse(TaskResult)
case scopeTapped(LeaderboardScope)
case solo(LeaderboardResultsAction)
case vocab(LeaderboardResultsAction)
@@ -96,12 +96,12 @@ public struct LeaderboardEnvironment {
#if DEBUG
extension LeaderboardEnvironment {
- public static let failing = Self(
- apiClient: .failing,
- audioPlayer: .failing,
- feedbackGenerator: .failing,
- lowPowerMode: .failing,
- mainQueue: .failing("mainQueue")
+ public static let unimplemented = Self(
+ apiClient: .unimplemented,
+ audioPlayer: .unimplemented,
+ feedbackGenerator: .unimplemented,
+ lowPowerMode: .unimplemented,
+ mainQueue: .unimplemented("mainQueue")
)
}
#endif
@@ -130,8 +130,7 @@ public let leaderboardReducer = Reducer<
action: /LeaderboardAction.solo,
environment: {
LeaderboardResultsEnvironment(
- loadResults: $0.apiClient.loadSoloResults(gameMode:timeScope:),
- mainQueue: $0.mainQueue
+ loadResults: $0.apiClient.loadSoloResults(gameMode:timeScope:)
)
}
),
@@ -142,8 +141,7 @@ public let leaderboardReducer = Reducer<
action: /LeaderboardAction.vocab,
environment: {
LeaderboardResultsEnvironment(
- loadResults: $0.apiClient.loadVocabResults(gameMode:timeScope:),
- mainQueue: $0.mainQueue
+ loadResults: $0.apiClient.loadVocabResults(gameMode:timeScope:)
)
}
),
@@ -190,17 +188,22 @@ public let leaderboardReducer = Reducer<
return .none
case let .vocab(.tappedRow(id)):
- struct CancelId: Hashable {}
+ enum CancelID {}
guard let resultEnvelope = state.vocab.resultEnvelope
else { return .none }
- return environment.apiClient.apiRequest(
- route: .leaderboard(.vocab(.fetchWord(wordId: .init(rawValue: id)))),
- as: FetchVocabWordResponse.self
- )
- .receive(on: environment.mainQueue)
- .catchToEffect(LeaderboardAction.fetchWordResponse)
- .cancellable(id: CancelId(), cancelInFlight: true)
+
+ return .task {
+ await .fetchWordResponse(
+ TaskResult {
+ try await environment.apiClient.apiRequest(
+ route: .leaderboard(.vocab(.fetchWord(wordId: .init(rawValue: id)))),
+ as: FetchVocabWordResponse.self
+ )
+ }
+ )
+ }
+ .cancellable(id: CancelID.self, cancelInFlight: true)
case .vocab:
return .none
@@ -313,11 +316,12 @@ public struct LeaderboardView: View {
}
extension ApiClient {
+ @Sendable
func loadSoloResults(
gameMode: GameMode,
timeScope: TimeScope
- ) -> Effect {
- self.apiRequest(
+ ) async throws -> ResultEnvelope {
+ let response = try await self.apiRequest(
route: .leaderboard(
.fetch(
gameMode: gameMode,
@@ -327,34 +331,33 @@ extension ApiClient {
),
as: FetchLeaderboardResponse.self
)
- .compactMap { response in
- response.entries.first.map { firstEntry in
- ResultEnvelope(
- outOf: firstEntry.outOf,
- results: response.entries.map { entry in
- ResultEnvelope.Result(
- denseRank: entry.rank,
- id: entry.id.rawValue,
- isYourScore: entry.isYourScore,
- rank: entry.rank,
- score: entry.score,
- subtitle: nil,
- title: entry.playerDisplayName ?? (entry.isYourScore ? "You" : "Someone")
- )
- }
- )
- }
+ return response.entries.first.map { firstEntry in
+ ResultEnvelope(
+ outOf: firstEntry.outOf,
+ results: response.entries.map { entry in
+ ResultEnvelope.Result(
+ denseRank: entry.rank,
+ id: entry.id.rawValue,
+ isYourScore: entry.isYourScore,
+ rank: entry.rank,
+ score: entry.score,
+ subtitle: nil,
+ title: entry.playerDisplayName ?? (entry.isYourScore ? "You" : "Someone")
+ )
+ }
+ )
}
- .eraseToEffect()
+ ?? .init()
}
}
extension ApiClient {
+ @Sendable
func loadVocabResults(
gameMode: GameMode,
timeScope: TimeScope
- ) -> Effect {
- self.apiRequest(
+ ) async throws -> ResultEnvelope {
+ let response = try await self.apiRequest(
route: .leaderboard(
.vocab(
.fetch(
@@ -365,15 +368,13 @@ extension ApiClient {
),
as: [FetchVocabLeaderboardResponse.Entry].self
)
- .compactMap { response in
- response.first.map { firstEntry in
- ResultEnvelope(
- outOf: firstEntry.outOf,
- results: response.map(ResultEnvelope.Result.init)
- )
- }
+ return response.first.map { firstEntry in
+ ResultEnvelope(
+ outOf: firstEntry.outOf,
+ results: response.map(ResultEnvelope.Result.init)
+ )
}
- .eraseToEffect()
+ ?? .init()
}
}
@@ -409,10 +410,11 @@ extension ResultEnvelope.Result {
reducer: leaderboardReducer,
environment: LeaderboardEnvironment(
apiClient: update(.noop) {
- $0.apiRequest = { route in
+ $0.apiRequest = { @Sendable route in
switch route {
case .leaderboard(.fetch(gameMode: _, language: _, timeScope: _)):
- return Effect.ok(
+ try await Task.sleep(nanoseconds: NSEC_PER_SEC)
+ return try await OK(
FetchLeaderboardResponse(
entries: (1...20).map { idx in
FetchLeaderboardResponse.Entry(
@@ -427,11 +429,9 @@ extension ResultEnvelope.Result {
}
)
)
- .delay(for: 1, scheduler: DispatchQueue.main)
- .eraseToEffect()
default:
- return .none
+ throw CancellationError()
}
}
},
diff --git a/Sources/LeaderboardFeature/LeaderboardResultsView.swift b/Sources/LeaderboardFeature/LeaderboardResultsView.swift
index 89611936..2957bc75 100644
--- a/Sources/LeaderboardFeature/LeaderboardResultsView.swift
+++ b/Sources/LeaderboardFeature/LeaderboardResultsView.swift
@@ -35,25 +35,22 @@ extension LeaderboardResultsState: Equatable where TimeScope: Equatable {}
public enum LeaderboardResultsAction {
case dismissTimeScopeMenu
case gameModeButtonTapped(GameMode)
- case resultsResponse(Result)
- case onAppear
+ case resultsResponse(TaskResult)
case tappedRow(id: UUID)
case tappedTimeScopeLabel
+ case task
case timeScopeChanged(TimeScope)
}
extension LeaderboardResultsAction: Equatable where TimeScope: Equatable {}
public struct LeaderboardResultsEnvironment {
- public let loadResults: (GameMode, TimeScope) -> Effect
- public let mainQueue: AnySchedulerOf
+ public let loadResults: @Sendable (GameMode, TimeScope) async throws -> ResultEnvelope
public init(
- loadResults: @escaping (GameMode, TimeScope) -> Effect,
- mainQueue: AnySchedulerOf
+ loadResults: @escaping @Sendable (GameMode, TimeScope) async throws -> ResultEnvelope
) {
self.loadResults = loadResults
- self.mainQueue = mainQueue
}
}
@@ -74,18 +71,12 @@ extension Reducer {
case let .gameModeButtonTapped(gameMode):
state.gameMode = gameMode
state.isLoading = true
- return environment.loadResults(state.gameMode, state.timeScope)
- .receive(on: environment.mainQueue.animation())
- .catchToEffect(LeaderboardResultsAction.resultsResponse)
-
- case .onAppear:
- state.isLoading = true
- state.isTimeScopeMenuVisible = false
- state.resultEnvelope = .placeholder
-
- return environment.loadResults(state.gameMode, state.timeScope)
- .receive(on: environment.mainQueue.animation())
- .catchToEffect(LeaderboardResultsAction.resultsResponse)
+ return .task { [timeScope = state.timeScope] in
+ await .resultsResponse(
+ TaskResult { try await environment.loadResults(gameMode, timeScope) }
+ )
+ }
+ .animation()
case .resultsResponse(.failure):
state.isLoading = false
@@ -104,14 +95,29 @@ extension Reducer {
state.isTimeScopeMenuVisible.toggle()
return .none
+ case .task:
+ state.isLoading = true
+ state.isTimeScopeMenuVisible = false
+ state.resultEnvelope = .placeholder
+
+ return .task { [gameMode = state.gameMode, timeScope = state.timeScope] in
+ await .resultsResponse(
+ TaskResult { try await environment.loadResults(gameMode, timeScope) }
+ )
+ }
+ .animation()
+
case let .timeScopeChanged(timeScope):
state.isLoading = true
state.isTimeScopeMenuVisible = false
state.timeScope = timeScope
- return environment.loadResults(state.gameMode, state.timeScope)
- .receive(on: environment.mainQueue.animation())
- .catchToEffect(LeaderboardResultsAction.resultsResponse)
+ return .task { [gameMode = state.gameMode] in
+ await .resultsResponse(
+ TaskResult { try await environment.loadResults(gameMode, timeScope) }
+ )
+ }
+ .animation()
}
}
}
@@ -209,7 +215,6 @@ where
}
self.subtitle
- .animation(nil)
.adaptiveFont(.matterMedium, size: 12)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.init(top: .grid(4), leading: .grid(5), bottom: .grid(3), trailing: .grid(5)))
@@ -222,12 +227,11 @@ where
ResultRow(color: self.color, result: result)
}
}
- .animation(nil)
if let result = self.viewStore.resultEnvelope?.nonContiguousResult {
Image(systemName: "ellipsis")
.opacity(0.4)
- .adaptivePadding([.top, .bottom], .grid(5))
+ .adaptivePadding(.vertical, .grid(5))
.adaptiveFont(.matterMedium, size: 16)
Button(action: { self.viewStore.send(.tappedRow(id: result.id)) }) {
@@ -250,7 +254,6 @@ where
}
.disabled(self.viewStore.isLoading)
.redacted(reason: self.viewStore.isLoading ? .placeholder : [])
- .animation(nil)
}
.background(self.color)
.foregroundColor(.isowordsBlack)
@@ -297,7 +300,7 @@ where
alignment: .topTrailing
)
- .onAppear { self.viewStore.send(.onAppear) }
+ .task { await self.viewStore.send(.task).finish() }
}
}
@@ -412,24 +415,21 @@ extension ResultEnvelope {
reducer: .leaderboardResultsReducer(),
environment: LeaderboardResultsEnvironment(
loadResults: { _, _ in
- Effect(
- value: .init(
- outOf: 1000,
- results: ([1, 2, 3, 4, 5, 6, 7, 7, 15]).map { index in
- ResultEnvelope.Result(
- denseRank: index,
- id: UUID(),
- isYourScore: index == 15,
- rank: index,
- score: 6000 - index * 300,
- subtitle: "mbrandonw",
- title: "Longword\(index)"
- )
- }
- )
+ .init(
+ outOf: 1000,
+ results: ([1, 2, 3, 4, 5, 6, 7, 7, 15]).map { index in
+ ResultEnvelope.Result(
+ denseRank: index,
+ id: UUID(),
+ isYourScore: index == 15,
+ rank: index,
+ score: 6000 - index * 300,
+ subtitle: "mbrandonw",
+ title: "Longword\(index)"
+ )
+ }
)
- },
- mainQueue: .immediate
+ }
)
),
title: Text("362,998 words"),
@@ -452,25 +452,20 @@ extension ResultEnvelope {
reducer: .leaderboardResultsReducer(),
environment: LeaderboardResultsEnvironment(
loadResults: { _, _ in
- Effect(
- value: .init(
- outOf: 1000,
- results: (1...5).map { index in
- ResultEnvelope.Result(
- denseRank: index,
- id: UUID(),
- isYourScore: index == 3,
- rank: index,
- score: 6000 - index * 800,
- title: "Player \(index)"
- )
- }
- )
+ .init(
+ outOf: 1000,
+ results: (1...5).map { index in
+ ResultEnvelope.Result(
+ denseRank: index,
+ id: UUID(),
+ isYourScore: index == 3,
+ rank: index,
+ score: 6000 - index * 800,
+ title: "Player \(index)"
+ )
+ }
)
- .delay(for: 1, scheduler: DispatchQueue.main.animation())
- .eraseToEffect()
- },
- mainQueue: .immediate
+ }
)
),
title: Text("Daily challenge"),
@@ -492,8 +487,10 @@ extension ResultEnvelope {
),
reducer: .leaderboardResultsReducer(),
environment: LeaderboardResultsEnvironment(
- loadResults: { _, _ in .init(error: .init(error: NSError(domain: "", code: 1))) },
- mainQueue: .immediate
+ loadResults: { _, _ in
+ struct Failure: Error {}
+ throw Failure()
+ }
)
),
title: Text("Solo"),
diff --git a/Sources/LocalDatabaseClient/Interface.swift b/Sources/LocalDatabaseClient/Interface.swift
index f0e13925..e6dfa28c 100644
--- a/Sources/LocalDatabaseClient/Interface.swift
+++ b/Sources/LocalDatabaseClient/Interface.swift
@@ -2,12 +2,12 @@ import ComposableArchitecture
import SharedModels
public struct LocalDatabaseClient {
- public var fetchGamesForWord: (String) -> Effect<[LocalDatabaseClient.Game], Error>
- public var fetchStats: Effect
- public var fetchVocab: Effect
- public var migrate: Effect
- public var playedGamesCount: (GameContext) -> Effect
- public var saveGame: (CompletedGame) -> Effect
+ public var fetchGamesForWord: @Sendable (String) async throws -> [LocalDatabaseClient.Game]
+ public var fetchStats: @Sendable () async throws -> Stats
+ public var fetchVocab: @Sendable () async throws -> Vocab
+ public var migrate: @Sendable () async throws -> Void
+ public var playedGamesCount: @Sendable (GameContext) async throws -> Int
+ public var saveGame: @Sendable (CompletedGame) async throws -> Void
public struct Game: Equatable {
public var id: Int
@@ -66,11 +66,11 @@ public struct LocalDatabaseClient {
extension LocalDatabaseClient {
public static let noop = Self(
- fetchGamesForWord: { _ in .none },
- fetchStats: .none,
- fetchVocab: .none,
- migrate: .none,
- playedGamesCount: { _ in .none },
- saveGame: { _ in .none }
+ fetchGamesForWord: { _ in try await Task.never() },
+ fetchStats: { try await Task.never() },
+ fetchVocab: { try await Task.never() },
+ migrate: {},
+ playedGamesCount: { _ in try await Task.never() },
+ saveGame: { _ in try await Task.never() }
)
}
diff --git a/Sources/LocalDatabaseClient/Live.swift b/Sources/LocalDatabaseClient/Live.swift
index 8daf688e..1a852740 100644
--- a/Sources/LocalDatabaseClient/Live.swift
+++ b/Sources/LocalDatabaseClient/Live.swift
@@ -5,31 +5,29 @@ import Sqlite
extension LocalDatabaseClient {
public static func live(path: URL) -> Self {
- var _db: Sqlite!
- func db() throws -> Sqlite {
- if _db == nil {
+ let _db = UncheckedSendable(Box(wrappedValue: nil))
+ @Sendable func db() throws -> Sqlite {
+ if _db.value.wrappedValue == nil {
try! FileManager.default.createDirectory(
at: path.deletingLastPathComponent(), withIntermediateDirectories: true
)
- _db = try Sqlite(path: path.absoluteString)
+ _db.value.wrappedValue = try Sqlite(path: path.absoluteString)
}
- return _db
+ return _db.value.wrappedValue!
}
return Self(
- fetchGamesForWord: { word in .catching { try db().fetchGames(for: word) } },
- fetchStats: .catching { try db().fetchStats() },
- fetchVocab: .catching { try db().fetchVocab() },
- migrate: .catching { try db().migrate() },
- playedGamesCount: { gameContext in
- .catching { try db().playedGamesCount(gameContext: gameContext) }
- },
- saveGame: { game in .catching { try db().saveGame(game) } }
+ fetchGamesForWord: { try db().fetchGames(for: $0) },
+ fetchStats: { try db().fetchStats() },
+ fetchVocab: { try db().fetchVocab() },
+ migrate: { try db().migrate() },
+ playedGamesCount: { try db().playedGamesCount(gameContext: $0) },
+ saveGame: { try db().saveGame($0) }
)
}
public static func autoMigratingLive(path: URL) -> Self {
let client = Self.live(path: path)
- _ = client.migrate.sink(receiveCompletion: { _ in }, receiveValue: {})
+ Task { try await client.migrate() }
return client
}
}
diff --git a/Sources/LocalDatabaseClient/Mocks.swift b/Sources/LocalDatabaseClient/Mocks.swift
index 92b00819..b74bf729 100644
--- a/Sources/LocalDatabaseClient/Mocks.swift
+++ b/Sources/LocalDatabaseClient/Mocks.swift
@@ -2,35 +2,35 @@ import ComposableArchitecture
import SharedModels
import XCTestDynamicOverlay
+class Box {
+ var wrappedValue: Value
+ init(wrappedValue: Value) {
+ self.wrappedValue = wrappedValue
+ }
+}
+
extension LocalDatabaseClient {
public static var mock: Self {
- var games: [CompletedGame] = []
+ let games = UncheckedSendable(Box(wrappedValue: [CompletedGame]()))
return Self(
- fetchGamesForWord: { _ in .result { .success([]) } },
- fetchStats: Effect(value: Stats()),
- fetchVocab: .none,
- migrate: .result { .success(()) },
- playedGamesCount: { _ in .init(value: 10) },
- saveGame: { game in
- .result {
- games.append(game)
- return .success(())
- }
- }
+ fetchGamesForWord: { _ in [] },
+ fetchStats: { Stats() },
+ fetchVocab: { Vocab(words: []) },
+ migrate: {},
+ playedGamesCount: { _ in 10 },
+ saveGame: { games.value.wrappedValue.append($0) }
)
}
#if DEBUG
- public static let failing = Self(
- fetchGamesForWord: { _ in
- .failing("\(Self.self).fetchGamesForWord is unimplemented")
- },
- fetchStats: .failing("\(Self.self).fetchStats is unimplemented"),
- fetchVocab: .failing("\(Self.self).fetchVocab is unimplemented"),
- migrate: .failing("\(Self.self).migrate is unimplemented"),
- playedGamesCount: { _ in .failing("\(Self.self).playedGamesCount is unimplemented") },
- saveGame: { _ in .failing("\(Self.self).saveGame is unimplemented") }
+ public static let unimplemented = Self(
+ fetchGamesForWord: XCTUnimplemented("\(Self.self).fetchGamesForWord"),
+ fetchStats: XCTUnimplemented("\(Self.self).fetchStats"),
+ fetchVocab: XCTUnimplemented("\(Self.self).fetchVocab"),
+ migrate: XCTUnimplemented("\(Self.self).migrate"),
+ playedGamesCount: XCTUnimplemented("\(Self.self).playedGamesCount"),
+ saveGame: XCTUnimplemented("\(Self.self).saveGame")
)
#endif
}
diff --git a/Sources/LowPowerModeClient/Client.swift b/Sources/LowPowerModeClient/Client.swift
index ecf962ec..ac020c2c 100644
--- a/Sources/LowPowerModeClient/Client.swift
+++ b/Sources/LowPowerModeClient/Client.swift
@@ -3,5 +3,5 @@ import ComposableArchitecture
import Foundation
public struct LowPowerModeClient {
- public var start: Effect
+ public var start: @Sendable () async -> AsyncStream
}
diff --git a/Sources/LowPowerModeClient/Live.swift b/Sources/LowPowerModeClient/Live.swift
index 780660ec..7cfa3546 100644
--- a/Sources/LowPowerModeClient/Live.swift
+++ b/Sources/LowPowerModeClient/Live.swift
@@ -3,13 +3,21 @@ import Foundation
extension LowPowerModeClient {
public static var live = Self(
- start: Publishers.Merge(
- Deferred { Just(ProcessInfo.processInfo.isLowPowerModeEnabled) },
-
- NotificationCenter.default
- .publisher(for: .NSProcessInfoPowerStateDidChange)
- .map { _ in ProcessInfo.processInfo.isLowPowerModeEnabled }
- )
- .eraseToEffect()
+ start: {
+ AsyncStream { continuation in
+ continuation.yield(ProcessInfo.processInfo.isLowPowerModeEnabled)
+ let task = Task {
+ let powerStateDidChange = NotificationCenter.default
+ .notifications(named: .NSProcessInfoPowerStateDidChange)
+ .map { _ in ProcessInfo.processInfo.isLowPowerModeEnabled }
+ for await isLowPowerModeEnabled in powerStateDidChange {
+ continuation.yield(isLowPowerModeEnabled)
+ }
+ }
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
+ }
+ }
)
}
diff --git a/Sources/LowPowerModeClient/Mocks.swift b/Sources/LowPowerModeClient/Mocks.swift
index a8a1118a..38171efc 100644
--- a/Sources/LowPowerModeClient/Mocks.swift
+++ b/Sources/LowPowerModeClient/Mocks.swift
@@ -1,19 +1,40 @@
import Combine
+import CombineSchedulers
import ComposableArchitecture
import Foundation
+import XCTestDynamicOverlay
extension LowPowerModeClient {
- public static let `false` = Self(start: Just(false).eraseToEffect())
- public static let `true` = Self(start: Just(true).eraseToEffect())
+ public static let `false` = Self(
+ start: { AsyncStream { $0.yield(false) } }
+ )
+
+ public static let `true` = Self(
+ start: { AsyncStream { $0.yield(true) } }
+ )
#if DEBUG
- public static let failing = Self(start: .failing("\(Self.self).start is unimplemented"))
+ public static let unimplemented = Self(
+ start: XCTUnimplemented("\(Self.self).start")
+ )
public static var backAndForth: Self {
Self(
- start: Timer.publish(every: 2, on: .main, in: .default)
- .autoconnect()
- .scan(false) { a, _ in !a }
- .eraseToEffect()
+ start: {
+ AsyncStream { continuation in
+ let isLowPowerModeEnabled = ActorIsolated(false)
+ Task {
+ await continuation.yield(isLowPowerModeEnabled.value)
+ for await _ in DispatchQueue.main.timer(interval: 2) {
+ let isLowPowerModeEnabled = await isLowPowerModeEnabled
+ .withValue { isLowPowerModeEnabled -> Bool in
+ isLowPowerModeEnabled.toggle()
+ return isLowPowerModeEnabled
+ }
+ continuation.yield(isLowPowerModeEnabled)
+ }
+ }
+ }
+ }
)
}
#endif
diff --git a/Sources/MailgunClient/Mocks.swift b/Sources/MailgunClient/Mocks.swift
index 89bdc630..9ed01067 100644
--- a/Sources/MailgunClient/Mocks.swift
+++ b/Sources/MailgunClient/Mocks.swift
@@ -2,9 +2,9 @@ import ServerTestHelpers
#if DEBUG
extension MailgunClient {
- public static let failing = Self(
+ public static let unimplemented = Self(
sendEmail: { _ in
- .failing("\(Self.self).sendEmail is unimplemented")
+ .unimplemented("\(Self.self).sendEmail")
}
)
}
diff --git a/Sources/MultiplayerFeature/MultiplayerView.swift b/Sources/MultiplayerFeature/MultiplayerView.swift
index 3aabfebe..ccbf70bf 100644
--- a/Sources/MultiplayerFeature/MultiplayerView.swift
+++ b/Sources/MultiplayerFeature/MultiplayerView.swift
@@ -63,13 +63,7 @@ public let multiplayerReducer = Reducer<
._pullback(
state: (\MultiplayerState.route).appending(path: /MultiplayerState.Route.pastGames),
action: /MultiplayerAction.pastGames,
- environment: {
- PastGamesEnvironment(
- backgroundQueue: $0.backgroundQueue,
- gameCenter: $0.gameCenter,
- mainQueue: $0.mainQueue
- )
- }
+ environment: { PastGamesEnvironment(gameCenter: $0.gameCenter) }
),
.init { state, action, environment in
@@ -78,14 +72,12 @@ public let multiplayerReducer = Reducer<
return .none
case .startButtonTapped:
- if environment.gameCenter.localPlayer.localPlayer().isAuthenticated {
- return environment.gameCenter.turnBasedMatchmakerViewController
- .present(showExistingMatches: false)
- .fireAndForget()
-
- } else {
- return environment.gameCenter.localPlayer.presentAuthenticationViewController
- .fireAndForget()
+ return .fireAndForget {
+ if environment.gameCenter.localPlayer.localPlayer().isAuthenticated {
+ try await environment.gameCenter.turnBasedMatchmakerViewController.present(false)
+ } else {
+ await environment.gameCenter.localPlayer.presentAuthenticationViewController()
+ }
}
case .setNavigation(tag: .pastGames):
diff --git a/Sources/MultiplayerFeature/PastGameRow.swift b/Sources/MultiplayerFeature/PastGameRow.swift
index 268f3ea9..30ea857c 100644
--- a/Sources/MultiplayerFeature/PastGameRow.swift
+++ b/Sources/MultiplayerFeature/PastGameRow.swift
@@ -38,9 +38,9 @@ public struct PastGameState: Equatable, Identifiable {
public enum PastGameAction: Equatable {
case delegate(DelegateAction)
case dismissAlert
- case matchResponse(Result)
+ case matchResponse(TaskResult)
case rematchButtonTapped
- case rematchResponse(Result)
+ case rematchResponse(TaskResult)
case tappedRow
public enum DelegateAction: Equatable {
@@ -50,7 +50,6 @@ public enum PastGameAction: Equatable {
struct PastGameEnvironment {
var gameCenter: GameCenterClient
- var mainQueue: AnySchedulerOf
}
let pastGameReducer = Reducer {
@@ -67,15 +66,11 @@ let pastGameReducer = Reducer