The trigger was a small uneasy feeling I had late one night looking at App Store Connect. I run five wallpaper apps in the same family, yet the way reviews accumulated and the average ratings were drifting in noticeably different directions. The apps themselves were not that different in quality, so the culprit had to be somewhere in the prompt logic. I opened Antigravity Editor and started untangling it.
I have been publishing apps as an indie developer since 2014, and the cumulative installs across them have passed 50 million. The longer you run a portfolio, the more these silent drifts in behavior — different prompt timing, different defaults, a typo in a key that nobody caught — quietly eat into the user experience. This is a quiet record of how I spent a few days bringing those five apps back into one consistent shape, with Antigravity Editor's multi-file editing keeping pace alongside me.
The variance was just early-stage copy-paste drift
The first thing I did was open the five repositories in parallel in Antigravity Editor and list every call site for SKStoreReviewController.requestReview. The conditions came back looking roughly like this.
- App A: at least 5 launches, at least 90 days since the last prompt
- App B: 3 launches, 60-day gap
- App C: triggered after the user tapped "favorite" once, on the next cold launch
- App D: 10 launches, no minimum gap at all
- App E: 4 launches, and a "days since last prompt" check that, due to a typo in the UserDefaults key, had never actually been writing the date back
App E was the painful one. The prompt was firing, but the "when did we last show it" timestamp had never been recorded, which meant a user who declined could see the same dialog again disturbingly soon. The slightly higher rate of low ratings on that one finally made sense.
Maybe it is the influence of my two grandfathers, both temple carpenters, but small inconsistencies like this leave a knot in my chest. Treat the small joints carefully — that is one of the few unwritten things I inherited from them, and I think the same applies to tiny UX strings and dialogs.
Pulling the shared logic into one coordinator
The plan was to consolidate the scattered logic into a single ReviewPromptCoordinator.swift and let each app inject its own extra condition as a closure. I asked Antigravity Editor to create the same file in all five repositories, adjusting the import paths to fit each project. The diff preview opened as five tabs side by side, which made it easy to visually confirm each one before applying.
import StoreKit
import UIKit
public final class ReviewPromptCoordinator {
public struct Config {
let minLaunchCount: Int
let minDaysSinceLastPrompt: Int
let additionalCondition: () -> Bool
}
private let config: Config
private let defaults: UserDefaults
private static let lastPromptKey = "review.prompt.lastShownAt.v2"
private static let launchCountKey = "review.prompt.launchCount.v2"
public init(config: Config, defaults: UserDefaults = .standard) {
self.config = config
self.defaults = defaults
}
public func registerLaunch() {
let current = defaults.integer(forKey: Self.launchCountKey)
defaults.set(current + 1, forKey: Self.launchCountKey)
}
public func requestIfEligible(in scene: UIWindowScene) {
guard isEligible() else { return }
SKStoreReviewController.requestReview(in: scene)
defaults.set(Date(), forKey: Self.lastPromptKey)
}
private func isEligible() -> Bool {
let count = defaults.integer(forKey: Self.launchCountKey)
guard count >= config.minLaunchCount else { return false }
if let last = defaults.object(forKey: Self.lastPromptKey) as? Date {
let days = Calendar.current.dateComponents([.day], from: last, to: Date()).day ?? 0
guard days >= config.minDaysSinceLastPrompt else { return false }
}
return config.additionalCondition()
}
}I deliberately suffixed the UserDefaults keys with .v2. The point was to cleanly break the broken state in App E rather than migrate from a key that had never been written. Letting one extra prompt fire once for some users felt healthier, on balance, than dragging along a broken legacy state.
Per-app conditions stay as plain closures
With the shared base in place, each app just supplies the moment when a review prompt feels comfortable to ask for. For example, showing it only to users who have saved at least three favorite wallpapers.
let coordinator = ReviewPromptCoordinator(config: .init(
minLaunchCount: 5,
minDaysSinceLastPrompt: 120,
additionalCondition: {
FavoriteStore.shared.count >= 3
}
))Initializing this near the AppDelegate or App struct of each project made the flow far easier to read at a glance. I kept Antigravity Editor's diff views side by side, edited each of the five files in turn, and re-read the result myself before applying. I do not hand the final touches entirely over to the agent. That last bit of work I prefer to do with my own hands.
What the agent quietly caught for me
The genuinely helpful moment came as a note the agent left in the diff preview. App B's older code was double-incrementing the launch counter — once on actual launch, and once when the settings screen opened. I had not noticed it while I was busy stitching the new coordinator in. The new path was getting a correct launch count, but the old code that lived elsewhere was still inflating the legacy value.
The agent had calmly written, under the diff, "this call site would stay more consistent if it migrated to the new Coordinator as well." I do not enjoy talking about AI in superlatives, but having something quietly watch the edges where humans tend to cut corners is honestly useful.
How to communicate the App E bug
Because App E had been missing the "last shown" timestamp for some period of time, some users who declined the dialog likely saw it again sooner than they should have. I treated this as a question of honesty to those users and added one quiet line to the release notes.
It read, "due to an internal issue, the review prompt may have been shown more often than intended; this version corrects that." No drama, but also not hidden. That is roughly the temperature I want my work to have when I look at it later, including for my children who live apart from me.
Watching review prompt timing carefully
After the unification, I drew up a plan to split the minLaunchCount and the favorites-count threshold into two cohorts and quietly observe their indirect effect on AdMob rewarded views and retention through Firebase. That belongs in its own write-up, so I will not go deeper here. The point is that prompt timing affects not only the average rating but also the next-session return rate.
A few days of impressions
Before unification, the number of reviews per month between the top-performing app and the worst-performing one was differing by several multiples. It has only been a few days since the changes, so it is too early to claim anything decisive, but the inflow of low ratings does feel more settled. The temptation in a blog post is to declare an enormous improvement, but a few days of data is not enough to honestly say that. I let the implementation rest quietly and plan to look at the numbers in a few months.
What I will work on next
Using the same approach, I plan to unify the in-app notification copy and the empty-state text across the five apps. Since I started teaching myself programming in 1997 at the age of sixteen, I have been looking for the quiet satisfaction of staying in the same place and writing code with care. These small unification tasks sit very close to that feeling.
Thank you for reading. I hope this is useful to other indie developers running multiple apps in the same family.