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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions Scrabbdict/Modules/App/ScrabbdictApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//

import ComposableArchitecture
import StoreKit
import SwiftUI

@main
Expand All @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion Scrabbdict/Modules/Scrabbdict/ScrabbdictFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions Scrabbdict/Services/AppReviewClient.swift
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions Scrabbdict/Services/LanguageStorageClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)

Expand Down
8 changes: 6 additions & 2 deletions Scrabbdict/Services/SearchModeStorageClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
)

Expand Down
33 changes: 33 additions & 0 deletions ScrabbdictTests/Features/ScrabbdictFeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions ScrabbdictTests/Services/AppReviewClientTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 24 additions & 0 deletions ScrabbdictTests/Services/LanguageStorageClientTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
24 changes: 24 additions & 0 deletions ScrabbdictTests/Services/SearchModeStorageClientTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}