emrah-skills
npx skills add https://github.com/emrahyurttutan/skills --skill emrah-skills
Agent 安装分布
Skill 文档
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)
-
src/app/att-permission.tsx– App Tracking Transparency permission screen (iOS only, shown BEFORE onboarding) -
src/app/onboarding.tsx– Swipe-based onboarding with fullscreen background video and gradient overlay -
src/app/paywall.tsx– expo-iap paywall screen (shown after onboarding) -
src/app/settings.tsx– Settings screen with language, theme, notifications, and reset onboarding options
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
assets/onboarding.mp4(adjust therequirepath to match the actual file)SafeAreaViewis fromreact-native-safe-area-context, NOTreact-native- Slide icons use
@expo/vector-iconsMaterialIconsâ 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., plusonboarding.skip,onboarding.next,onboarding.getStarted
Required Navigation (ALWAYS USE)
- Use
NativeTabsfromexpo-router/unstable-native-tabsfor tab navigation – NEVER use@react-navigation/bottom-tabsorTabsfrom expo-router
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:
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-notificationsi18next+react-i18next+expo-localizationreact-native-reanimatedexpo-video+expo-audioexpo-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";
// ââ 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
localStorageunderads_app_open_last_shown isFirstLaunchRefensures the ad never fires on the initial cold open- After
AdsProvidermounts, 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(); // 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 | 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 usersAdsProvidermust be nested insidePurchasesProvider
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/installinstead - â lineHeight style – Use padding/margin instead
- â
Tabsfrom expo-router – UseNativeTabsinstead - â
@react-navigation/bottom-tabs– UseNativeTabsinstead - â
expo-av– Useexpo-videofor video,expo-audiofor audio instead - â
expo-ads-admob– Usereact-native-google-mobile-adsinstead - â Any other ads library – ONLY use
react-native-google-mobile-ads - â Reanimated hooks inside callbacks – Call at component top level
- â
SafeAreaViewfromreact-native– Useimport { 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 })); // 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
(tabs)folder, DELETEsrc/app/index.tsxto avoid route conflicts:
rm src/app/index.tsx
- Check and remove
lineHeightfrom 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 --fixfixes dependency version mismatchesprebuild --cleanrecreates 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)?”
- 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
app.jsonwith 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.
- Example usage
import "expo-sqlite/localStorage/install";
globalThis.localStorage.setItem("key", "value");
console.log(globalThis.localStorage.getItem("key")); // 'value'
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" 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
useIAPhook 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 purchasedrainPendingTransactions()runs on startup to acknowledge stuck transactions- Use
getAvailablePurchases()for restore purchases flow - Always call
finishTransactionafter 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";
// 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:
drainPendingTransactionsacknowledges unfinished transactions on startup (prevents stuck purchases)premiumExpiryDateis iOS only (expirationDateIOS); Android doesn’t expose this fieldpremiumProductIdlets you know which plan (monthly/yearly) is active- Replace
SUBSCRIPTION_SKUSwith the app’s actual App Store / Play Store product IDs
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>âtrueif user earned the reward - All ads hidden for premium users via
shouldShowAds = !isPremium - Always use
TestIds.*in__DEV__to avoid policy violations AdsProvidermust be nested insidePurchasesProviderin_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
requestTrackingPermissionsAsyncfromexpo-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.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
// 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
SKUSwith the app’s actual App Store / Play Store product IDs- Replace
TERMS_URLandPRIVACY_URLwith actual links- Default selected plan is yearly â adjust
FEATURESarray per appdisplayPricefromsubscriptionsshows 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(); // 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)
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;
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
import { 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
import { 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
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>
)}
</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
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
// 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
bash. Download first, inspect, then execute.
# 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 |
|---|---|
launchApp: clearState: true |
Fresh launch, clears all data |
tapOn: "Text" |
Tap by visible text |
tapOn: {id: "testID"} |
Tap by testID prop |
assertVisible: "Text" |
Assert element visible |
assertNotVisible: "Text" |
Assert element NOT visible |
inputText: "value" |
Type into focused input |
swipe: {direction: LEFT} |
Swipe gesture |
back |
Android back button |
takeScreenshot: name |
Capture screenshot |
runFlow: path/to/flow.yaml |
Reuse another flow |
optional: true |
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
01_att_permission.yamlâ iOS only, skip on Android builds- System dialogs use
optional: true(vary by OS/device) - Android:
- backsimulates hardware back button - iOS simulator:
maestro --device booted test .maestro/ - Use
runFlowto chain â no duplicate setup steps
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
name: 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
name: 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
-
maestro test .maestro/â all flows pass on iOS and Android - 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:
prebuild --cleanrecreates ios and android folders. Run it after modifying native modules or app.json.