I'm thrilled to share that I recently achieved a major milestone - my first iOS app, Daily Reps, got approved for distribution in the App Store!
As an individual who's passionate about fitness and technology, creating this app was a dream come true. Daily Reps is designed for people who want to stay committed to their daily workout routine by tracking the number of reps they complete throughout the day. The core idea behind Daily Reps is straightforward: track your progress towards daily rep goals. For example, if you want to do 100 push-ups per day, Daily Reps lets you set this goal to stay on track.
Looking back on my journey to get Daily Reps approved, I'm grateful for the experience and what I learned along the way. In this article, I'll share some insights into the process, including the challenges I faced and the successes I achieved.
🔽 Download Daily Reps from the App Store to try it out!
Inspiration
I've been iterating on the idea of Daily Reps for years, this is actually the fourth iteration. I was asked to write a piece for the Expo blog, so this felt like a great opportunity to use the idea once again since it really felt like it was meant to be a mobile app.
With Expo and React Native, I built a fully functional prototype and wrote an in-depth guide for the Expo blog (which you can read here). This experience gave me the confidence boost I needed to see this project through, despite having no prior experience shipping an app to the App Store. So I started iterating on it again in my free time.
The tech stack
React Native & Expo
As the core technology powering Daily Reps, React Native and Expo enable me to build a seamless user experience across both iOS and Android platforms. By leveraging their features such as hot reloading and easy integration with React Native, I was able to create a robust and scalable app that meets the needs of users.
I'm also using Expo to create local builds of the app that are uploaded to Apple, but more on that in the next section.
Clerk
For authentication, I use Clerk to handle user logins and ensure that only authorized individuals can access the app's features and functionality. I've integrated both Google and Apple social providers so users can log in with their preferred method. Clerk is also going to be super helpful when I get the other versions of the app going.
Convex
Convex provides a real-time database solution for Daily Reps, allowing me to store and retrieve data in a highly efficient and scalable manner. This enables me to provide users with a seamless experience, even as they engage with the app and perform various actions. This will be especially useful when I start building the web and Android versions.
RevenueCat
RevenueCat plays a crucial role in handling subscriptions and entitlement management for Daily Reps. By integrating this service, I am able to offer users a range of subscription-based features and ensure that only authorized individuals can access premium content and functionality.
Monetization
Here's the expanded text:
I wanted to monetize Daily Reps, so I created a pro version with additional features to earn consistent revenue. I've landed on max two workouts for now, with more workout types coming in the future (time, distance, etc.). I may eventually tweak this limitation, but it works for now.
To integrate RevenueCat's subscription logic throughout the app, I built a custom <SubscriptionProvider>
component that allows me to easily check the subscription status and allow or deny actions based on the user's subscription. This component can be used anywhere in the app where subscription-related decisions need to be made.
Here's an example of how I implemented this:
import { Platform } from 'react-native';
import { useState, useEffect, ReactNode } from 'react';
import { SubscriptionContext } from './SubscriptionContext';
import Purchases from 'react-native-purchases';
import { useUser } from '@clerk/clerk-expo';
const revenueCatKey = process.env.EXPO_PUBLIC_REVENUECAT_KEY!
if (!revenueCatKey) {
throw new Error(
'Missing RevenueCat Key. Please set EXPO_PUBLIC_REVENUECAT_KEY in your .env',
)
}
export function SubscriptionProvider({ children }: { children: ReactNode }) {
const { user } = useUser();
const [isSubscribed, setIsSubscribed] = useState<boolean | null>(null);
const [isInitStarted, setIsInitStarted] = useState(false);
const checkSubscriptionStatus = async () => {
console.log('[SubscriptionProvider] Checking subscription status...');
if (Platform.OS === 'ios') {
try {
const customerInfo = await Purchases.getCustomerInfo();
console.log('[SubscriptionProvider] Customer info:', JSON.stringify(customerInfo, null, 2));
if(typeof customerInfo.entitlements.active["Daily Reps Pro"] !== "undefined") {
setIsSubscribed(true);
} else {
setIsSubscribed(false);
}
} catch (error) {
console.error('[SubscriptionProvider] Error checking subscription:', error);
setIsSubscribed(false);
}
} else {
// Add Android implementation here
setIsSubscribed(false);
}
};
// Initial setup - only runs once
useEffect(() => {
async function init() {
if(isInitStarted) return
setIsInitStarted(true);
Purchases.configure({
apiKey: revenueCatKey,
appUserID: user?.id
});
// Add listener to check subscription status
Purchases.addCustomerInfoUpdateListener(async () => {
await checkSubscriptionStatus()
});
await checkSubscriptionStatus();
}
void init();
}, []);
async function recheckSubscriptionStatus() {
await checkSubscriptionStatus();
}
return (
<SubscriptionContext.Provider value={{ isSubscribed, recheckSubscriptionStatus }}>
{children}
</SubscriptionContext.Provider>
);
}
Then the context:
import { createContext, useContext } from 'react';
const MONTHLY_ID = "pro_monthly_5_sub"
const YEARLY_ID = "pro_yearly_45_sub"
interface SubscriptionContextType {
isSubscribed: boolean | null;
recheckSubscriptionStatus: () => Promise<void>;
}
export const SubscriptionContext = createContext<SubscriptionContextType | null>(null);
export function useSubscription() {
const context = useContext(SubscriptionContext);
if (!context) {
throw new Error('useSubscription must be used within a SubscriptionProvider');
}
return context;
}
// Export the subscription IDs if needed elsewhere
export { MONTHLY_ID, YEARLY_ID };
I also added Google Ads to the app. Since I already had an account from previous projects (GuardianForge, my website), it was a breeze to create a component that renders ads that also uses my <SubscriptionProvider>
to hide them if the user is subscribed.
Here's an example of how I implemented this:
import { useSubscription } from '@/contexts/SubscriptionContext';
import React, { useRef, useState } from 'react';
import { Platform, Text } from 'react-native';
import { BannerAd, BannerAdSize, TestIds, useForeground } from 'react-native-google-mobile-ads';
const adUnitId = __DEV__ ? TestIds.ADAPTIVE_BANNER : 'ca-app-pub-123123123123/123123123123';
export function GlobalBannerAd() {
const { isSubscribed } = useSubscription()
const [didFail, setDidFail] = useState(false)
const bannerRef = useRef<BannerAd>(null);
useForeground(() => {
Platform.OS === 'ios' && bannerRef.current?.load();
})
if(isSubscribed) {
return null
}
if(didFail) {
return null
}
return (
<>
<BannerAd
onAdFailedToLoad={() => setDidFail(true)}
ref={bannerRef}
unitId={adUnitId}
size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER} />
</>
);
}
Building and deploying
Expo provides a build service with a limited number of build minutes on the free tier, as well as a relatively long wait queue. To ensure a smoother development process, I took the time to learn how to create builds locally. This approach allows me to maintain more control over my project and conserve resources.
The script I developed creates a build and stores it in a builds
folder that is intentionally ignored by Git. This means that changes made during the local build process won't be tracked, allowing for clean experimentation without affecting the main project repository. With this setup, I can quickly generate builds as needed, without relying on remote services.
Here is my script:
#!/bin/bash
FOLDER_PATH="./build"
# Create a build folder for artifacts
if [ ! -d "$FOLDER_PATH" ]; then
mkdir -p "$FOLDER_PATH"
fi
# Build the thing
eas build -p ios --profile production --local --clear-cache --non-interactive
# Get the filename of the latest build
LATEST_BUILD=$(ls -t build-* 2>/dev/null | head -1)
# Move the file into the build dir and deploy it to the device
if [ -n "$LATEST_BUILD" ]; then
mv $LATEST_BUILD $FOLDER_PATH/prod-$LATEST_BUILD
else
echo "No builds found."
fi
Seamlessly Deploying Builds to My Phone
In addition to local builds, I'm also using Expo to deploy builds directly to my phone using the npx expo run:ios -d
command. This feature enables me to test and iterate on my project more efficiently, as I can instantly transfer new builds to my device without needing to wait for remote processing or upload files manually. These builds also use hot reloading as well so I can code on my Mac and the changes reflect on the phone in real time.
Letting People Use It Via TestFlight
I was excited to share my app with others and get their feedback, so I decided to leverage my family and friends to test it out. By using TestFlight, I was able to invite them to join the testing process and provide me with valuable insights on how to improve my app.
One of the benefits of using TestFlight is that I have immediate access to builds as they are updated, which allows me to make changes quickly based on user feedback. However, for non-developers, there is a lightweight approval process required by Apple to let those users access the build.
To invite others to test my app, I have the option to send them an email invitation or simply share a link for multiple users to join in on the testing process. This makes it easy to get feedback from a wide range of people and helps me identify any issues or areas that need improvement before releasing the app to the public.
For gathering feedback, I'm just using the fullstack.chat discord for now.
Issues I ran into
Technical Challenges with App Store Connect
App Store Connect can be a frustrating experience. The interface feels dated and lacks intuitive guidance when something goes awry. For instance, I encountered an issue where my subscriptions were missing information, but it turned out to be the subscription group that was incomplete. The lack of clear indicators or error messages made it difficult to identify and resolve the problem.
Setting Up In-App Purchases
In-app purchases were a struggle to get working properly. I initially attempted to handle in-app purchases myself but soon realized that validating purchases required a server-side solution. I eventually just decided to use RevenueCat to let them handle that.
Dev Environment In-App Purchase Challenges
When testing in-app purchases in the dev environment, I needed to have a dedicated dev iCloud account. I initially used my main iCloud account to test a subscription which led to an issue where subscriptions made through the sandbox environment cannot be unsubscribed until the testing window closes. There was a vague error when I tried to manage the subscription on my main iCloud account.
Thankfully, this default testing window is relatively short, but it's still a challenge that developers should be aware of when working with in-app purchases.
Whats next
I have a ton of ideas that I want to build next year, from different workout types, overhauling the app design, and integrating social features. I also want to build web and Android versions so non-iOS users can use Daily Reps as well!
🔽 Download Daily Reps from the App Store to try it out!
Top comments (0)