Awesome-omni-skill emrah-skills

Expo React Native mobile app development with expo-iap in-app purchases, AdMob ads, i18n localization, ATT tracking transparency, optional OIDC authentication, onboarding flow, paywall, and NativeTabs navigation

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/emrah-skills" ~/.claude/skills/diegosouzapw-awesome-omni-skill-emrah-skills && rm -rf "$T"
manifest: skills/development/emrah-skills/SKILL.md
source content

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 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)

  • Use
    NativeTabs
    from
    expo-router/unstable-native-tabs
    for tab navigation - NEVER use
    @react-navigation/bottom-tabs
    or
    Tabs
    from 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-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:

FormatTriggerCooldownPremium Hidden
App OpenApp foreground (after first launch)4 hours
BannerTab bar, always visibleNone
NativeIn-feed, every 5 items in FlatListNone
InterstitialAfter key user action3 minutes / max 3/day
RewardedUser-initiated, for a benefitUser-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
    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(); // 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
:

FormatConstantAdMob 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 })); // 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:

  1. If using
    (tabs)
    folder, DELETE
    src/app/index.tsx
    to avoid route conflicts:
rm src/app/index.tsx
  1. 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/
:

ScreenFlow 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:

FilePurpose
.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
  1. install --fix
    fixes dependency version mismatches
  2. prebuild --clean
    recreates ios and android folders

Do NOT skip these steps.


Project Creation

When user asks to create an app, you MUST:

  1. FIRST ask for the bundle ID (e.g., "What is the bundle ID? Example: com.company.appname")
  2. SECOND ask: "Does the app require user login/authentication (OIDC)?"
  3. Create the project in the CURRENT directory using:
bunx create-expo -t default@next app-name
  1. Update
    app.json
    with the bundle ID:
{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.company.appname"
    },
    "android": {
      "package": "com.company.appname"
    }
  }
}
  1. Then cd into the project and start implementing all required screens
  2. 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

PurposeSF SymbolMaterial Icon
Homehouse.fillhome
Explorecompass.fillexplore
Settingsgearsettings
Profileperson.fillperson
Searchmagnifyingglasssearch
Favoritesheart.fillfavorite
Notificationsbell.fillnotifications

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";

// 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:

  • 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();

// 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>
    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";

// 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
    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:

  1. Language - Change app language
  2. Theme - Light/Dark/System
  3. Notifications - Enable/disable notifications
  4. Remove Ads - Navigate to paywall (hidden if already premium)
  5. 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

  1. iOS permissions are defined in
    app.json
  2. Android permissions are defined in
    app.json
  3. Enable new architecture via
    newArchEnabled: true
  4. 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

FeatureDetail
PKCEAuthorization Code Flow with Proof Key for Code Exchange
SecureStoreTokens stored in iOS Keychain / Android Keystore
Ephemeral SessionWebBrowser doesn't share cookies; every login is fresh
Auto Token RefreshToken renewed 30s before expiry automatically
Token CleanupOn 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

CommandDescription
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:
    - back
    simulates hardware back button
  • iOS simulator:
    maestro --device booted test .maestro/
  • Use
    runFlow
    to 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 --clean
recreates ios and android folders. Run it after modifying native modules or app.json.