I'm a developer. Most days that means sitting at a desk for eight to ten hours straight, and over time that caught up with me: I was getting heavier every year. I started looking for an intermittent fasting app on iOS, since that's the phone I actually carry. Everything I found was either bloated with features I'd never touch, or locked behind a subscription just to use a timer.
At some point I thought: why not just build the thing myself? The idea came from a coworker, JC, who had already built and shipped his own app. Seeing that made it feel achievable, and since his app was built in Flutter, that settled the tech stack for me too. It would kill two birds with one stone: I'd get a simple app I'd actually use every day, and I'd have a real, shipped iOS app to put on my portfolio. That's how Nimbus: Fasting started, built in Flutter so the same codebase could eventually cover Android too.
Then, before I'd written a line of the app itself, I hit two walls. First, Apple charges $99 a year (about ₱6,200 at today's rate) just to be allowed to publish anything, whether it's a hobby project or a business. Second, and worse, iOS development normally requires a Mac, and I don't own one. I found a workaround for the Mac problem (more on it below), decided the $99 was worth it, and went ahead anyway. To help that yearly fee pay for itself, the app carries a small AdMob banner plus a one-time "Remove Ads" purchase instead of a subscription, so with any luck it covers its own renewal cost without ever charging anyone just to use it. Then came the actual struggles.
No Mac, no problem, until you need one
The workaround was Codemagic: a cloud CI service that builds, signs, and publishes the iOS app on a hosted Mac instance, so I never need one locally. It builds the Flutter app, signs it with an App Store distribution profile, and pushes straight to TestFlight through App Store Connect. Even the very first config had a mistake: Codemagic's schema requires integrations as a top-level workflow key, not nested under environment. Its own validation caught that before the first build ever ran, which was a good early sign of how strict the rest of this process would be.
The provisioning profile that wasn't the problem, twice
The first real wall was IPA export failing with "requires a provisioning profile with the HealthKit feature," even after regenerating the profile with HealthKit confirmed present on the App ID. The cause was subtle: flutter build ipa was resolving its own export signing instead of using the profile Codemagic had already resolved correctly. Passing --export-options-plist explicitly fixed it.
Then the same class of error came back with a scarier name: "requires a provisioning profile with the HealthKit and HealthKit Access (Verifiable Health Records) features." The app's entitlements file had a leftover com.apple.developer.healthkit.access key, which declares Clinical Health Records access, a much more sensitive capability than plain HealthKit. The app only ever reads and writes Weight through the standard HealthKit API. Removing the unused entitlement fixed the export for good.
Permissions you ask for need a reason
Apple checks that every permission you request is actually used. The app was requesting Steps read access to "sharpen the activity estimate," but no code path ever read step data back, so that permission request, the onboarding copy explaining it, and both privacy policy mentions all had to come out under Guideline 5.1.1 (Data Collection and Storage). A leftover NSUserTrackingUsageDescription key caused a similar problem later: it was never backed by an actual App Tracking Transparency prompt (the app only ever serves non-personalized ads), and its mere presence triggered an App Privacy warning in App Store Connect for no reason.
The paperwork Apple wants before it even looks at the app
A few requirements had nothing to do with the app's code at all:
- AdMob needs 50
SKAdNetworkItemsidentifiers inInfo.plistfor ad attribution, pulled straight from Google's setup docs. - App Store Connect asks an export compliance question on every upload unless you declare it upfront. Since the app only uses standard HTTPS through the AdMob and StoreKit SDKs, setting
ITSAppUsesNonExemptEncryptionto false skips that manual prompt for good. - App Store Connect requires a real, hosted Privacy Policy URL and a Support URL. The in-app privacy screen isn't enough. Both ended up as small static pages hosted on GitLab Pages.
Getting to the first real submission
Two more changes came right before the first submission. TARGETED_DEVICE_FAMILY was set to universal (iPhone and iPad), which meant App Store Connect wanted iPad screenshots for a UI that was never designed for one. Restricting the build to iPhone only removed that requirement. The version also moved from a development 0.1.0 to 1.0.0 to mark it as an actual release candidate.
Two rejections, two lessons
A build number that never moved. App Store Connect rejected an upload outright: "bundle version must be higher than the previously uploaded version: 1." The build number in pubspec.yaml hadn't been bumped by hand across roughly ten commits since the first successful upload, so every new build was silently trying to re-upload a version that already existed. The fix was to stop bumping it by hand: using the current Unix timestamp as the build number makes it strictly increasing by construction, so this specific bug can't happen again.
"Free" isn't free advertising. Apple rejected the listing under Guideline 2.3.7 (Accurate Metadata) because "Free" in the name "Nimbus: Free Intermittent Fasting" read like a price claim. That meant renaming the app everywhere it appeared: the in-app title, the welcome screen wordmark, iOS's CFBundleDisplayName, Android's label, the privacy and support pages, even internal docs, settling on the shorter "Nimbus: Fasting."
Approved, then back for round two
Getting approved wasn't the end of it. A follow-up resubmission was needed to fix an app-ads.txt and Marketing URL issue, and that surfaced one more wrinkle: the acceptance email implied the live version was 1.0, but the actual build logs showed it was 1.0.1. Apple rejects a same-or-lower version string on resubmission, so the fix required trusting the build log over the email and bumping the version past what was really live.
How long did this actually take?
Start to finish, from the first Codemagic build to the final approved resubmission, this was about 18 days. Barely any of that was spent writing app code. Almost all of it was waiting on App Review, or fixing one specific, narrow rejection at a time.
What I'd tell myself before starting
- Entitlements and permissions have to match what the code actually does, exactly. Anything unused is a review risk, not a harmless default.
- Never hand-bump a build number. Make it auto-increment so an entire category of rejection becomes impossible.
- Have your Privacy Policy and Support URLs live and hosted before you submit, not while you're scrambling to fix a rejection.
- If Apple's email and your build logs disagree about what's actually live, believe the build log.
Nimbus: Fasting is live on the App Store now. Since it's built in Flutter, the same codebase is currently going through Android submission to Google Play, which has its own list of surprises. More on that in a future post.