| name | emrah-skills |
| description | 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 |
Expo 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)
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",
: ,
: ,
: ,
},
{
: ,
: ,
: ,
: ,
},
{
: ,
: ,
: ,
: ,
},
{
: ,
: ,
: ,
: ,
},
];
() {
{ t } = ();
{ setOnboardingCompleted } = ();
[activeIndex, setActiveIndex] = ();
flatListRef = useRef<>();
player = (, {
p. = ;
p. = ;
p.();
});
= () => {
(activeIndex < . - ) {
flatListRef.?.({
: activeIndex + ,
: ,
});
(activeIndex + );
} {
();
}
};
= () => {
();
router.();
};
isLast = activeIndex === . - ;
(
);
}
styles = .({
: { : , : },
: { : },
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
});
Notes:
- Place your onboarding video at
assets/onboarding.mp4 (adjust the require path to match the actual file)
SafeAreaView is from react-native-safe-area-context, NOT react-native
- Slide icons use
@expo/vector-icons MaterialIcons — adjust icon names per app theme
- Slides array and icon names should be customized per app
- Add required i18n keys:
onboarding.slide1.title, onboarding.slide1.description, etc., plus onboarding.skip, onboarding.next, onboarding.getStarted
Required Navigation (ALWAYS USE)
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>
;
Required Libraries (ALWAYS INSTALL)
Use npx expo install to install Expo libraries (NOT npm/yarn/bun install).
Use bun add for non-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
npx expo install react-native-screens react-native-reanimated react-native-gesture-handler react-native-safe-area-context react-native-svg
Libraries:
expo-iap (In-App Purchases)
expo-build-properties (required by expo-iap)
expo-tracking-transparency (ATT — iOS App Tracking Transparency)
react-native-google-mobile-ads (AdMob)
expo-notifications
i18next + react-i18next + expo-localization
react-native-reanimated
expo-video + expo-audio
expo-sqlite (for localStorage)
expo-linear-gradient (for gradient overlays)
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";
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 : ,
};
= * * * ;
= * * ;
= ;
= ;
= ;
= ;
= ;
() {
().().(, );
}
{
: ;
: ;
: ;
: ;
: <>;
}
= createContext<>({
: ,
: .,
: .,
: {},
: () => ,
});
() {
{ isPremium } = ();
shouldShowAds = !isPremium;
appOpenAdRef = useRef< | >();
appOpenLoadedRef = ();
isFirstLaunchRef = ();
loadAppOpen = ( {
(!shouldShowAds) ;
ad = .(., {
: ,
});
ad.(., {
appOpenLoadedRef. = ;
});
ad.(., {
appOpenLoadedRef. = ;
appOpenAdRef. = ;
();
});
ad.(., {
appOpenLoadedRef. = ;
(loadAppOpen, );
});
ad.();
appOpenAdRef. = ad;
}, [shouldShowAds]);
tryShowAppOpen = ( {
(!shouldShowAds || !appOpenLoadedRef. || !appOpenAdRef.)
;
(isFirstLaunchRef.) {
isFirstLaunchRef. = ;
;
}
lastShown = globalThis..();
now = .();
(lastShown && now - (lastShown, ) < )
;
globalThis..(, (now));
appOpenAdRef..().( ());
}, [shouldShowAds, loadAppOpen]);
appStateRef = useRef<>(.);
( {
(!shouldShowAds) ;
();
sub = .(, {
(appStateRef. !== && state === ) {
();
}
appStateRef. = state;
});
sub.();
}, [shouldShowAds, loadAppOpen, tryShowAppOpen]);
interstitialRef = useRef< | >();
interstitialLoadedRef = ();
loadInterstitial = ( {
(!shouldShowAds) ;
ad = .(., {
: ,
});
ad.(., {
interstitialLoadedRef. = ;
});
ad.(., {
interstitialLoadedRef. = ;
interstitialRef. = ;
();
});
ad.(., {
interstitialLoadedRef. = ;
});
ad.();
interstitialRef. = ad;
}, [shouldShowAds]);
( {
(shouldShowAds) ();
}, [shouldShowAds, loadInterstitial]);
showInterstitial = ( {
(
!shouldShowAds ||
!interstitialLoadedRef. ||
!interstitialRef.
)
;
now = .();
today = ();
lastDate = globalThis..();
countToday = (
globalThis..() ?? ,
,
);
(lastDate !== today) {
countToday = ;
globalThis..(, today);
}
(countToday >= ) ;
lastTs = (
globalThis..() ?? ,
,
);
(now - lastTs < ) ;
globalThis..(, (now));
globalThis..(, (countToday + ));
interstitialRef..().( ());
}, [shouldShowAds, loadInterstitial]);
rewardedRef = useRef< | >();
rewardedLoadedRef = ();
loadRewarded = ( {
(!shouldShowAds) ;
ad = .(., {
: ,
});
ad.(., {
rewardedLoadedRef. = ;
});
ad.(., {
rewardedLoadedRef. = ;
rewardedRef. = ;
();
});
ad.(., {
rewardedLoadedRef. = ;
});
ad.();
rewardedRef. = ad;
}, [shouldShowAds]);
( {
(shouldShowAds) ();
}, [shouldShowAds, loadRewarded]);
showRewarded = ((): <> => {
( {
(
!shouldShowAds ||
!rewardedLoadedRef. ||
!rewardedRef.
) {
();
;
}
ad = rewardedRef.!;
rewarded = ;
ad.(., {
rewarded = ;
});
ad.(., {
(rewarded);
});
ad.().( ());
});
}, [shouldShowAds]);
(
);
}
() {
();
}
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>
< =>
{t("tabs.settings")}
{shouldShowAds && (
)}
);
}
styles = .({
: { : },
: { : , : },
});
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
localStorage under ads_app_open_last_shown
isFirstLaunchRef ensures the ad never fires on the initial cold open
- After
AdsProvider mounts, the App Open ad is preloaded silently and auto-reloaded after each show
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();
};
}
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 ;
(
);
}
styles = .({
: {
: ,
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
},
: { : , : , : },
: { : , : },
: {
: ,
: ,
: ,
: ,
},
: { : , : },
: {
: ,
: ,
: ,
: ,
: ,
: ,
: ,
},
});
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 (
entry.type === "item" ? entry.item.id : entry.key
}
renderItem={({ item: entry }) =>
entry.type === "native_ad" ? (
) : (
)
}
/>
);
}
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();
}
};
}
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 | AD_UNITS.banner | Apps → Ad units → Banner |
| Interstitial | AD_UNITS.interstitial | Apps → Ad units → Interstitial |
| Rewarded | AD_UNITS.rewarded | Apps → Ad units → Rewarded |
| App Open | AD_UNITS.appOpen | Apps → Ad units → App open |
| Native | AD_UNITS.native | Apps → Ad units → Native advanced |
- ALWAYS use
TestIds.* in __DEV__ to avoid policy violations
shouldShowAds = !isPremium — all formats hidden for premium users
AdsProvider must be nested inside PurchasesProvider
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
expo-sqlite/localStorage/install instead
- ❌ lineHeight style - Use padding/margin instead
- ❌
Tabs from expo-router - Use NativeTabs instead
- ❌
@react-navigation/bottom-tabs - Use NativeTabs instead
- ❌
expo-av - Use expo-video for video, expo-audio for audio instead
- ❌
expo-ads-admob - Use react-native-google-mobile-ads instead
- ❌ Any other ads library - ONLY use
react-native-google-mobile-ads
- ❌ Reanimated hooks inside callbacks - Call at component top level
- ❌
SafeAreaView from react-native - Use import { SafeAreaView } from 'react-native-safe-area-context' instead
Reanimated Usage (IMPORTANT)
NEVER call useAnimatedStyle, useSharedValue, or other reanimated hooks inside callbacks, loops, or conditions.
❌ WRONG:
const renderItem = () => {
const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 }));
return <Animated.View style={animatedStyle} />;
};
✅ CORRECT:
function MyComponent() {
const animatedStyle = useAnimatedStyle(() => ({ opacity: 1 }));
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>;
}
renderItem={({ item }) => <AnimatedItem item={item} />}
POST-CREATION CLEANUP (ALWAYS DO)
After creating a new Expo project, you MUST:
- If using
(tabs) folder, DELETE src/app/index.tsx to avoid route conflicts:
rm src/app/index.tsx
- Check and remove
lineHeight from these files:
src/components/themed-text.tsx (comes with lineHeight by default - REMOVE IT)
- 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 |
|---|
src/app/att-permission.tsx | .maestro/01_att_permission.yaml |
src/app/onboarding.tsx | .maestro/02_onboarding.yaml |
src/app/paywall.tsx | .maestro/03_paywall_skip.yaml + .maestro/04_paywall_subscribe.yaml |
src/app/(tabs)/index.tsx | .maestro/05_main_tabs.yaml |
src/app/settings.tsx | .maestro/06_settings.yaml |
| Any new tab/screen | .maestro/0N_<screen_name>.yaml |
When creating a new project, also create the GitHub Actions workflows:
| File | Purpose |
|---|
.github/workflows/maestro-android.yml | Android emulator E2E (ubuntu) |
.github/workflows/maestro-ios.yml | 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
install --fix fixes dependency version mismatches
prebuild --clean recreates ios and android folders
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)?"
- Create the project in the CURRENT directory using:
bunx create-expo -t default@next app-name
- Update
app.json with the bundle ID:
{
"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.
import "expo-sqlite/localStorage/install";
globalThis.localStorage.setItem("key", "value");
console.log(globalThis.localStorage.getItem("key"));
WARNING: NEVER USE lineHeight! It causes layout issues in React Native. Use padding or margin instead.
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" = />
);
}
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
useIAP hook and checks subscription status on app startup
- Product SKUs: weekly (
weekly_premium) and yearly (yearly_premium)
- Paywall:
app/paywall.tsx
- Exposes
usePurchases() → { isPremium, loading, premiumExpiryDate, premiumProductId, refreshPremiumStatus }
refreshPremiumStatus() must be called after a successful purchase
drainPendingTransactions() runs on startup to acknowledge stuck transactions
- Use
getAvailablePurchases() for restore purchases flow
- Always call
finishTransaction after a successful purchase
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";
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] = ();
[loading, setLoading] = ();
[premiumExpiryDate, setPremiumExpiryDate] = useState< | >();
[premiumProductId, setPremiumProductId] = useState< | >();
= () => {
{
purchases = ();
( purchase purchases) {
{
({ purchase, : });
} {
}
}
} {
}
};
refreshPremiumStatus = ( () => {
{
();
hasPremium = ();
(hasPremium);
(hasPremium) {
purchases = ();
activeSubs = purchases.(
.(p.),
);
: | = ;
: | = ;
( p activeSubs) {
expMs = (p { ?: | })
.;
(expMs) {
d = (expMs);
(!bestExpiry || d > bestExpiry) {
bestExpiry = d;
bestProductId = p.;
}
} (!bestProductId) {
bestProductId = p.;
}
}
(bestExpiry);
(bestProductId);
} {
();
();
}
} (error) {
.(, error);
} {
();
}
}, [hasActiveSubscriptions]);
( {
();
}, [refreshPremiumStatus]);
(
);
}
() {
();
}
Notes:
drainPendingTransactions acknowledges unfinished transactions on startup (prevents stuck purchases)
premiumExpiryDate is iOS only (expirationDateIOS); Android doesn't expose this field
premiumProductId lets you know which plan (monthly/yearly) is active
- Replace
SUBSCRIPTION_SKUS with the app's actual App Store / Play Store product IDs
After a successful purchase in paywall.tsx, always call refreshPremiumStatus():
const { refreshPremiumStatus } = usePurchases();
await finishTransaction({ purchase, isConsumable: false });
await refreshPremiumStatus();
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> — true if user earned the reward
- All ads hidden for premium users via
shouldShowAds = !isPremium
- Always use
TestIds.* in __DEV__ to avoid policy violations
AdsProvider must be nested inside PurchasesProvider in _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
requestTrackingPermissionsAsync from expo-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";
export function unstable_settings() {
return {};
}
export default function ATTPermissionScreen() {
const { t } = useTranslation();
useEffect(() => {
if (Platform.OS !== ) {
router.();
}
}, []);
= () => {
();
globalThis..(, );
router.();
};
(
);
}
() {
(
);
}
styles = .({
: {
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
});
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.ts, src/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
const handleContinue = async () => {
await requestTrackingPermissionsAsync();
globalThis.localStorage.setItem("att_shown", "true");
router.replace("/onboarding");
};
const handleComplete = async () => {
await setOnboardingCompleted(true);
router.replace("/paywall");
};
const handleContinue = () => {
router.replace("/(tabs)");
};
_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;
if (hasCompletedOnboarding) {
router.replace("/(tabs)");
return;
}
const attShown = globalThis.localStorage.getItem("att_shown");
if (Platform.OS === "ios" && !attShown) {
router.replace("/att-permission");
} else {
router.replace("/onboarding");
}
}, [hasCompletedOnboarding]);
return < = }} />;
}
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";
= {
: ,
: ,
};
= ;
= ;
{
: ;
: keyof .;
}
: [] = [
{ : , : },
{ : , : },
{ : , : },
];
() {
{ t } = ();
{ refreshPremiumStatus, isPremium } = ();
[selectedPlan, setSelectedPlan] = useState< | >(
,
);
[purchasing, setPurchasing] = ();
[restoring, setRestoring] = ();
{
connected,
subscriptions,
fetchProducts,
requestPurchase,
finishTransaction,
restorePurchases,
} = ({
: (: ) => {
{
({ purchase, : });
();
router.();
} (err) {
.(, err);
} {
();
}
},
: {
();
((error )?. !== ) {
.(, ());
}
},
});
( {
(connected) {
({ : [., .], : });
}
}, [connected]);
= () => {
(router.()) {
router.();
} {
router.();
}
};
= () => {
(purchasing) ;
();
{
sku = selectedPlan === ? . : .;
(
. ===
? { : { : { sku } }, : }
: { : { : { : [sku] } }, : },
);
} {
();
}
};
= () => {
(restoring) ;
();
{
();
();
(isPremium) {
router.();
} {
.(, ());
}
} {
.(, ());
} {
();
}
};
monthlyProduct = subscriptions?.( p. === .);
yearlyProduct = subscriptions?.( p. === .);
(
);
}
styles = .({
: { : },
: { : },
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: .,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: -,
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: .,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
: ,
: ,
},
: {
: ,
: ,
},
: {
: ,
: ,
},
});
Notes:
- Replace
SKUS with the app's actual App Store / Play Store product IDs
- Replace
TERMS_URL and PRIVACY_URL with actual links
- Default selected plan is yearly — adjust
FEATURES array per app
displayPrice from subscriptions shows the real localized price; fallback strings are used while products load
- 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.noActivePurchases, errors.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();
const handleRemoveAds = () => {
router.push("/paywall");
};
const handleResetOnboarding = async () => {
await setOnboardingCompleted(false);
router.replace("/onboarding");
};
{
!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)
EXPO_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)
{
"expo": {
"scheme": "anatoli"
}
}
src/utils/constants.ts
export 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
import * 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;
?: ;
?: ;
?: ;
?: ;
};
= {
: ;
?: ;
?: ;
?: ;
?: ;
?: ;
?: ;
?: ;
};
= {
: | ;
: | ;
: . | ;
: ;
: ;
: <>;
: <>;
: <>;
: <>;
: <>;
: < | >;
: ;
};
useAuthStore = create<>( ({
: ,
: ,
: ,
: ,
: ,
: () => {
{
discovery = .(
.,
);
({ discovery });
raw = .();
(raw) {
: = .(raw);
({ tokens });
().();
}
} (e) {
.(, e);
} {
({ : });
}
},
: () => {
{ discovery } = ();
(!discovery) ();
({ : });
{
request = .({
: .,
redirectUri,
: .,
: ..,
: ,
});
authUrl = request.(discovery);
authUrlFull = ;
result = .(
authUrlFull,
redirectUri,
{ : },
);
(result. !== ) ();
code = (result.)..();
(!code) ();
tokenResult = .(
{
code,
: .,
redirectUri,
: request.!,
},
discovery,
);
: = {
: tokenResult.,
: tokenResult. ?? ,
: tokenResult. ?? ,
: tokenResult. ?? ,
: .(.() / ),
};
.(, .(payload));
({ : payload });
().();
} {
({ : });
}
},
: () => {
{ tokens, discovery } = ();
{
(tokens?. && discovery?.) {
logoutUrl = ;
.(logoutUrl, redirectUri, {
: ,
});
}
} {
.();
({ : , : });
}
},
: () => {
{ tokens, discovery } = ();
(!tokens?. || !discovery) ();
result = .(
{ : ., : tokens. },
discovery,
);
: = {
: result.,
: result. ?? tokens.,
: result. ?? ,
: .(.() / ),
};
.(, .(payload));
({ : payload });
payload;
},
: () => {
{ tokens, discovery } = ();
(!tokens?. || !discovery?.) ;
res = (discovery., {
: { : },
});
: = res.();
({ user });
},
: () => {
{ tokens, refresh } = ();
(!tokens) ;
isExpired = ( {
(!tokens. || !tokens.) ;
(
.(.() / ) >=
tokens. + tokens. -
);
})();
(isExpired) {
{
refreshed = ();
refreshed.;
} {
({ : , : });
;
}
}
tokens.;
},
: {
!!().?.;
},
}));
src/store/useIntegratedAuth.ts
import { useEffect } from "react";
import { useAuthStore } from "./authStore";
export interface AppUser {
id?: string;
name?: string;
surname?: string;
email?: string;
avatar?: string;
isLoggedIn: boolean;
}
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();
useEffect(() => {
if (!authStore.ready) ;
oidcLoggedIn = authStore.();
(oidcLoggedIn && authStore. && !_appUser.) {
({
: authStore..,
: authStore.. || authStore..,
: authStore..,
: authStore..,
: authStore..,
: ,
});
} (!oidcLoggedIn && _appUser.) {
({ : });
}
}, [authStore., authStore., authStore.]);
= () => {
authStore.();
};
= () => {
authStore.();
({ : });
};
= () => authStore.();
{
: authStore.(),
: authStore.,
: authStore.,
: authStore.,
: _appUser,
login,
logout,
getAccessToken,
};
}
Initialize Auth in _layout.tsx
import { useEffect } from "react";
import { useAuthStore } from "@/store/authStore";
export default function RootLayout() {
const initAuth = useAuthStore((s) => s.init);
useEffect(() => {
initAuth();
}, []);
}
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
import {
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>
)}
);
}
styles = .({
: {
: ,
: ,
: ,
: ,
},
: { : , : , : },
: {
: ,
: ,
: ,
: ,
: ,
},
: { : },
: { : , : , : },
});
src/services/identity/index.ts — Authenticated Axios
import 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
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>
);
}
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