From 2addfe9b080132dd978a27929d22297d3610b100 Mon Sep 17 00:00:00 2001 From: Abdo Eaita Date: Tue, 16 Jun 2026 15:04:36 +0200 Subject: [PATCH] Add Nightcap review prompt after app add --- Nightcap/AppFeature.swift | 10 ++++--- Nightcap/Services/ReviewPromptClient.swift | 34 ++++++++++++++++++++++ NightcapTests/NightcapAppTests.swift | 24 ++++++++++++++- 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 Nightcap/Services/ReviewPromptClient.swift diff --git a/Nightcap/AppFeature.swift b/Nightcap/AppFeature.swift index c6b0e49..b2462fb 100644 --- a/Nightcap/AppFeature.swift +++ b/Nightcap/AppFeature.swift @@ -34,6 +34,7 @@ struct AppFeature { @Dependency(\.powerAssertionClient) var assertion @Dependency(\.launchAtLoginClient) var launchAtLogin @Dependency(\.appQuitterClient) var quitter + @Dependency(\.reviewPromptClient) var reviewPrompt var body: some ReducerOf { Reduce { state, action in @@ -75,14 +76,15 @@ struct AppFeature { return .none case let .addAppRequested(app): - if !state.watchedApps.contains(where: { $0.bundleID == app.bundleID }) { - state.$watchedApps.withLock { $0.append(app) } - } + guard !state.watchedApps.contains(where: { $0.bundleID == app.bundleID }) else { return .none } + state.$watchedApps.withLock { $0.append(app) } if lifecycle.runningBundleIDs().contains(app.bundleID) { state.runningWatchedIDs.insert(app.bundleID) syncAssertion(&state) } - return .none + return .run { _ in + reviewPrompt.requestIfAppropriate() + } case let .removeAppRequested(id): state.$watchedApps.withLock { $0.removeAll { $0.bundleID == id } } diff --git a/Nightcap/Services/ReviewPromptClient.swift b/Nightcap/Services/ReviewPromptClient.swift new file mode 100644 index 0000000..ad908cb --- /dev/null +++ b/Nightcap/Services/ReviewPromptClient.swift @@ -0,0 +1,34 @@ +import Dependencies +import DependenciesMacros +import Foundation +import StoreKit + +@DependencyClient +struct ReviewPromptClient: Sendable { + var requestIfAppropriate: @Sendable () -> Void +} + +extension ReviewPromptClient: DependencyKey { + static let liveValue: ReviewPromptClient = .init( + requestIfAppropriate: { + let defaults = UserDefaults.standard + let key = "reviewPrompt.lastRequestDate" + let now = Date() + if let last = defaults.object(forKey: key) as? Date, + now.timeIntervalSince(last) < 60 * 60 * 24 * 30 { + return + } + defaults.set(now, forKey: key) + SKStoreReviewController.requestReview() + } + ) + + static let testValue = ReviewPromptClient(requestIfAppropriate: {}) +} + +extension DependencyValues { + var reviewPromptClient: ReviewPromptClient { + get { self[ReviewPromptClient.self] } + set { self[ReviewPromptClient.self] = newValue } + } +} diff --git a/NightcapTests/NightcapAppTests.swift b/NightcapTests/NightcapAppTests.swift index a11b5ea..91fcfe0 100644 --- a/NightcapTests/NightcapAppTests.swift +++ b/NightcapTests/NightcapAppTests.swift @@ -131,6 +131,23 @@ final class NightcapAppTests: XCTestCase { let duplicate = WatchedApp(bundleID: "com.mitchellh.ghostty", displayName: "Ghostty") await store.send(.addAppRequested(duplicate)) + XCTAssertEqual(env.reviewPrompts.value, 0) + } + + func test_add_new_app_requests_review_after_value_moment() async { + let env = makeEnv(running: []) + let store = makeStore(env: env) + + await store.send(.onAppear) { + $0.launchAtLoginStatus = .disabled + } + + let app = WatchedApp(bundleID: "com.example.writer", displayName: "Writer") + await store.send(.addAppRequested(app)) { + $0.$watchedApps.withLock { $0.append(app) } + } + + XCTAssertEqual(env.reviewPrompts.value, 1) } func test_observation_toggle_off_releases_running_app_without_removing_it() async { @@ -249,6 +266,7 @@ final class NightcapAppTests: XCTestCase { let runningApps: LockIsolated<[WatchedApp]> let acquired: LockIsolated<[String]> let released: LockIsolated + let reviewPrompts: LockIsolated } private func makeEnv( @@ -259,7 +277,8 @@ final class NightcapAppTests: XCTestCase { running: LockIsolated(running), runningApps: LockIsolated(runningApps), acquired: LockIsolated([]), - released: LockIsolated(0) + released: LockIsolated(0), + reviewPrompts: LockIsolated(0) ) } @@ -281,6 +300,9 @@ final class NightcapAppTests: XCTestCase { $0.powerAssertionClient.release = { env.released.withValue { $0 += 1 } } + $0.reviewPromptClient.requestIfAppropriate = { + env.reviewPrompts.withValue { $0 += 1 } + } } } }