Awesome-omni-skill emrah-skills
Expo React Native mobile app development with expo-iap in-app purchases, AdMob ads, i18n localization, ATT tracking transparency, optional OIDC authentication, onboarding flow, paywall, and NativeTabs navigation
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/emrah-skills" ~/.claude/skills/diegosouzapw-awesome-omni-skill-emrah-skills && rm -rf "$T"
skills/development/emrah-skills/SKILL.mdExpo Mobile Application Development Guide
IMPORTANT: This is a SKILL file, NOT a project. NEVER run npm/bun install in this folder. NEVER create code files here. When creating a new project, ALWAYS ask the user for the project path first or create it in a separate directory (e.g.,
).~/Projects/app-name
This guide is created to provide context when working with Expo projects using Claude Code.
MANDATORY REQUIREMENTS
When creating a new Expo project, you MUST include ALL of the following:
Required Screens (ALWAYS CREATE)
-
- App Tracking Transparency permission screen (iOS only, shown BEFORE onboarding)src/app/att-permission.tsx -
- Swipe-based onboarding with fullscreen background video and gradient overlaysrc/app/onboarding.tsx -
- expo-iap paywall screen (shown after onboarding)src/app/paywall.tsx -
- Settings screen with language, theme, notifications, and reset onboarding optionssrc/app/settings.tsx
Onboarding Screen Implementation (REQUIRED)
The onboarding screen MUST have a fullscreen background video. Use a local asset (
require("@/assets/...")). The video is looped, muted, and played automatically.
Full implementation of
src/app/onboarding.tsx:
import { useOnboarding } from "@/context/onboarding-context"; import { MaterialIcons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { router } from "expo-router"; import { useVideoPlayer, VideoView } from "expo-video"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Dimensions, FlatList, StyleSheet, Text, TouchableOpacity, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; const VIDEO_SOURCE = require("@/assets/onboarding.mp4"); const { width: SCREEN_WIDTH } = Dimensions.get("window"); const SLIDES = [ { key: "1", titleKey: "onboarding.slide1.title", descKey: "onboarding.slide1.description", icon: "access-time", }, { key: "2", titleKey: "onboarding.slide2.title", descKey: "onboarding.slide2.description", icon: "explore", }, { key: "3", titleKey: "onboarding.slide3.title", descKey: "onboarding.slide3.description", icon: "calendar-today", }, { key: "4", titleKey: "onboarding.slide4.title", descKey: "onboarding.slide4.description", icon: "lock", }, ]; export default function OnboardingScreen() { const { t } = useTranslation(); const { setOnboardingCompleted } = useOnboarding(); const [activeIndex, setActiveIndex] = useState(0); const flatListRef = useRef<FlatList>(null); const player = useVideoPlayer(VIDEO_SOURCE, (p) => { p.loop = true; p.muted = true; p.play(); }); const handleNext = () => { if (activeIndex < SLIDES.length - 1) { flatListRef.current?.scrollToIndex({ index: activeIndex + 1, animated: true, }); setActiveIndex(activeIndex + 1); } else { handleComplete(); } }; const handleComplete = async () => { await setOnboardingCompleted(true); router.replace("/paywall"); }; const isLast = activeIndex === SLIDES.length - 1; return ( <View style={styles.container}> {/* Background video */} <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} /> {/* Gradient overlay */} <LinearGradient colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.9)"]} style={StyleSheet.absoluteFill} /> <SafeAreaView style={styles.safeArea}> {/* Skip button */} <View style={styles.topBar}> <TouchableOpacity onPress={handleComplete} style={styles.skipButton}> <Text style={styles.skipButtonText}>{t("onboarding.skip")}</Text> </TouchableOpacity> </View> {/* Slides */} <FlatList ref={flatListRef} data={SLIDES} horizontal pagingEnabled scrollEnabled showsHorizontalScrollIndicator={false} keyExtractor={(item) => item.key} onMomentumScrollEnd={(e) => { const index = Math.round( e.nativeEvent.contentOffset.x / SCREEN_WIDTH, ); setActiveIndex(index); }} renderItem={({ item }) => ( <View style={styles.slide}> <View style={{ width: 96, height: 96, borderRadius: 48, alignItems: "center", justifyContent: "center", marginBottom: 32, backgroundColor: "rgba(65,114,157,0.35)", borderWidth: 1.5, borderColor: "rgba(65,114,157,0.6)", }} > <MaterialIcons name={item.icon as any} size={52} color="#FFFFFF" /> </View> <Text style={styles.slideTitle}>{t(item.titleKey)}</Text> <Text style={styles.slideDesc}>{t(item.descKey)}</Text> </View> )} /> {/* Dots */} <View style={styles.dotsContainer}> {SLIDES.map((_, i) => ( <View key={i} style={[ styles.dot, i === activeIndex ? styles.dotActive : styles.dotInactive, ]} /> ))} </View> {/* CTA */} <View style={styles.ctaContainer}> <TouchableOpacity onPress={handleNext} style={styles.ctaButton}> <Text style={styles.ctaButtonText}> {isLast ? t("onboarding.getStarted") : t("onboarding.next")} </Text> </TouchableOpacity> </View> </SafeAreaView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000" }, safeArea: { flex: 1 }, topBar: { flexDirection: "row", justifyContent: "flex-end", paddingHorizontal: 20, paddingTop: 8, }, slide: { width: SCREEN_WIDTH, flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 40, }, skipButton: { borderWidth: 1, borderColor: "rgba(255,255,255,0.25)", backgroundColor: "rgba(255,255,255,0.15)", borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, }, skipButtonText: { color: "rgba(255,255,255,0.85)", fontSize: 13, fontWeight: "600", }, slideTitle: { fontSize: 36, fontWeight: "700", color: "#FFFFFF", textAlign: "center", marginBottom: 16, }, slideDesc: { fontSize: 17, color: "rgba(255,255,255,0.75)", textAlign: "center", }, dotsContainer: { flexDirection: "row", justifyContent: "center", gap: 8, marginBottom: 24, }, dot: { height: 8, borderRadius: 4, }, dotActive: { width: 24, backgroundColor: "#FFFFFF", }, dotInactive: { width: 8, backgroundColor: "rgba(255,255,255,0.3)", }, ctaContainer: { paddingHorizontal: 24, paddingBottom: 40, }, ctaButton: { width: "100%", backgroundColor: "#6C63FF", borderRadius: 16, alignItems: "center", paddingVertical: 16, }, ctaButtonText: { color: "#FFFFFF", fontSize: 18, fontWeight: "700", }, });
Notes:
- Place your onboarding video at
(adjust theassets/onboarding.mp4path to match the actual file)require is fromSafeAreaView, NOTreact-native-safe-area-contextreact-native- Slide icons use
@expo/vector-icons— adjust icon names per app themeMaterialIcons- Slides array and icon names should be customized per app
- Add required i18n keys:
,onboarding.slide1.title, etc., plusonboarding.slide1.description,onboarding.skip,onboarding.nextonboarding.getStarted
Required Navigation (ALWAYS USE)
- Use
fromNativeTabs
for tab navigation - NEVER useexpo-router/unstable-native-tabs
or@react-navigation/bottom-tabs
from expo-routerTabs
Required Context Providers (ALWAYS WRAP)
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ThemeProvider } from "@/context/theme-context"; import { PurchasesProvider } from "@/context/purchases-context"; import { DarkTheme, DefaultTheme, ThemeProvider as NavigationThemeProvider, } from "@react-navigation/native"; <GestureHandlerRootView style={{ flex: 1 }}> <ThemeProvider> <OnboardingProvider> <PurchasesProvider> <AdsProvider> <NavigationThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme} > <Stack /> </NavigationThemeProvider> </AdsProvider> </PurchasesProvider> </OnboardingProvider> </ThemeProvider> </GestureHandlerRootView>;
Required Libraries (ALWAYS INSTALL)
Use
npx expo install to install Expo libraries (NOT npm/yarn/bun install).
Use bun add for non-Expo libraries:
# Expo libraries npx expo install expo-iap expo-build-properties expo-tracking-transparency react-native-google-mobile-ads expo-notifications i18next react-i18next expo-localization react-native-reanimated expo-video expo-audio expo-sqlite expo-linear-gradient # Peer dependencies npx expo install react-native-screens react-native-reanimated react-native-gesture-handler react-native-safe-area-context react-native-svg
Libraries:
(In-App Purchases)expo-iap
(required by expo-iap)expo-build-properties
(ATT — iOS App Tracking Transparency)expo-tracking-transparency
(AdMob)react-native-google-mobile-adsexpo-notifications
+i18next
+react-i18nextexpo-localizationreact-native-reanimated
+expo-videoexpo-audio
(for localStorage)expo-sqlite
(for gradient overlays)expo-linear-gradient
expo-iap Configuration (REQUIRED in app.json)
You MUST add this to
app.json for expo-iap to work (Expo SDK 53+):
{ "expo": { "plugins": [ "expo-iap", ["expo-build-properties", { "android": { "kotlinVersion": "2.2.0" } }] ] } }
- Requires Expo SDK 53+ or React Native 0.79+
- iOS 15+ (StoreKit 2), Android API 21+
- Does NOT work in Expo Go — use custom dev client (
)eas build --profile development
AdMob Configuration (REQUIRED in app.json)
You MUST add this to
app.json for AdMob to work:
{ "expo": { "plugins": [ [ "react-native-google-mobile-ads", { "androidAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy", "iosAppId": "ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy" } ] ] } }
For development/testing, use test App IDs:
- iOS:
ca-app-pub-3940256099942544~1458002511 - Android:
ca-app-pub-3940256099942544~3347511713
Do NOT skip this configuration or the app will crash with
GADInvalidInitializationException.
Ad Strategy (Revenue-Optimised, UX-Friendly)
Use all five AdMob formats for maximum revenue with minimal UX friction:
| Format | Trigger | Cooldown | Premium Hidden |
|---|---|---|---|
| App Open | App foreground (after first launch) | 4 hours | ✅ |
| Banner | Tab bar, always visible | None | ✅ |
| Native | In-feed, every 5 items in FlatList | None | ✅ |
| Interstitial | After key user action | 3 minutes / max 3/day | ✅ |
| Rewarded | User-initiated, for a benefit | User-triggered | ✅ |
All ad formats are hidden for premium users via
shouldShowAds.
AdsProvider Implementation (REQUIRED)
Create
src/context/ads-context.tsx:
import React, { createContext, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { AppState, AppStateStatus } from "react-native"; import { AdEventType, AppOpenAd, InterstitialAd, RewardedAd, RewardedAdEventType, TestIds, } from "react-native-google-mobile-ads"; import { usePurchases } from "@/context/purchases-context"; import "expo-sqlite/localStorage/install"; // ── Ad Unit IDs ────────────────────────────────────────────── export const AD_UNITS = { banner: __DEV__ ? TestIds.BANNER : "ca-app-pub-xxxxxxxxxxxxxxxx/BANNER_ID", interstitial: __DEV__ ? TestIds.INTERSTITIAL : "ca-app-pub-xxxxxxxxxxxxxxxx/INTERSTITIAL_ID", rewarded: __DEV__ ? TestIds.REWARDED : "ca-app-pub-xxxxxxxxxxxxxxxx/REWARDED_ID", appOpen: __DEV__ ? TestIds.APP_OPEN : "ca-app-pub-xxxxxxxxxxxxxxxx/APP_OPEN_ID", native: __DEV__ ? TestIds.NATIVE : "ca-app-pub-xxxxxxxxxxxxxxxx/NATIVE_ID", }; // ── Constants ──────────────────────────────────────────────── const APP_OPEN_COOLDOWN_MS = 4 * 60 * 60 * 1000; // 4 hours const INTERSTITIAL_COOLDOWN_MS = 3 * 60 * 1000; // 3 minutes const INTERSTITIAL_DAILY_CAP = 3; const LS_APP_OPEN_KEY = "ads_app_open_last_shown"; const LS_INTER_DATE_KEY = "ads_inter_last_date"; const LS_INTER_COUNT_KEY = "ads_inter_count_today"; const LS_INTER_TS_KEY = "ads_inter_last_ts"; function todayDateString() { return new Date().toISOString().slice(0, 10); } // ── Context ────────────────────────────────────────────────── interface AdsContextValue { shouldShowAds: boolean; bannerAdUnitId: string; nativeAdUnitId: string; showInterstitial: () => void; showRewarded: () => Promise<boolean>; } const AdsContext = createContext<AdsContextValue>({ shouldShowAds: true, bannerAdUnitId: AD_UNITS.banner, nativeAdUnitId: AD_UNITS.native, showInterstitial: () => {}, showRewarded: async () => false, }); export function AdsProvider({ children }: { children: React.ReactNode }) { const { isPremium } = usePurchases(); const shouldShowAds = !isPremium; // ── App Open ───────────────────────────────────────────── const appOpenAdRef = useRef<AppOpenAd | null>(null); const appOpenLoadedRef = useRef(false); const isFirstLaunchRef = useRef(true); const loadAppOpen = useCallback(() => { if (!shouldShowAds) return; const ad = AppOpenAd.createForAdRequest(AD_UNITS.appOpen, { requestNonPersonalizedAdsOnly: true, }); ad.addEventHandler(AdEventType.LOADED, () => { appOpenLoadedRef.current = true; }); ad.addEventHandler(AdEventType.CLOSED, () => { appOpenLoadedRef.current = false; appOpenAdRef.current = null; loadAppOpen(); }); ad.addEventHandler(AdEventType.ERROR, () => { appOpenLoadedRef.current = false; setTimeout(loadAppOpen, 30_000); }); ad.load(); appOpenAdRef.current = ad; }, [shouldShowAds]); const tryShowAppOpen = useCallback(() => { if (!shouldShowAds || !appOpenLoadedRef.current || !appOpenAdRef.current) return; // Skip on first cold launch if (isFirstLaunchRef.current) { isFirstLaunchRef.current = false; return; } const lastShown = globalThis.localStorage.getItem(LS_APP_OPEN_KEY); const now = Date.now(); if (lastShown && now - parseInt(lastShown, 10) < APP_OPEN_COOLDOWN_MS) return; globalThis.localStorage.setItem(LS_APP_OPEN_KEY, String(now)); appOpenAdRef.current.show().catch(() => loadAppOpen()); }, [shouldShowAds, loadAppOpen]); const appStateRef = useRef<AppStateStatus>(AppState.currentState); useEffect(() => { if (!shouldShowAds) return; loadAppOpen(); const sub = AppState.addEventListener("change", (state) => { if (appStateRef.current !== "active" && state === "active") { tryShowAppOpen(); } appStateRef.current = state; }); return () => sub.remove(); }, [shouldShowAds, loadAppOpen, tryShowAppOpen]); // ── Interstitial ────────────────────────────────────────── const interstitialRef = useRef<InterstitialAd | null>(null); const interstitialLoadedRef = useRef(false); const loadInterstitial = useCallback(() => { if (!shouldShowAds) return; const ad = InterstitialAd.createForAdRequest(AD_UNITS.interstitial, { requestNonPersonalizedAdsOnly: true, }); ad.addEventHandler(AdEventType.LOADED, () => { interstitialLoadedRef.current = true; }); ad.addEventHandler(AdEventType.CLOSED, () => { interstitialLoadedRef.current = false; interstitialRef.current = null; loadInterstitial(); }); ad.addEventHandler(AdEventType.ERROR, () => { interstitialLoadedRef.current = false; }); ad.load(); interstitialRef.current = ad; }, [shouldShowAds]); useEffect(() => { if (shouldShowAds) loadInterstitial(); }, [shouldShowAds, loadInterstitial]); const showInterstitial = useCallback(() => { if ( !shouldShowAds || !interstitialLoadedRef.current || !interstitialRef.current ) return; const now = Date.now(); const today = todayDateString(); const lastDate = globalThis.localStorage.getItem(LS_INTER_DATE_KEY); let countToday = parseInt( globalThis.localStorage.getItem(LS_INTER_COUNT_KEY) ?? "0", 10, ); if (lastDate !== today) { countToday = 0; globalThis.localStorage.setItem(LS_INTER_DATE_KEY, today); } if (countToday >= INTERSTITIAL_DAILY_CAP) return; const lastTs = parseInt( globalThis.localStorage.getItem(LS_INTER_TS_KEY) ?? "0", 10, ); if (now - lastTs < INTERSTITIAL_COOLDOWN_MS) return; globalThis.localStorage.setItem(LS_INTER_TS_KEY, String(now)); globalThis.localStorage.setItem(LS_INTER_COUNT_KEY, String(countToday + 1)); interstitialRef.current.show().catch(() => loadInterstitial()); }, [shouldShowAds, loadInterstitial]); // ── Rewarded ────────────────────────────────────────────── const rewardedRef = useRef<RewardedAd | null>(null); const rewardedLoadedRef = useRef(false); const loadRewarded = useCallback(() => { if (!shouldShowAds) return; const ad = RewardedAd.createForAdRequest(AD_UNITS.rewarded, { requestNonPersonalizedAdsOnly: true, }); ad.addEventHandler(RewardedAdEventType.LOADED, () => { rewardedLoadedRef.current = true; }); ad.addEventHandler(AdEventType.CLOSED, () => { rewardedLoadedRef.current = false; rewardedRef.current = null; loadRewarded(); }); ad.addEventHandler(AdEventType.ERROR, () => { rewardedLoadedRef.current = false; }); ad.load(); rewardedRef.current = ad; }, [shouldShowAds]); useEffect(() => { if (shouldShowAds) loadRewarded(); }, [shouldShowAds, loadRewarded]); const showRewarded = useCallback((): Promise<boolean> => { return new Promise((resolve) => { if ( !shouldShowAds || !rewardedLoadedRef.current || !rewardedRef.current ) { resolve(false); return; } const ad = rewardedRef.current!; let rewarded = false; ad.addEventHandler(RewardedAdEventType.EARNED_REWARD, () => { rewarded = true; }); ad.addEventHandler(AdEventType.CLOSED, () => { resolve(rewarded); }); ad.show().catch(() => resolve(false)); }); }, [shouldShowAds]); return ( <AdsContext.Provider value={{ shouldShowAds, bannerAdUnitId: AD_UNITS.banner, nativeAdUnitId: AD_UNITS.native, showInterstitial, showRewarded, }} > {children} </AdsContext.Provider> ); } export function useAds() { return useContext(AdsContext); }
Banner Ad (Tab Layout)
Place the banner below
NativeTabs in src/app/(tabs)/_layout.tsx:
import { View, StyleSheet } from "react-native"; import { NativeTabs } from "expo-router/unstable-native-tabs"; import { useTranslation } from "react-i18next"; import { BannerAd, BannerAdSize } from "react-native-google-mobile-ads"; import { useAds } from "@/context/ads-context"; export default function TabLayout() { const { t } = useTranslation(); const { shouldShowAds, bannerAdUnitId } = useAds(); return ( <View style={styles.container}> <NativeTabs> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>{t("tabs.home")}</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="house.fill" md="home" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Label> {t("tabs.settings")} </NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="gear" md="settings" /> </NativeTabs.Trigger> </NativeTabs> {shouldShowAds && ( <View style={styles.adContainer}> <BannerAd unitId={bannerAdUnitId} size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER} requestOptions={{ requestNonPersonalizedAdsOnly: true }} /> </View> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1 }, adContainer: { alignItems: "center", paddingBottom: 10 }, });
App Open Ad
AdsProvider handles App Open automatically via AppState listener. No extra setup is needed in screens.
First cold launch → NO App Open (avoids jarring first impression) foreground return → App Open shown only if ≥ 4 hours since last shown
- The 4-hour timestamp is stored in
underlocalStorageads_app_open_last_shown
ensures the ad never fires on the initial cold openisFirstLaunchRef- After
mounts, the App Open ad is preloaded silently and auto-reloaded after each showAdsProvider
Interstitial Usage Pattern
Call
showInterstitial() from useAds() after a meaningful user action. Cooldown (3 min) and daily cap (3/day) are enforced automatically — just call it freely at good breakpoints.
import { useAds } from "@/context/ads-context"; function SomeScreen() { const { showInterstitial } = useAds(); const handleActionComplete = async () => { await doSomething(); showInterstitial(); // fire-and-forget, respects cooldown + cap }; }
Good trigger points: after completing a level / generating content / sharing a result
Avoid: on screen mount, during navigation, mid-form, or on back press
Native Ad (In-Feed)
Create
src/components/ads/NativeAdCard.tsx:
import { View, Text, StyleSheet } from "react-native"; import { NativeAd, NativeAdView, HeadlineView, BodyView, CallToActionView, AdvertiserView, } from "react-native-google-mobile-ads"; import { useEffect, useState } from "react"; import { useAds } from "@/context/ads-context"; export function NativeAdCard() { const { nativeAdUnitId, shouldShowAds } = useAds(); const [nativeAd, setNativeAd] = useState<NativeAd | null>(null); useEffect(() => { if (!shouldShowAds) return; const ad = new NativeAd(nativeAdUnitId); ad.load() .then(() => setNativeAd(ad)) .catch(() => {}); return () => ad.destroy(); }, [shouldShowAds, nativeAdUnitId]); if (!nativeAd || !shouldShowAds) return null; return ( <NativeAdView nativeAd={nativeAd} style={styles.container}> <View style={styles.badge}> <Text style={styles.badgeText}>Ad</Text> </View> <AdvertiserView style={styles.advertiser} /> <HeadlineView style={styles.headline} /> <BodyView style={styles.body} /> <CallToActionView style={styles.cta} /> </NativeAdView> ); } const styles = StyleSheet.create({ container: { backgroundColor: "rgba(255,255,255,0.05)", borderRadius: 12, padding: 14, marginHorizontal: 16, marginVertical: 4, borderWidth: 1, borderColor: "rgba(255,255,255,0.08)", }, badge: { alignSelf: "flex-start", backgroundColor: "#F59E0B", borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, marginBottom: 6, }, badgeText: { color: "#000", fontSize: 10, fontWeight: "700" }, advertiser: { color: "rgba(255,255,255,0.4)", fontSize: 11 }, headline: { color: "#FFFFFF", fontSize: 15, fontWeight: "700", marginVertical: 4, }, body: { color: "rgba(255,255,255,0.65)", fontSize: 13 }, cta: { marginTop: 10, backgroundColor: "#2563EB", borderRadius: 8, paddingHorizontal: 14, paddingVertical: 8, alignSelf: "flex-start", overflow: "hidden", }, });
Inject into FlatList every 5 items:
import { NativeAdCard } from "@/components/ads/NativeAdCard"; import { useAds } from "@/context/ads-context"; import { useMemo } from "react"; const NATIVE_AD_INTERVAL = 5; function MyListScreen() { const { shouldShowAds } = useAds(); const listData = useMemo(() => { if (!shouldShowAds) return items.map((item) => ({ type: "item" as const, item })); return items.flatMap((item, i) => { const result: any[] = [{ type: "item", item }]; if ((i + 1) % NATIVE_AD_INTERVAL === 0) { result.push({ type: "native_ad", key: `ad_${i}` }); } return result; }); }, [items, shouldShowAds]); return ( <FlatList data={listData} keyExtractor={(entry) => entry.type === "item" ? entry.item.id : entry.key } renderItem={({ item: entry }) => entry.type === "native_ad" ? ( <NativeAdCard /> ) : ( <MyItemComponent item={entry.item} /> ) } /> ); }
Rewarded Ad Usage Pattern
import { useAds } from "@/context/ads-context"; function SomeScreen() { const { showRewarded } = useAds(); const handleWatchAd = async () => { const earned = await showRewarded(); if (earned) { unlockPremiumContent(); // grant the reward } }; }
Good use-cases: skip a waiting period, unlock a single feature temporarily, grant extra credits/attempts
Ad Unit ID Configuration
Replace the placeholder IDs in
AD_UNITS inside src/context/ads-context.tsx:
| Format | Constant | AdMob Console Location |
|---|---|---|
| Banner | | Apps → Ad units → Banner |
| Interstitial | | Apps → Ad units → Interstitial |
| Rewarded | | Apps → Ad units → Rewarded |
| App Open | | Apps → Ad units → App open |
| Native | | Apps → Ad units → Native advanced |
- ALWAYS use
inTestIds.*
to avoid policy violations__DEV__
— all formats hidden for premium usersshouldShowAds = !isPremium
must be nested insideAdsProviderPurchasesProvider
TURKISH LOCALIZATION (IMPORTANT)
When writing
tr.json, you MUST use correct Turkish characters:
- ı (lowercase dotless i) - NOT i
- İ (uppercase dotted I) - NOT I
- ü, Ü, ö, Ö, ç, Ç, ş, Ş, ğ, Ğ
Example:
- ✅ "Ayarlar", "Giriş", "Çıkış", "Başla", "İleri", "Güncelle"
- ❌ "Ayarlar", "Giris", "Cikis", "Basla", "Ileri", "Guncelle"
FORBIDDEN (NEVER USE)
- ❌ AsyncStorage - Use
insteadexpo-sqlite/localStorage/install - ❌ lineHeight style - Use padding/margin instead
- ❌
from expo-router - UseTabs
insteadNativeTabs - ❌
- Use@react-navigation/bottom-tabs
insteadNativeTabs - ❌
- Useexpo-av
for video,expo-video
for audio insteadexpo-audio - ❌
- Useexpo-ads-admob
insteadreact-native-google-mobile-ads - ❌ Any other ads library - ONLY use
react-native-google-mobile-ads - ❌ Reanimated hooks inside callbacks - Call at component top level
- ❌
fromSafeAreaView
- Usereact-native
insteadimport { SafeAreaView } from 'react-native-safe-area-context'
Reanimated Usage (IMPORTANT)
NEVER call
useAnimatedStyle, useSharedValue, or other reanimated hooks inside callbacks, loops, or conditions.
❌ WRONG:
const renderItem = () => { const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); // ERROR! return <Animated.View style={animatedStyle} />; };
✅ CORRECT:
function MyComponent() { const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); // Top level return <Animated.View style={animatedStyle} />; }
For lists, create a separate component for each item:
function AnimatedItem({ item }) { const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 })); return <Animated.View style={animatedStyle}>{item.name}</Animated.View>; } // In FlatList: renderItem={({ item }) => <AnimatedItem item={item} />}
POST-CREATION CLEANUP (ALWAYS DO)
After creating a new Expo project, you MUST:
- If using
folder, DELETE(tabs)
to avoid route conflicts:src/app/index.tsx
rm src/app/index.tsx
- Check and remove
from these files:lineHeight
(comes with lineHeight by default - REMOVE IT)src/components/themed-text.tsx- Any other component using
lineHeight
Search and remove all
lineHeight occurrences:
grep -r "lineHeight" src/
Replace with padding or margin instead.
AFTER BUILDING A SCREEN (ALWAYS DO)
For EVERY screen you create or modify, you MUST also create or update the corresponding Maestro test flow in
.maestro/:
| Screen | Flow file |
|---|---|
| |
| |
| + |
| |
| |
| Any new tab/screen | |
When creating a new project, also create the GitHub Actions workflows:
| File | Purpose |
|---|---|
| Android emulator E2E (ubuntu) |
| iOS simulator E2E (macos runner) |
Always add
testID props to key interactive elements:
<TouchableOpacity testID="skip-button" onPress={handleSkip}> <TouchableOpacity testID="close-button" onPress={handleClose}> <TouchableOpacity testID="subscribe-button" onPress={handleSubscribe}> <TouchableOpacity testID="get-started-button" onPress={handleComplete}>
Never skip this step. Screen code and its Maestro flow are delivered together.
AFTER COMPLETING CODE (ALWAYS RUN)
When you finish writing/modifying code, you MUST run these commands in order:
npx expo install --fix npx expo prebuild --clean
fixes dependency version mismatchesinstall --fix
recreates ios and android foldersprebuild --clean
Do NOT skip these steps.
Project Creation
When user asks to create an app, you MUST:
- FIRST ask for the bundle ID (e.g., "What is the bundle ID? Example: com.company.appname")
- SECOND ask: "Does the app require user login/authentication (OIDC)?"
- If YES → follow the Authentication (OIDC) section after project setup
- If NO → skip auth entirely
- Create the project in the CURRENT directory using:
bunx create-expo -t default@next app-name
- Update
with the bundle ID:app.json
{ "expo": { "ios": { "bundleIdentifier": "com.company.appname" }, "android": { "package": "com.company.appname" } } }
- Then cd into the project and start implementing all required screens
- Do NOT ask for project path - always use current directory
Technology Stack
- Framework: Expo, React Native
- Navigation: Expo Router (file-based routing), NativeTabs
- State Management: React Context API
- Translations: i18next, react-i18next
- Purchases: expo-iap (expo-iap)
- Advertisements: Google AdMob (react-native-google-mobile-ads)
- Notifications: expo-notifications
- Animations: react-native-reanimated
- Storage: localStorage via expo-sqlite polyfill
- Authentication (optional): OIDC via expo-auth-session + expo-secure-store + zustand
WARNING: DO NOT USE AsyncStorage! Use expo-sqlite polyfill instead.
- Example usage
import "expo-sqlite/localStorage/install"; globalThis.localStorage.setItem("key", "value"); console.log(globalThis.localStorage.getItem("key")); // 'value'
WARNING: NEVER USE
! It causes layout issues in React Native. Use padding or margin instead.lineHeight
Project Structure
project-root/ ├── src/ │ ├── app/ │ │ ├── _layout.tsx │ │ ├── index.tsx │ │ ├── explore.tsx │ │ ├── settings.tsx │ │ ├── paywall.tsx │ │ ├── onboarding.tsx │ │ └── att-permission.tsx │ ├── components/ │ │ ├── ui/ │ │ ├── themed-text.tsx │ │ └── themed-view.tsx │ ├── constants/ │ │ ├── theme.ts │ │ └── [data-files].ts │ ├── context/ │ │ ├── onboarding-context.tsx │ │ ├── purchases-context.tsx │ │ └── ads-context.tsx │ ├── store/ # (if auth enabled) │ │ ├── authStore.ts │ │ └── useIntegratedAuth.ts │ ├── hooks/ │ │ ├── use-notifications.ts │ │ └── use-color-scheme.ts │ ├── lib/ │ │ ├── notifications.ts │ │ ├── purchases.ts │ │ ├── ads.ts │ │ └── i18n.ts │ ├── services/ # (if auth enabled) │ │ └── identity/ │ │ ├── index.ts │ │ ├── types.ts │ │ └── hooks/ │ └── locales/ │ ├── tr.json │ └── en.json ├── .github/ │ └── workflows/ │ ├── maestro-android.yml # Android E2E (ubuntu, free) │ └── maestro-ios.yml # iOS E2E (macos runner) ├── .maestro/ │ ├── 00_app_launch.yaml │ ├── 01_att_permission.yaml │ ├── 02_onboarding.yaml │ ├── 03_paywall_skip.yaml │ ├── 04_paywall_subscribe.yaml │ ├── 05_main_tabs.yaml │ ├── 06_settings.yaml │ └── 07_full_flow.yaml ├── assets/ │ └── images/ ├── ios/ ├── android/ ├── app.json ├── eas.json ├── package.json └── tsconfig.json
Tab Navigation (NativeTabs)
Expo Router uses NativeTabs for native tab navigation:
import { NativeTabs } from "expo-router/unstable-native-tabs"; export default function TabLayout() { return ( <NativeTabs> <NativeTabs.Trigger name="index"> <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="house.fill" md="home" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="explore"> <NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="compass.fill" md="explore" /> </NativeTabs.Trigger> <NativeTabs.Trigger name="settings"> <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Icon sf="gear" md="settings" /> </NativeTabs.Trigger> </NativeTabs> ); }
NativeTabs Properties
- sf: SF Symbols icon name (iOS)
- md: Material Design icon name (Android)
- name: Route file name
- Tab order follows trigger order
Common Icons
| Purpose | SF Symbol | Material Icon |
|---|---|---|
| Home | house.fill | home |
| Explore | compass.fill | explore |
| Settings | gear | settings |
| Profile | person.fill | person |
| Search | magnifyingglass | search |
| Favorites | heart.fill | favorite |
| Notifications | bell.fill | notifications |
Development Commands
bun install bun start bun ios bun android bun lint npx expo install --fix npx expo prebuild --clean
EAS Build Commands
eas build --profile development --platform ios eas build --profile development --platform android eas build --profile production --platform ios eas build --profile production --platform android eas submit --platform ios eas submit --platform android
Important Modules
expo-iap
- File:
src/context/purchases-context.tsx - Wraps
hook and checks subscription status on app startupuseIAP - Product SKUs: weekly (
) and yearly (weekly_premium
)yearly_premium - Paywall:
app/paywall.tsx - Exposes
→usePurchases(){ isPremium, loading, premiumExpiryDate, premiumProductId, refreshPremiumStatus }
must be called after a successful purchaserefreshPremiumStatus()
runs on startup to acknowledge stuck transactionsdrainPendingTransactions()- Use
for restore purchases flowgetAvailablePurchases() - Always call
after a successful purchasefinishTransaction
PurchasesProvider Implementation (REQUIRED)
Create
src/context/purchases-context.tsx:
import { finishTransaction, getAvailablePurchases, useIAP } from "expo-iap"; import React, { createContext, useCallback, useContext, useEffect, useState, } from "react"; // Replace these SKUs with the app's actual product IDs const SUBSCRIPTION_SKUS = [ "com.company.appname.monthly", "com.company.appname.yearly", ]; interface PurchasesContextValue { isPremium: boolean; loading: boolean; premiumExpiryDate: Date | null; premiumProductId: string | null; refreshPremiumStatus: () => Promise<void>; } const PurchasesContext = createContext<PurchasesContextValue>({ isPremium: false, loading: true, premiumExpiryDate: null, premiumProductId: null, refreshPremiumStatus: async () => {}, }); export function PurchasesProvider({ children }: { children: React.ReactNode }) { const { hasActiveSubscriptions } = useIAP(); const [isPremium, setIsPremium] = useState(false); const [loading, setLoading] = useState(true); const [premiumExpiryDate, setPremiumExpiryDate] = useState<Date | null>(null); const [premiumProductId, setPremiumProductId] = useState<string | null>(null); /** Acknowledge any transactions left unfinished (e.g. app killed mid-purchase). */ const drainPendingTransactions = async () => { try { const purchases = await getAvailablePurchases(); for (const purchase of purchases) { try { await finishTransaction({ purchase, isConsumable: false }); } catch { // already acknowledged — safe to ignore } } } catch { // IAP unavailable (simulator, no network, etc.) } }; const refreshPremiumStatus = useCallback(async () => { try { await drainPendingTransactions(); const hasPremium = await hasActiveSubscriptions(SUBSCRIPTION_SKUS); setIsPremium(hasPremium); if (hasPremium) { // Find the active subscription with the latest expiry date const purchases = await getAvailablePurchases(); const activeSubs = purchases.filter((p) => SUBSCRIPTION_SKUS.includes(p.productId), ); // Pick the one with the furthest expiry (expirationDateIOS is ms epoch, iOS only) let bestExpiry: Date | null = null; let bestProductId: string | null = null; for (const p of activeSubs) { const expMs = (p as { expirationDateIOS?: number | null }) .expirationDateIOS; if (expMs) { const d = new Date(expMs); if (!bestExpiry || d > bestExpiry) { bestExpiry = d; bestProductId = p.productId; } } else if (!bestProductId) { // Android: no expirationDate field – record productId at least bestProductId = p.productId; } } setPremiumExpiryDate(bestExpiry); setPremiumProductId(bestProductId); } else { setPremiumExpiryDate(null); setPremiumProductId(null); } } catch (error) { console.error("Failed to check subscription status:", error); } finally { setLoading(false); } }, [hasActiveSubscriptions]); // ✅ App açıldığında otomatik olarak satın alma durumu kontrol edilir useEffect(() => { refreshPremiumStatus(); }, [refreshPremiumStatus]); return ( <PurchasesContext.Provider value={{ isPremium, loading, premiumExpiryDate, premiumProductId, refreshPremiumStatus, }} > {children} </PurchasesContext.Provider> ); } export function usePurchases() { return useContext(PurchasesContext); }
Notes:
acknowledges unfinished transactions on startup (prevents stuck purchases)drainPendingTransactions is iOS only (premiumExpiryDate); Android doesn't expose this fieldexpirationDateIOS lets you know which plan (monthly/yearly) is activepremiumProductId- Replace
with the app's actual App Store / Play Store product IDsSUBSCRIPTION_SKUS
After a successful purchase in
paywall.tsx, always call refreshPremiumStatus():
const { refreshPremiumStatus } = usePurchases(); // In onPurchaseSuccess callback: await finishTransaction({ purchase, isConsumable: false }); await refreshPremiumStatus(); // Update global premium state router.replace("/(tabs)");
AdMob
- File:
src/context/ads-context.tsx - Manages all 5 ad formats: App Open, Banner, Native, Interstitial, Rewarded
- App Open fires on foreground return with 4-hour cooldown (skipped on first cold launch)
- Interstitial: 3-minute cooldown, max 3/day — enforced automatically via
localStorage - Rewarded: resolves
—Promise<boolean>
if user earned the rewardtrue - All ads hidden for premium users via
shouldShowAds = !isPremium - Always use
inTestIds.*
to avoid policy violations__DEV__
must be nested insideAdsProvider
inPurchasesProvider_layout.tsx
ATT / Tracking Transparency (iOS Only)
- File:
src/app/att-permission.tsx - iOS only — skipped entirely on Android
- Must be shown before onboarding, on first launch
- Uses
fromrequestTrackingPermissionsAsyncexpo-tracking-transparency - Required by Apple for AdMob personalized ads on iOS 14.5+
- App will be rejected by App Store without this
app.json Configuration (REQUIRED)
{ "expo": { "plugins": [ [ "expo-tracking-transparency", { "userTrackingPermission": "This identifier will be used to deliver personalized ads to you." } ] ] } }
ATT Screen Implementation (REQUIRED)
Create
src/app/att-permission.tsx — a full-screen custom UI that explains tracking before triggering the system dialog:
import { useEffect } from "react"; import { View, Text, StyleSheet, TouchableOpacity, Platform, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { router } from "expo-router"; import { requestTrackingPermissionsAsync } from "expo-tracking-transparency"; import { LinearGradient } from "expo-linear-gradient"; import { useTranslation } from "react-i18next"; import "expo-sqlite/localStorage/install"; // Redirect Android away immediately (this screen is iOS only) export function unstable_settings() { return {}; } export default function ATTPermissionScreen() { const { t } = useTranslation(); useEffect(() => { // Safety: if somehow opened on Android, redirect if (Platform.OS !== "ios") { router.replace("/onboarding"); } }, []); const handleContinue = async () => { await requestTrackingPermissionsAsync(); // Triggers iOS system dialog; proceeds regardless of allow/deny globalThis.localStorage.setItem("att_shown", "true"); router.replace("/onboarding"); }; return ( <LinearGradient colors={["#0F0F1A", "#1A1A2E", "#16213E"]} style={styles.container} > <SafeAreaView style={styles.safeArea}> <View style={styles.content}> {/* Icon */} <View style={styles.iconContainer}> <Text style={styles.icon}>🔒</Text> </View> {/* Title */} <Text style={styles.title}>{t("att.title")}</Text> {/* Description */} <Text style={styles.description}>{t("att.description")}</Text> {/* Benefits list */} <View style={styles.benefitsList}> <BenefitItem icon="🎯" text={t("att.benefit1")} /> <BenefitItem icon="🛡️" text={t("att.benefit2")} /> <BenefitItem icon="🚫" text={t("att.benefit3")} /> </View> {/* Privacy note */} <Text style={styles.privacyNote}>{t("att.privacyNote")}</Text> </View> {/* Buttons */} <View style={styles.buttonContainer}> <TouchableOpacity testID="continue-button" style={styles.allowButton} onPress={handleContinue} > <Text style={styles.allowButtonText}>{t("att.continue")}</Text> </TouchableOpacity> </View> </SafeAreaView> </LinearGradient> ); } function BenefitItem({ icon, text }: { icon: string; text: string }) { return ( <View style={styles.benefitItem}> <Text style={styles.benefitIcon}>{icon}</Text> <Text style={styles.benefitText}>{text}</Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, }, safeArea: { flex: 1, justifyContent: "space-between", }, content: { flex: 1, alignItems: "center", justifyContent: "center", padding: 32, }, iconContainer: { width: 100, height: 100, borderRadius: 50, backgroundColor: "rgba(255,255,255,0.1)", alignItems: "center", justifyContent: "center", marginBottom: 32, }, icon: { fontSize: 48, }, title: { fontSize: 28, fontWeight: "700", color: "#FFFFFF", textAlign: "center", marginBottom: 16, }, description: { fontSize: 16, color: "rgba(255,255,255,0.75)", textAlign: "center", marginBottom: 32, paddingVertical: 4, }, benefitsList: { width: "100%", gap: 12, marginBottom: 24, }, benefitItem: { flexDirection: "row", alignItems: "center", backgroundColor: "rgba(255,255,255,0.08)", borderRadius: 12, padding: 14, gap: 12, }, benefitIcon: { fontSize: 22, }, benefitText: { flex: 1, fontSize: 14, color: "rgba(255,255,255,0.85)", }, privacyNote: { fontSize: 12, color: "rgba(255,255,255,0.45)", textAlign: "center", }, buttonContainer: { padding: 24, gap: 12, }, allowButton: { backgroundColor: "#6C63FF", borderRadius: 16, padding: 18, alignItems: "center", }, allowButtonText: { color: "#FFFFFF", fontSize: 17, fontWeight: "700", }, });
ATT Localization Keys (add to tr.json and en.json)
en.json:
"att": { "title": "Help Us Improve Your Experience", "description": "We use your data to show you relevant ads and improve app performance. Your privacy is important to us.", "benefit1": "See ads that are relevant to you", "benefit2": "Your data is never sold to third parties", "benefit3": "You can change this anytime in Settings", "privacyNote": "Tapping \"Continue\" will show Apple's permission dialog. You can allow or deny.", "continue": "Continue" }
tr.json:
"att": { "title": "Deneyiminizi Geliştirmemize Yardım Edin", "description": "Verilerinizi size uygun reklamlar göstermek ve uygulama performansını artırmak için kullanıyoruz. Gizliliğiniz bizim için önemlidir.", "benefit1": "Size ilgili reklamlar görün", "benefit2": "Verileriniz asla üçüncü taraflara satılmaz", "benefit3": "Bunu Ayarlar'dan istediğiniz zaman değiştirebilirsiniz", "privacyNote": "\"Devam Et\" tuşuna basınca Apple'ın izin diyaloğu görünecektir. İzin verebilir veya reddedebilirsiniz.", "continue": "Devam Et" }
Notifications
- Files:
,src/lib/notifications.tssrc/hooks/use-notifications.ts - iOS requires push notification entitlement
App Flow (CRITICAL — ALWAYS FOLLOW THIS ORDER)
iOS: ATT Permission → Onboarding → Paywall → Main App (tabs) Android: Onboarding → Paywall → Main App (tabs)
- ATT screen is iOS only — Android skips it entirely
- ATT screen shows once; result is stored in
(localStorage
)att_shown - After ATT (grant or deny), navigate to onboarding
- After onboarding completes, navigate to paywall
- After paywall (purchase or skip), navigate to main app
// In att-permission.tsx - after permission result: const handleContinue = async () => { await requestTrackingPermissionsAsync(); // request system dialog globalThis.localStorage.setItem("att_shown", "true"); router.replace("/onboarding"); };
// In onboarding.tsx - when user completes onboarding: const handleComplete = async () => { await setOnboardingCompleted(true); router.replace("/paywall"); // Navigate to paywall immediately };
// In paywall.tsx - after purchase or skip: const handleContinue = () => { router.replace("/(tabs)"); // Navigate to main app };
_layout.tsx Routing Logic (iOS ATT check)
In the root
_layout.tsx, determine the initial route on app start:
import { Platform } from "react-native"; import { useEffect } from "react"; import { router } from "expo-router"; import { useOnboarding } from "@/context/onboarding-context"; import "expo-sqlite/localStorage/install"; export default function RootLayout() { const { hasCompletedOnboarding } = useOnboarding(); useEffect(() => { if (hasCompletedOnboarding === null) return; // still loading if (hasCompletedOnboarding) { router.replace("/(tabs)"); return; } // Show ATT only on iOS and only once const attShown = globalThis.localStorage.getItem("att_shown"); if (Platform.OS === "ios" && !attShown) { router.replace("/att-permission"); } else { router.replace("/onboarding"); } }, [hasCompletedOnboarding]); return <Stack screenOptions={{ headerShown: false }} />; }
Paywall Screen Implementation (REQUIRED)
Full implementation of
src/app/paywall.tsx:
import { usePurchases } from "@/context/purchases-context"; import { MaterialIcons } from "@expo/vector-icons"; import type { Purchase } from "expo-iap"; import { useIAP } from "expo-iap"; import { LinearGradient } from "expo-linear-gradient"; import { router } from "expo-router"; import * as WebBrowser from "expo-web-browser"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; // Replace with actual product IDs const SKUS = { monthly: "com.company.appname.monthly", yearly: "com.company.appname.yearly", }; // Replace with actual URLs const TERMS_URL = "https://example.com/terms.html"; const PRIVACY_URL = "https://example.com/privacy.html"; interface Feature { key: string; icon: keyof typeof MaterialIcons.glyphMap; } const FEATURES: Feature[] = [ { key: "paywall.feature1", icon: "block" }, { key: "paywall.feature2", icon: "notifications-active" }, { key: "paywall.feature3", icon: "cloud-off" }, ]; export default function PaywallScreen() { const { t } = useTranslation(); const { refreshPremiumStatus, isPremium } = usePurchases(); const [selectedPlan, setSelectedPlan] = useState<"monthly" | "yearly">( "yearly", ); const [purchasing, setPurchasing] = useState(false); const [restoring, setRestoring] = useState(false); const { connected, subscriptions, fetchProducts, requestPurchase, finishTransaction, restorePurchases, } = useIAP({ onPurchaseSuccess: async (purchase: Purchase) => { try { await finishTransaction({ purchase, isConsumable: false }); await refreshPremiumStatus(); router.replace("/(tabs)"); } catch (err) { console.error("Finish transaction error:", err); } finally { setPurchasing(false); } }, onPurchaseError: (error) => { setPurchasing(false); if ((error as any)?.code !== "E_USER_CANCELLED") { Alert.alert("Error", t("errors.purchaseFailed")); } }, }); useEffect(() => { if (connected) { fetchProducts({ skus: [SKUS.monthly, SKUS.yearly], type: "subs" }); } }, [connected]); const handleClose = () => { if (router.canGoBack()) { router.back(); } else { router.replace("/(tabs)"); } }; const handleSubscribe = async () => { if (purchasing) return; setPurchasing(true); try { const sku = selectedPlan === "monthly" ? SKUS.monthly : SKUS.yearly; await requestPurchase( Platform.OS === "ios" ? { request: { apple: { sku } }, type: "subs" } : { request: { google: { skus: [sku] } }, type: "subs" }, ); } catch { setPurchasing(false); } }; const handleRestore = async () => { if (restoring) return; setRestoring(true); try { await restorePurchases(); await refreshPremiumStatus(); if (isPremium) { router.replace("/(tabs)"); } else { Alert.alert("", t("errors.noActivePurchases")); } } catch { Alert.alert("Error", t("errors.restoreFailed")); } finally { setRestoring(false); } }; const monthlyProduct = subscriptions?.find((p) => p.id === SKUS.monthly); const yearlyProduct = subscriptions?.find((p) => p.id === SKUS.yearly); return ( <View style={styles.container}> <StatusBar barStyle="light-content" /> <LinearGradient colors={["#0A0F1E", "#111827", "#0F172A"]} style={StyleSheet.absoluteFill} /> <SafeAreaView style={styles.safeArea}> {/* Top bar — close button */} <View style={styles.topBar}> <TouchableOpacity onPress={handleClose} testID="close-button" style={styles.closeButton} > <MaterialIcons name="close" size={18} color="rgba(255,255,255,0.7)" /> </TouchableOpacity> </View> {/* Scrollable content */} <ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} bounces={false} > {/* Hero icon */} <View style={styles.heroWrap}> <LinearGradient colors={["#2563EB", "#1D4ED8"]} style={styles.heroGradient} > <MaterialIcons name="workspace-premium" size={40} color="#fff" /> </LinearGradient> <View style={styles.heroBadge}> <MaterialIcons name="verified" size={14} color="#34D399" /> </View> </View> <Text style={styles.title}>{t("paywall.title")}</Text> <Text style={styles.subtitle}>{t("paywall.subtitle")}</Text> {/* Features */} <View style={styles.featuresCard}> {FEATURES.map(({ key, icon }, i) => ( <View key={key}> <View style={styles.featureRow}> <View style={styles.featureIconWrap}> <MaterialIcons name={icon} size={18} color="#60A5FA" /> </View> <Text style={styles.featureText}>{t(key)}</Text> <MaterialIcons name="check" size={16} color="#34D399" /> </View> {i < FEATURES.length - 1 && <View style={styles.separator} />} </View> ))} </View> {/* Plan selector — side by side */} <View style={styles.plansRow}> {/* Monthly */} <TouchableOpacity onPress={() => setSelectedPlan("monthly")} style={[ styles.planCard, selectedPlan === "monthly" ? styles.planCardSelected : styles.planCardIdle, ]} > {selectedPlan === "monthly" && <View style={styles.planDot} />} <Text style={styles.planLabel}>{t("paywall.monthly")}</Text> <Text style={styles.planPrice}> {monthlyProduct?.displayPrice ?? t("paywall.monthlyPrice")} </Text> </TouchableOpacity> {/* Yearly */} <View style={styles.planCardWrap}> <View style={styles.badgeWrap}> <Text style={styles.badgeText}>{t("paywall.yearlyBadge")}</Text> </View> <TouchableOpacity onPress={() => setSelectedPlan("yearly")} style={[ styles.planCard, selectedPlan === "yearly" ? styles.planCardSelected : styles.planCardIdle, ]} > {selectedPlan === "yearly" && <View style={styles.planDot} />} <Text style={styles.planLabel}>{t("paywall.yearly")}</Text> <Text style={styles.planPrice}> {yearlyProduct?.displayPrice ?? t("paywall.yearlyPrice")} </Text> <Text style={styles.planPerWeek}> {t("paywall.yearlyPerWeek")} </Text> </TouchableOpacity> </View> </View> </ScrollView> {/* Sticky bottom CTA */} <View style={styles.footer} className="px-6 pb-4 pt-3"> {/* Gradient subscribe button — kept as Pressable for custom gradient */} <Pressable onPress={handleSubscribe} disabled={purchasing} style={styles.subscribeTouchable} > <LinearGradient colors={ purchasing ? ["#374151", "#374151"] : ["#2563EB", "#1D4ED8"] } start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.subscribeButton} > {purchasing ? ( <ActivityIndicator color="#fff" /> ) : ( <Text className="text-white text-lg font-bold tracking-wide"> {t("paywall.subscribe")} </Text> )} </LinearGradient> </Pressable> <Text style={styles.autoRenewText}>{t("paywall.autoRenew")}</Text> <TouchableOpacity onPress={handleRestore} disabled={restoring} style={styles.restoreRow} > {restoring ? ( <ActivityIndicator size="small" color="rgba(255,255,255,0.4)" /> ) : ( <Text style={styles.linkText}>{t("paywall.restore")}</Text> )} </TouchableOpacity> <View style={styles.linksRow}> <TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(TERMS_URL)} > <Text style={styles.linkText}>{t("paywall.terms")}</Text> </TouchableOpacity> <Text style={styles.linkDot}>·</Text> <TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(PRIVACY_URL)} > <Text style={styles.linkText}>{t("paywall.privacy")}</Text> </TouchableOpacity> </View> </View> </SafeAreaView> </View> ); } const styles = StyleSheet.create({ container: { flex: 1 }, safeArea: { flex: 1 }, topBar: { flexDirection: "row", justifyContent: "flex-end", paddingHorizontal: 16, paddingTop: 8, paddingBottom: 4, }, closeButton: { width: 32, height: 32, borderRadius: 16, backgroundColor: "rgba(255,255,255,0.1)", alignItems: "center", justifyContent: "center", }, scroll: { paddingHorizontal: 24, paddingBottom: 24, alignItems: "center", }, featuresCard: { width: "100%", backgroundColor: "rgba(255,255,255,0.05)", borderWidth: 1, borderColor: "rgba(255,255,255,0.08)", borderRadius: 14, marginBottom: 20, overflow: "hidden", }, featureRow: { flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 16, paddingVertical: 12, }, featureIconWrap: { width: 32, height: 32, borderRadius: 8, backgroundColor: "rgba(37,99,235,0.2)", alignItems: "center", justifyContent: "center", }, featureText: { flex: 1, color: "rgba(255,255,255,0.85)", fontSize: 14, fontWeight: "500", }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: "rgba(255,255,255,0.08)", marginHorizontal: 16, }, plansRow: { flexDirection: "row", width: "100%", gap: 12, }, planCardWrap: { flex: 1, position: "relative", marginTop: 12, }, badgeWrap: { position: "absolute", top: -12, alignSelf: "center", backgroundColor: "#F59E0B", borderRadius: 10, paddingHorizontal: 10, paddingVertical: 3, zIndex: 1, }, badgeText: { color: "#000", fontSize: 11, fontWeight: "800", }, planCard: { flex: 1, alignItems: "center", paddingVertical: 16, paddingHorizontal: 8, borderRadius: 12, }, planCardSelected: { borderWidth: 2, borderColor: "#2563EB", backgroundColor: "rgba(37,99,235,0.12)", }, planCardIdle: { borderWidth: 1, borderColor: "rgba(255,255,255,0.12)", backgroundColor: "rgba(255,255,255,0.04)", }, planDot: { position: "absolute", top: 8, right: 8, width: 8, height: 8, borderRadius: 4, backgroundColor: "#2563EB", }, planLabel: { color: "rgba(255,255,255,0.55)", fontSize: 11, fontWeight: "600", textTransform: "uppercase", letterSpacing: 1, }, planPrice: { color: "#FFFFFF", fontSize: 15, fontWeight: "700", textAlign: "center", }, planPerWeek: { color: "rgba(255,255,255,0.4)", fontSize: 11, textAlign: "center", }, footer: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: "rgba(255,255,255,0.07)", }, subscribeTouchable: { borderRadius: 14, overflow: "hidden", marginBottom: 10, }, subscribeButton: { alignItems: "center", justifyContent: "center", paddingVertical: 16, }, autoRenewText: { color: "rgba(255,255,255,0.3)", fontSize: 11, textAlign: "center", marginBottom: 8, }, restoreRow: { alignItems: "center", justifyContent: "center", paddingVertical: 4, marginBottom: 8, }, linksRow: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, }, linkText: { color: "rgba(255,255,255,0.4)", fontSize: 13, }, linkDot: { color: "rgba(255,255,255,0.2)", fontSize: 14, }, });
Notes:
- Replace
with the app's actual App Store / Play Store product IDsSKUS- Replace
andTERMS_URLwith actual linksPRIVACY_URL- Default selected plan is yearly — adjust
array per appFEATURES fromdisplayPriceshows the real localized price; fallback strings are used while products loadsubscriptions- Add i18n keys:
,paywall.title,paywall.subtitle,paywall.monthly,paywall.yearly,paywall.monthlyPrice,paywall.yearlyPrice,paywall.yearlyBadge,paywall.yearlyPerWeek,paywall.subscribe,paywall.autoRenew,paywall.restore,paywall.terms,paywall.privacy,paywall.feature1-3,errors.purchaseFailed,errors.noActivePurchaseserrors.restoreFailed
Settings Screen Options (REQUIRED)
Settings screen MUST include:
- Language - Change app language
- Theme - Light/Dark/System
- Notifications - Enable/disable notifications
- Remove Ads - Navigate to paywall (hidden if already premium)
- Reset Onboarding - Restart onboarding flow (for testing/demo)
import { usePurchases } from "@/context/purchases-context"; const { isPremium } = usePurchases(); // Global premium state (checked on app startup) // Remove Ads - navigates to paywall const handleRemoveAds = () => { router.push("/paywall"); }; // Reset onboarding const handleResetOnboarding = async () => { await setOnboardingCompleted(false); router.replace("/onboarding"); }; // In settings list: { !isPremium && ( <SettingsItem title={t("settings.removeAds")} icon="crown.fill" onPress={handleRemoveAds} /> ); } <SettingsItem title={t("settings.resetOnboarding")} icon="arrow.counterclockwise" onPress={handleResetOnboarding} />;
Localization
- File:
lib/i18n.ts - Languages stored in
locales/ - App restarts on language change
Coding Standards
- Use functional components
- Strict TypeScript
- Avoid hardcoded strings
- Use padding instead of lineHeight
- Use memoization when necessary
Context Providers
<GestureHandlerRootView style={{ flex: 1 }}> <ThemeProvider> <OnboardingProvider> <PurchasesProvider> {/* ✅ App açılışında isPremium kontrol eder */} <AdsProvider> {/* AdsProvider, isPremium'u PurchasesProvider'dan okur */} <Stack /> </AdsProvider> </PurchasesProvider> </OnboardingProvider> </ThemeProvider> </GestureHandlerRootView>
useColorScheme Hook
File:
src/hooks/use-color-scheme.ts
import { useThemeContext } from "@/context/theme-context"; export function useColorScheme(): "light" | "dark" | "unspecified" { const { isDark } = useThemeContext(); return isDark ? "dark" : "light"; }
Important Notes
- iOS permissions are defined in
app.json - Android permissions are defined in
app.json - Enable new architecture via
newArchEnabled: true - Enable typed routes via
experiments.typedRoutes
App Store & Play Store Notes
- iOS ATT permission required
- Restore purchases must work correctly
- Target SDK must be up to date
Authentication (OIDC — Optional)
Only implement this section if the user answered YES to "Does the app need login/authentication?"
This project uses OpenID Connect (OIDC) with OAuth 2.0 Authorization Code Flow + PKCE.
Architecture
UI (useIntegratedAuth hook) │ ├── authStore (Zustand) ── SecureStore (tokens) │ │ │ └── Identity Server (OIDC) │ ├── /authorize │ ├── /token │ └── /userinfo │ └── services/identity/ ── Authenticated Axios instance
Install Auth Libraries
npx expo install expo-auth-session expo-secure-store expo-web-browser bunx expo install zustand @tanstack/react-query
Environment Variables (.env
)
.envEXPO_PUBLIC_IDENTITY_SERVER_AUTHORITY=https://identity.appaflytech.com EXPO_PUBLIC_OIDC_CLIENT_ID=wap-mobile-app EXPO_PUBLIC_APP_SCHEME=anatoli EXPO_PUBLIC_APP=anatoli
app.json
— Scheme (REQUIRED for redirect URI)
app.json{ "expo": { "scheme": "anatoli" } }
src/utils/constants.ts
src/utils/constants.tsexport const AppConfig = { identityServerAuthority: process.env.EXPO_PUBLIC_IDENTITY_SERVER_AUTHORITY || "https://identity.appaflytech.com", oidcClientId: process.env.EXPO_PUBLIC_OIDC_CLIENT_ID || "wap-mobile-app", appScheme: process.env.EXPO_PUBLIC_APP_SCHEME || "anatoli", app: process.env.EXPO_PUBLIC_APP || "anatoli", };
src/store/authStore.ts
src/store/authStore.tsimport * as AuthSession from "expo-auth-session"; import * as SecureStore from "expo-secure-store"; import * as WebBrowser from "expo-web-browser"; import { create } from "zustand"; import { AppConfig } from "@/utils/constants"; WebBrowser.maybeCompleteAuthSession(); export const OIDC_CONFIG = { issuer: AppConfig.identityServerAuthority, clientId: AppConfig.oidcClientId, scopes: ["openid", "profile", "offline_access"], }; const STORAGE_KEY = "auth_tokens"; const redirectUri = AuthSession.makeRedirectUri({ scheme: AppConfig.appScheme, }); type TokenResponse = { access_token: string; refresh_token?: string; expires_in?: number; id_token?: string; token_type?: string; issued_at?: number; }; type UserModel = { sub: string; name?: string; given_name?: string; family_name?: string; preferred_username?: string; picture?: string; email?: string; email_verified?: boolean; }; type AuthState = { tokens: TokenResponse | null; user: UserModel | null; discovery: AuthSession.DiscoveryDocument | null; ready: boolean; isLoggingIn: boolean; init: () => Promise<void>; login: () => Promise<void>; logout: () => Promise<void>; refresh: () => Promise<TokenResponse>; loadUserInfo: () => Promise<void>; getValidAccessToken: () => Promise<string | null>; isAuthenticated: () => boolean; }; export const useAuthStore = create<AuthState>((set, get) => ({ tokens: null, user: null, discovery: null, ready: false, isLoggingIn: false, init: async () => { try { // Load discovery document const discovery = await AuthSession.fetchDiscoveryAsync( OIDC_CONFIG.issuer, ); set({ discovery }); // Restore saved tokens const raw = await SecureStore.getItemAsync(STORAGE_KEY); if (raw) { const tokens: TokenResponse = JSON.parse(raw); set({ tokens }); await get().loadUserInfo(); } } catch (e) { console.warn("Auth init error:", e); } finally { set({ ready: true }); } }, login: async () => { const { discovery } = get(); if (!discovery) throw new Error("Discovery not loaded"); set({ isLoggingIn: true }); try { const request = new AuthSession.AuthRequest({ clientId: OIDC_CONFIG.clientId, redirectUri, scopes: OIDC_CONFIG.scopes, responseType: AuthSession.ResponseType.Code, usePKCE: true, }); const authUrl = await request.makeAuthUrlAsync(discovery); const authUrlFull = `${authUrl}&app=${AppConfig.app}&lang=tr`; const result = await WebBrowser.openAuthSessionAsync( authUrlFull, redirectUri, { preferEphemeralSession: true }, ); if (result.type !== "success") throw new Error("Login cancelled"); const code = new URL(result.url).searchParams.get("code"); if (!code) throw new Error("No code returned"); const tokenResult = await AuthSession.exchangeCodeAsync( { code, clientId: OIDC_CONFIG.clientId, redirectUri, codeVerifier: request.codeVerifier!, }, discovery, ); const payload: TokenResponse = { access_token: tokenResult.accessToken, refresh_token: tokenResult.refreshToken ?? undefined, expires_in: tokenResult.expiresIn ?? undefined, id_token: tokenResult.idToken ?? undefined, issued_at: Math.floor(Date.now() / 1000), }; await SecureStore.setItemAsync(STORAGE_KEY, JSON.stringify(payload)); set({ tokens: payload }); await get().loadUserInfo(); } finally { set({ isLoggingIn: false }); } }, logout: async () => { const { tokens, discovery } = get(); try { if (tokens?.id_token && discovery?.endSessionEndpoint) { const logoutUrl = `${discovery.endSessionEndpoint}?id_token_hint=${tokens.id_token}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`; await WebBrowser.openAuthSessionAsync(logoutUrl, redirectUri, { preferEphemeralSession: true, }); } } finally { await SecureStore.deleteItemAsync(STORAGE_KEY); set({ tokens: null, user: null }); } }, refresh: async () => { const { tokens, discovery } = get(); if (!tokens?.refresh_token || !discovery) throw new Error("Cannot refresh"); const result = await AuthSession.refreshAsync( { clientId: OIDC_CONFIG.clientId, refreshToken: tokens.refresh_token }, discovery, ); const payload: TokenResponse = { access_token: result.accessToken, refresh_token: result.refreshToken ?? tokens.refresh_token, expires_in: result.expiresIn ?? undefined, issued_at: Math.floor(Date.now() / 1000), }; await SecureStore.setItemAsync(STORAGE_KEY, JSON.stringify(payload)); set({ tokens: payload }); return payload; }, loadUserInfo: async () => { const { tokens, discovery } = get(); if (!tokens?.access_token || !discovery?.userInfoEndpoint) return; const res = await fetch(discovery.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokens.access_token}` }, }); const user: UserModel = await res.json(); set({ user }); }, getValidAccessToken: async () => { const { tokens, refresh } = get(); if (!tokens) return null; const isExpired = (() => { if (!tokens.expires_in || !tokens.issued_at) return false; return ( Math.floor(Date.now() / 1000) >= tokens.issued_at + tokens.expires_in - 30 ); })(); if (isExpired) { try { const refreshed = await refresh(); return refreshed.access_token; } catch { set({ tokens: null, user: null }); return null; } } return tokens.access_token; }, isAuthenticated: () => { return !!get().tokens?.access_token; }, }));
src/store/useIntegratedAuth.ts
src/store/useIntegratedAuth.tsimport { useEffect } from "react"; import { useAuthStore } from "./authStore"; export interface AppUser { id?: string; name?: string; surname?: string; email?: string; avatar?: string; isLoggedIn: boolean; } // Minimal app-level user state — wire into your own store/context as needed let _appUser: AppUser = { isLoggedIn: false }; const _listeners = new Set<() => void>(); function setAppUser(u: AppUser) { _appUser = u; _listeners.forEach((l) => l()); } export function useIntegratedAuth() { const authStore = useAuthStore(); // Sync OIDC state → app user state useEffect(() => { if (!authStore.ready) return; const oidcLoggedIn = authStore.isAuthenticated(); if (oidcLoggedIn && authStore.user && !_appUser.isLoggedIn) { setAppUser({ id: authStore.user.sub, name: authStore.user.given_name || authStore.user.name, surname: authStore.user.family_name, email: authStore.user.email, avatar: authStore.user.picture, isLoggedIn: true, }); } else if (!oidcLoggedIn && _appUser.isLoggedIn) { setAppUser({ isLoggedIn: false }); } }, [authStore.ready, authStore.tokens, authStore.user]); const login = async () => { await authStore.login(); }; const logout = async () => { await authStore.logout(); setAppUser({ isLoggedIn: false }); }; const getAccessToken = () => authStore.getValidAccessToken(); return { isAuthenticated: authStore.isAuthenticated(), isLoggingIn: authStore.isLoggingIn, ready: authStore.ready, user: authStore.user, appUser: _appUser, login, logout, getAccessToken, }; }
Initialize Auth in _layout.tsx
_layout.tsximport { useEffect } from "react"; import { useAuthStore } from "@/store/authStore"; export default function RootLayout() { const initAuth = useAuthStore((s) => s.init); useEffect(() => { initAuth(); // Load tokens + discovery on app start }, []); // ... rest of your layout }
Flow with Auth Enabled
iOS: ATT → Onboarding → Paywall → Main App Android: Onboarding → Paywall → Main App Login screen is accessible from Settings or any protected screen. Authenticated state is checked via useIntegratedAuth().isAuthenticated.
src/app/auth/oidc-login.tsx
— Login Screen
src/app/auth/oidc-login.tsximport { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet, } from "react-native"; import { useIntegratedAuth } from "@/store/useIntegratedAuth"; export default function OIDCLoginScreen() { const { login, isLoggingIn, ready } = useIntegratedAuth(); return ( <View style={styles.container}> <Text style={styles.title}>Giriş Yap</Text> <TouchableOpacity style={[ styles.button, (!ready || isLoggingIn) && styles.buttonDisabled, ]} onPress={login} disabled={!ready || isLoggingIn} > {isLoggingIn ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.buttonText}>Hesabınla Giriş Yap</Text> )} </TouchableOpacity> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", alignItems: "center", padding: 32, }, title: { fontSize: 28, fontWeight: "700", marginBottom: 40 }, button: { backgroundColor: "#6C63FF", borderRadius: 16, padding: 18, width: "100%", alignItems: "center", }, buttonDisabled: { opacity: 0.5 }, buttonText: { color: "#fff", fontSize: 17, fontWeight: "700" }, });
src/services/identity/index.ts
— Authenticated Axios
src/services/identity/index.tsimport axios from "axios"; import { AppConfig } from "@/utils/constants"; import { useAuthStore } from "@/store/authStore"; export const identityAxios = axios.create({ baseURL: AppConfig.identityServerAuthority, }); identityAxios.interceptors.request.use(async (config) => { const token = await useAuthStore.getState().getValidAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; });
Auth Usage Examples
// Check auth state import { useIntegratedAuth } from "@/store/useIntegratedAuth"; function ProfileScreen() { const { isAuthenticated, user, logout } = useIntegratedAuth(); if (!isAuthenticated) return <LoginPrompt />; return ( <View> <Text>Hoş geldin, {user?.given_name}!</Text> <Button title="Çıkış Yap" onPress={logout} /> </View> ); }
// Authenticated API call async function fetchProtectedData() { const token = await useAuthStore.getState().getValidAccessToken(); if (!token) throw new Error("Not authenticated"); const res = await fetch("https://api.appaflytech.com/data", { headers: { Authorization: `Bearer ${token}` }, }); return res.json(); }
Security Features
| Feature | Detail |
|---|---|
| PKCE | Authorization Code Flow with Proof Key for Code Exchange |
| SecureStore | Tokens stored in iOS Keychain / Android Keystore |
| Ephemeral Session | WebBrowser doesn't share cookies; every login is fresh |
| Auto Token Refresh | Token renewed 30s before expiry automatically |
| Token Cleanup | On refresh failure, tokens cleared and user logged out |
Maestro E2E Tests (ALWAYS GENERATE AFTER BUILDING SCREENS)
Maestro is an open-source mobile UI testing framework using YAML flow files. After building each screen, automatically generate the corresponding Maestro flow.
Installation
SECURITY NOTE: Do NOT pipe remote scripts directly to
. Download first, inspect, then execute.bash
# Safe two-step install (download, review, then execute) curl -fsSL "https://get.maestro.mobile.dev" -o install-maestro.sh # Optionally inspect: cat install-maestro.sh bash install-maestro.sh maestro --version # requires Java 17+
Project Structure
project-root/ └── .maestro/ ├── 00_app_launch.yaml ├── 01_att_permission.yaml # iOS only ├── 02_onboarding.yaml ├── 03_paywall_skip.yaml ├── 04_paywall_subscribe.yaml ├── 05_main_tabs.yaml ├── 06_settings.yaml └── 07_full_flow.yaml
Run Tests
maestro test .maestro/02_onboarding.yaml # single flow maestro test .maestro/ # all flows
Key Commands
| Command | Description |
|---|---|
| Fresh launch, clears all data |
| Tap by visible text |
| Tap by testID prop |
| Assert element visible |
| Assert element NOT visible |
| Type into focused input |
| Swipe gesture |
| Android back button |
| Capture screenshot |
| Reuse another flow |
| Skip step if element not found |
Flow Templates
Adapt
appId and all text strings to the app's actual English i18n values.
00 — App Launch
# .maestro/00_app_launch.yaml appId: com.company.appname --- - launchApp: clearState: true - takeScreenshot: app_launch
01 — ATT Permission (iOS only)
# .maestro/01_att_permission.yaml appId: com.company.appname --- - launchApp: clearState: true - assertVisible: "Continue" - takeScreenshot: att_screen - tapOn: "Continue" - tapOn: text: "Allow" optional: true - takeScreenshot: att_after
02 — Onboarding
# .maestro/02_onboarding.yaml appId: com.company.appname --- - launchApp: clearState: true # Dismiss ATT if present (iOS) - tapOn: text: "Continue" optional: true - tapOn: text: "Allow" optional: true # Swipe through slides - takeScreenshot: onboarding_slide_1 - swipe: direction: LEFT duration: 400 - takeScreenshot: onboarding_slide_2 - swipe: direction: LEFT duration: 400 - takeScreenshot: onboarding_slide_3 - swipe: direction: LEFT duration: 400 - takeScreenshot: onboarding_slide_4 - tapOn: "Get Started" - takeScreenshot: onboarding_complete
03 — Paywall Skip
# .maestro/03_paywall_skip.yaml appId: com.company.appname --- - runFlow: 02_onboarding.yaml - assertVisible: "Yearly" - assertVisible: "Monthly" - takeScreenshot: paywall_screen - tapOn: id: "close-button" optional: true - tapOn: text: "×" optional: true - takeScreenshot: paywall_closed
04 — Paywall Plan Selection
# .maestro/04_paywall_subscribe.yaml appId: com.company.appname --- - runFlow: 02_onboarding.yaml - tapOn: "Yearly" - takeScreenshot: paywall_yearly_selected - tapOn: "Monthly" - takeScreenshot: paywall_monthly_selected # Opens store sheet — cannot complete purchase in automated test - tapOn: "Subscribe" - takeScreenshot: paywall_subscribe_tapped
05 — Main Tabs Navigation
# .maestro/05_main_tabs.yaml appId: com.company.appname --- - runFlow: 02_onboarding.yaml - runFlow: 03_paywall_skip.yaml - takeScreenshot: main_home - tapOn: "Settings" - takeScreenshot: main_settings - tapOn: "Home" - takeScreenshot: main_home_again
06 — Settings Screen
# .maestro/06_settings.yaml appId: com.company.appname --- - runFlow: 02_onboarding.yaml - runFlow: 03_paywall_skip.yaml - tapOn: "Settings" - assertVisible: "Language" - assertVisible: "Theme" - assertVisible: "Notifications" - takeScreenshot: settings_screen
07 — Full End-to-End Flow
# .maestro/07_full_flow.yaml appId: com.company.appname --- - launchApp: clearState: true - runFlow: 01_att_permission.yaml - runFlow: 02_onboarding.yaml - runFlow: 03_paywall_skip.yaml - runFlow: 05_main_tabs.yaml - runFlow: 06_settings.yaml - takeScreenshot: full_flow_complete
Notes
— iOS only, skip on Android builds01_att_permission.yaml- System dialogs use
(vary by OS/device)optional: true - Android:
simulates hardware back button- back - iOS simulator:
maestro --device booted test .maestro/ - Use
to chain — no duplicate setup stepsrunFlow
GitHub Actions CI/CD (ALWAYS CREATE)
After generating
.maestro/ flows, you MUST also create the GitHub Actions workflow so tests run automatically on every push and pull request.
Project Structure (add these files)
project-root/ ├── .github/ │ └── workflows/ │ ├── maestro-android.yml # Android emulator tests (free, ubuntu) │ └── maestro-ios.yml # iOS simulator tests (macOS runner) └── .maestro/ └── ...
.github/workflows/maestro-android.yml
.github/workflows/maestro-android.ymlname: Maestro E2E — Android on: push: branches: [main, develop] pull_request: branches: [main, develop] jobs: e2e-android: runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Java 17 uses: actions/setup-java@v4 with: java-version: "17" distribution: "temurin" - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm install - name: Install Maestro run: | # Download first, then execute (avoids curl|bash anti-pattern) curl -fsSL "https://get.maestro.mobile.dev" -o install-maestro.sh bash install-maestro.sh echo "$HOME/.maestro/bin" >> $GITHUB_PATH - name: Enable KVM (Android emulator acceleration) run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Expo Prebuild run: npx expo prebuild --platform android --non-interactive - name: Run Android E2E Tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 33 arch: x86_64 profile: pixel_6 avd-name: maestro_test emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim disable-animations: true script: | cd android && ./gradlew assembleDebug --no-daemon && cd .. adb install -r android/app/build/outputs/apk/debug/app-debug.apk maestro test .maestro/ --format junit --output test-results.xml - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: maestro-android-results path: | test-results.xml ~/.maestro/tests/ - name: Upload screenshots if: always() uses: actions/upload-artifact@v4 with: name: maestro-android-screenshots path: ~/.maestro/tests/**/*.png
.github/workflows/maestro-ios.yml
.github/workflows/maestro-ios.ymlname: Maestro E2E — iOS on: push: branches: [main] pull_request: branches: [main] jobs: e2e-ios: runs-on: macos-15 timeout-minutes: 90 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Java 17 (required by Maestro) uses: actions/setup-java@v4 with: java-version: "17" distribution: "temurin" - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm install - name: Install Maestro run: | # Download first, then execute (avoids curl|bash anti-pattern) curl -fsSL "https://get.maestro.mobile.dev" -o install-maestro.sh bash install-maestro.sh echo "$HOME/.maestro/bin" >> $GITHUB_PATH - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_16.2.app - name: Expo Prebuild run: npx expo prebuild --platform ios --non-interactive - name: Install CocoaPods dependencies run: cd ios && pod install - name: Boot iOS Simulator run: | UDID=$(xcrun simctl create "MaestroTest" "iPhone 16" "iOS-18-2") xcrun simctl boot $UDID echo "SIM_UDID=$UDID" >> $GITHUB_ENV - name: Build app for simulator run: | SCHEME=$(ls ios/*.xcworkspace | head -1 | xargs basename | sed 's/.xcworkspace//') xcodebuild \ -workspace ios/$SCHEME.xcworkspace \ -scheme $SCHEME \ -configuration Debug \ -sdk iphonesimulator \ -derivedDataPath build \ -quiet APP_PATH=$(find build -name "*.app" | head -1) xcrun simctl install ${{ env.SIM_UDID }} "$APP_PATH" - name: Run iOS E2E Tests run: maestro --device ${{ env.SIM_UDID }} test .maestro/ --format junit --output test-results.xml env: MAESTRO_DRIVER_STARTUP_TIMEOUT: "60000" - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: maestro-ios-results path: | test-results.xml ~/.maestro/tests/ - name: Upload screenshots if: always() uses: actions/upload-artifact@v4 with: name: maestro-ios-screenshots path: ~/.maestro/tests/**/*.png - name: Cleanup simulator if: always() run: xcrun simctl delete ${{ env.SIM_UDID }}
No GitHub Secrets Required
Both workflows build the app locally on the CI runner — no EAS account, no Maestro Cloud, no secrets needed.
Android uses Gradle directly:
- name: Expo Prebuild run: npx expo prebuild --platform android --non-interactive # Then inside android-emulator-runner script: # cd android && ./gradlew assembleDebug --no-daemon # adb install -r app/build/outputs/apk/debug/app-debug.apk
iOS uses
xcodebuild directly:
- name: Expo Prebuild run: npx expo prebuild --platform ios --non-interactive - name: Install CocoaPods run: cd ios && pod install - name: Build for simulator run: | SCHEME=$(ls ios/*.xcworkspace | head -1 | xargs basename | sed 's/.xcworkspace//') xcodebuild \ -workspace ios/$SCHEME.xcworkspace \ -scheme $SCHEME \ -configuration Debug \ -sdk iphonesimulator \ -derivedDataPath build \ -quiet APP_PATH=$(find build -name "*.app" | head -1) xcrun simctl install ${{ env.SIM_UDID }} "$APP_PATH"
The complete final workflows with local builds are provided above (
maestro-android.yml / maestro-ios.yml). Replace the # install APK comment lines in those templates with the Gradle/xcodebuild steps shown here.
CI-Friendly Maestro Flow Tips
# Use env variables for appId in CI appId: ${APP_ID:-com.company.appname} --- # Add retries for flaky steps - tapOn: text: "Get Started" retryTapIfNoChange: true # Increase timeouts for slow CI environments - tapOn: text: "Subscribe" waitToSettleTimeoutMs: 5000 # Skip ATT on Android / CI - runFlow: when: platform: iOS file: 01_att_permission.yaml
Testing Checklist
-
— all flows pass on iOS and Androidmaestro test .maestro/ - Login/logout flow (if auth enabled)
- UI tested in all languages (tr / en)
- Dark / Light mode
- Notifications
- Premium flow
- Restore purchases
- Offline support
- Multiple screen sizes
After Development
npx expo prebuild --clean bun ios bun android
NOTE:
recreates ios and android folders. Run it after modifying native modules or app.json.prebuild --clean