From a789d3214371988043b82758c70a86dd3c5d0bf7 Mon Sep 17 00:00:00 2001 From: Piotr Sochalewski Date: Sun, 7 Jun 2026 16:12:43 +0200 Subject: [PATCH 1/2] [AI] Request App Store reviews --- Scrabbdict/Modules/App/ScrabbdictApp.swift | 25 ++++-- .../Scrabbdict/ScrabbdictFeature.swift | 7 +- Scrabbdict/Services/AppReviewClient.swift | 63 +++++++++++++++ .../Features/ScrabbdictFeatureTests.swift | 33 ++++++++ .../Services/AppReviewClientTests.swift | 79 +++++++++++++++++++ 5 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 Scrabbdict/Services/AppReviewClient.swift create mode 100644 ScrabbdictTests/Services/AppReviewClientTests.swift diff --git a/Scrabbdict/Modules/App/ScrabbdictApp.swift b/Scrabbdict/Modules/App/ScrabbdictApp.swift index 701c3a9..a02b310 100644 --- a/Scrabbdict/Modules/App/ScrabbdictApp.swift +++ b/Scrabbdict/Modules/App/ScrabbdictApp.swift @@ -5,6 +5,7 @@ // import ComposableArchitecture +import StoreKit import SwiftUI @main @@ -13,18 +14,30 @@ struct ScrabbdictApp: App { var body: some Scene { WindowGroup { - ScrabbdictView( - store: Store(initialState: ScrabbdictFeature.State()) { - ScrabbdictFeature() - } - ) + ScrabbdictRootView() #if DEBUG && targetEnvironment(simulator) - .dynamicTypeSizeOverlay() + .dynamicTypeSizeOverlay() #endif } } } +private struct ScrabbdictRootView: View { + @Environment(\.requestReview) var requestReview + + var body: some View { + ScrabbdictView( + store: Store(initialState: ScrabbdictFeature.State()) { + ScrabbdictFeature() + } withDependencies: { + $0.appReviewClient.requestReview = { + requestReview() + } + } + ) + } +} + #if DEBUG && targetEnvironment(simulator) private extension View { func dynamicTypeSizeOverlay() -> some View { diff --git a/Scrabbdict/Modules/Scrabbdict/ScrabbdictFeature.swift b/Scrabbdict/Modules/Scrabbdict/ScrabbdictFeature.swift index df05989..3a8744a 100644 --- a/Scrabbdict/Modules/Scrabbdict/ScrabbdictFeature.swift +++ b/Scrabbdict/Modules/Scrabbdict/ScrabbdictFeature.swift @@ -64,6 +64,8 @@ struct ScrabbdictFeature { @Dependency(\.crashlyticsClient) var crashlytics @Dependency(\.analyticsClient) var analytics + @Dependency(\.appReviewClient) var appReview + @Dependency(\.continuousClock) var clock @Dependency(\.searchModeStorage) var searchModeStorage @Dependency(\.validatorClient) var validator @@ -263,7 +265,10 @@ private extension ScrabbdictFeature { case let .searchResponse(.checked(result)): state.result = result state.search = nil - return .none + return .run { _ in + try await clock.sleep(for: .seconds(2)) + await appReview.requestReviewIfAppropriate() + } case let .searchResponse(.failed(error)): state.search = nil state.alert = .init(kind: .dictionaryUnavailable) diff --git a/Scrabbdict/Services/AppReviewClient.swift b/Scrabbdict/Services/AppReviewClient.swift new file mode 100644 index 0000000..ae0be3c --- /dev/null +++ b/Scrabbdict/Services/AppReviewClient.swift @@ -0,0 +1,63 @@ +// +// Scrabbdict +// Copyright © 2026 Piotr Sochalewski. +// Licensed under the Apache License, Version 2.0. +// + +import ComposableArchitecture +import Foundation + +struct AppReviewClient: Sendable { + /// Requests an App Store review only after the review cooldown has passed. + /// + /// The first call stores the current date and does not ask for a review, so new users are not prompted immediately. + var requestReviewIfAppropriate: @MainActor @Sendable () -> Void + + /// Performs the actual SwiftUI review request. + /// + /// Override this with `@Environment(\.requestReview)` when constructing the app's store. + var requestReview: @MainActor @Sendable () -> Void +} + +extension AppReviewClient: DependencyKey { + static let liveValue = Self( + requestReviewIfAppropriate: { + @Dependency(\.appReviewClient.requestReview) var requestReview + @Dependency(\.defaultAppStorage) var appStorage + @Dependency(\.date.now) var now + + guard let lastRequestDate = appStorage.object(forKey: storageKey) as? Date else { + appStorage.set(now, forKey: storageKey) + return + } + + guard now.timeIntervalSince(lastRequestDate) >= appReviewMinimumRequestInterval else { + return + } + + appStorage.set(now, forKey: storageKey) + requestReview() + }, + requestReview: {} + ) + + static let testValue = Self( + requestReviewIfAppropriate: unimplemented("\(Self.self).requestReviewIfAppropriate"), + requestReview: unimplemented("\(Self.self).requestReview") + ) + + static let previewValue = Self( + requestReviewIfAppropriate: {}, + requestReview: {} + ) +} + +extension DependencyValues { + var appReviewClient: AppReviewClient { + get { self[AppReviewClient.self] } + set { self[AppReviewClient.self] = newValue } + } +} + +private let storageKey = "lastReviewRequestDate" +private let appReviewMinimumRequestInterval: TimeInterval = 60 * 24 * 60 * 60 diff --git a/ScrabbdictTests/Features/ScrabbdictFeatureTests.swift b/ScrabbdictTests/Features/ScrabbdictFeatureTests.swift index 952c00d..03caf8d 100644 --- a/ScrabbdictTests/Features/ScrabbdictFeatureTests.swift +++ b/ScrabbdictTests/Features/ScrabbdictFeatureTests.swift @@ -238,6 +238,8 @@ final class ScrabbdictFeatureTests: XCTestCase { let store = TestStore(initialState: ScrabbdictFeature.State(isSearchFocused: true, query: "pizza")) { ScrabbdictFeature() } withDependencies: { + $0.appReviewClient.requestReviewIfAppropriate = {} + $0.continuousClock = ImmediateClock() $0.validatorClient.check = { word async throws(ValidatorError) in XCTAssertEqual(word, "pizza") return .valid(points: 25) @@ -259,6 +261,35 @@ final class ScrabbdictFeatureTests: XCTestCase { } } + func testWordCheckRequestsReviewIfAppropriateAfterTwoSeconds() async { + let clock = TestClock() + let requestReviewCallsCount = LockIsolated(0) + let store = TestStore(initialState: ScrabbdictFeature.State(query: "pizza")) { + ScrabbdictFeature() + } withDependencies: { + $0.appReviewClient.requestReviewIfAppropriate = { + requestReviewCallsCount.withValue { $0 += 1 } + } + $0.continuousClock = clock + $0.validatorClient.check = { _ async throws(ValidatorError) in + .valid(points: 25) + } + } + + await store.send(.view(.searchButtonTapped)) { + $0.search = .result(showsRackWordsButton: true) + $0.showsRackWordsButton = true + } + await store.receive(.internal(.searchResponse(.checked(.valid(points: 25))))) { + $0.search = nil + $0.result = .valid(points: 25) + } + + XCTAssertEqual(requestReviewCallsCount.value, 0) + await clock.advance(by: .seconds(2)) + XCTAssertEqual(requestReviewCallsCount.value, 1) + } + func testRackWordsButtonHidesResultAndMatchesRackWords() async { let words = [Word(string: "pizza", points: 25)] let store = TestStore( @@ -314,6 +345,8 @@ final class ScrabbdictFeatureTests: XCTestCase { ) { ScrabbdictFeature() } withDependencies: { + $0.appReviewClient.requestReviewIfAppropriate = {} + $0.continuousClock = ImmediateClock() $0.validatorClient.check = { word async throws(ValidatorError) in XCTAssertEqual(word, "piz??") return .invalid diff --git a/ScrabbdictTests/Services/AppReviewClientTests.swift b/ScrabbdictTests/Services/AppReviewClientTests.swift new file mode 100644 index 0000000..157348d --- /dev/null +++ b/ScrabbdictTests/Services/AppReviewClientTests.swift @@ -0,0 +1,79 @@ +// +// ScrabbdictTests +// Copyright © 2026 Piotr Sochalewski. +// Licensed under the Apache License, Version 2.0. +// + +import ComposableArchitecture +import XCTest +@testable import Scrabbdict + +@MainActor +final class AppReviewClientTests: XCTestCase { + func testRequestReviewIfAppropriateStoresDateWithoutRequestingWhenNoPreviousRequestExists() { + let appStorage = UserDefaults.inMemory + let now = Date(timeIntervalSince1970: 0) + let requestReviewCallsCount = LockIsolated(0) + + withDependencies { + $0.appReviewClient = .liveValue + $0.appReviewClient.requestReview = { + requestReviewCallsCount.withValue { $0 += 1 } + } + $0.defaultAppStorage = appStorage + $0.date.now = now + } operation: { + @Dependency(\.appReviewClient.requestReviewIfAppropriate) var requestReviewIfAppropriate + requestReviewIfAppropriate() + } + + XCTAssertEqual(requestReviewCallsCount.value, 0) + XCTAssertEqual(appStorage.object(forKey: "lastReviewRequestDate") as? Date, now) + } + + func testRequestReviewIfAppropriateSkipsRequestBeforeSixtyDaysPass() { + let appStorage = UserDefaults.inMemory + let lastRequestDate = Date(timeIntervalSince1970: 0) + let now = lastRequestDate.addingTimeInterval(5_184_000 - 1) + appStorage.set(lastRequestDate, forKey: "lastReviewRequestDate") + let requestReviewCallsCount = LockIsolated(0) + + withDependencies { + $0.appReviewClient = .liveValue + $0.appReviewClient.requestReview = { + requestReviewCallsCount.withValue { $0 += 1 } + } + $0.defaultAppStorage = appStorage + $0.date.now = now + } operation: { + @Dependency(\.appReviewClient.requestReviewIfAppropriate) var requestReviewIfAppropriate + requestReviewIfAppropriate() + } + + XCTAssertEqual(requestReviewCallsCount.value, 0) + XCTAssertEqual(appStorage.object(forKey: "lastReviewRequestDate") as? Date, lastRequestDate) + } + + func testRequestReviewIfAppropriateRequestsWhenSixtyDaysPass() { + let appStorage = UserDefaults.inMemory + let lastRequestDate = Date(timeIntervalSince1970: 0) + let now = lastRequestDate.addingTimeInterval(5_184_000) + appStorage.set(lastRequestDate, forKey: "lastReviewRequestDate") + let requestReviewCallsCount = LockIsolated(0) + + withDependencies { + $0.appReviewClient = .liveValue + $0.appReviewClient.requestReview = { + requestReviewCallsCount.withValue { $0 += 1 } + } + $0.defaultAppStorage = appStorage + $0.date.now = now + } operation: { + @Dependency(\.appReviewClient.requestReviewIfAppropriate) var requestReviewIfAppropriate + requestReviewIfAppropriate() + } + + XCTAssertEqual(requestReviewCallsCount.value, 1) + XCTAssertEqual(appStorage.object(forKey: "lastReviewRequestDate") as? Date, now) + } +} From 9002df21c8dae98170bb4f66b6ab294e72d591c1 Mon Sep 17 00:00:00 2001 From: Piotr Sochalewski Date: Sun, 7 Jun 2026 16:16:11 +0200 Subject: [PATCH 2/2] [AI] Use injectable app storage in preference clients --- .../Services/LanguageStorageClient.swift | 8 +++++-- .../Services/SearchModeStorageClient.swift | 8 +++++-- .../Services/LanguageStorageClientTests.swift | 24 +++++++++++++++++++ .../SearchModeStorageClientTests.swift | 24 +++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 ScrabbdictTests/Services/LanguageStorageClientTests.swift create mode 100644 ScrabbdictTests/Services/SearchModeStorageClientTests.swift diff --git a/Scrabbdict/Services/LanguageStorageClient.swift b/Scrabbdict/Services/LanguageStorageClient.swift index 2499877..dae5653 100644 --- a/Scrabbdict/Services/LanguageStorageClient.swift +++ b/Scrabbdict/Services/LanguageStorageClient.swift @@ -15,14 +15,18 @@ struct LanguageStorageClient: Sendable { extension LanguageStorageClient: DependencyKey { static let liveValue = Self( current: { + @Dependency(\.defaultAppStorage) var appStorage + guard - let rawValue = UserDefaults.standard.string(forKey: storageKey), + let rawValue = appStorage.string(forKey: storageKey), let language = Language(rawValue: rawValue) else { return .englishUS } return language }, setCurrent: { language in - UserDefaults.standard.set(language.rawValue, forKey: storageKey) + @Dependency(\.defaultAppStorage) var appStorage + + appStorage.set(language.rawValue, forKey: storageKey) } ) diff --git a/Scrabbdict/Services/SearchModeStorageClient.swift b/Scrabbdict/Services/SearchModeStorageClient.swift index 0d5c47d..c9a7c97 100644 --- a/Scrabbdict/Services/SearchModeStorageClient.swift +++ b/Scrabbdict/Services/SearchModeStorageClient.swift @@ -15,11 +15,15 @@ struct SearchModeStorageClient: Sendable { extension SearchModeStorageClient: DependencyKey { static let liveValue = Self( current: { - let rawValue = UserDefaults.standard.integer(forKey: storageKey) + @Dependency(\.defaultAppStorage) var appStorage + + let rawValue = appStorage.integer(forKey: storageKey) return SearchMode(rawValue: rawValue) ?? .auto }, setCurrent: { searchMode in - UserDefaults.standard.set(searchMode.rawValue, forKey: storageKey) + @Dependency(\.defaultAppStorage) var appStorage + + appStorage.set(searchMode.rawValue, forKey: storageKey) } ) diff --git a/ScrabbdictTests/Services/LanguageStorageClientTests.swift b/ScrabbdictTests/Services/LanguageStorageClientTests.swift new file mode 100644 index 0000000..0e7d944 --- /dev/null +++ b/ScrabbdictTests/Services/LanguageStorageClientTests.swift @@ -0,0 +1,24 @@ +// +// ScrabbdictTests +// Copyright © 2026 Piotr Sochalewski. +// Licensed under the Apache License, Version 2.0. +// + +import ComposableArchitecture +import XCTest +@testable import Scrabbdict + +final class LanguageStorageClientTests: XCTestCase { + func testUsesDefaultAppStorage() { + withDependencies { + $0.defaultAppStorage = .inMemory + } operation: { + let client = LanguageStorageClient.liveValue + + XCTAssertEqual(client.current(), .englishUS) + + client.setCurrent(.polish) + XCTAssertEqual(client.current(), .polish) + } + } +} diff --git a/ScrabbdictTests/Services/SearchModeStorageClientTests.swift b/ScrabbdictTests/Services/SearchModeStorageClientTests.swift new file mode 100644 index 0000000..bd8b4f7 --- /dev/null +++ b/ScrabbdictTests/Services/SearchModeStorageClientTests.swift @@ -0,0 +1,24 @@ +// +// ScrabbdictTests +// Copyright © 2026 Piotr Sochalewski. +// Licensed under the Apache License, Version 2.0. +// + +import ComposableArchitecture +import XCTest +@testable import Scrabbdict + +final class SearchModeStorageClientTests: XCTestCase { + func testUsesDefaultAppStorage() { + withDependencies { + $0.defaultAppStorage = .inMemory + } operation: { + let client = SearchModeStorageClient.liveValue + + XCTAssertEqual(client.current(), .auto) + + client.setCurrent(.rack) + XCTAssertEqual(client.current(), .rack) + } + } +}