emrah-skills

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

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "emrah-skills" with this command: npx skills add emrahyurttutan/skills/emrahyurttutan-skills-emrah-skills

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

{
  /* QueryClientProvider sadece data source (swagger/rest/supabase) seçilmişse eklenir */
}
<QueryClientProvider client={queryClient}>
  <GestureHandlerRootView style={{ flex: 1 }}>
    <ThemeProvider>
      <OnboardingProvider>
        <PurchasesProvider>
          <AdsProvider>
            <NavigationThemeProvider
              value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
            >
              <Stack />
            </NavigationThemeProvider>
          </AdsProvider>
        </PurchasesProvider>
      </OnboardingProvider>
    </ThemeProvider>
  </GestureHandlerRootView>
</QueryClientProvider>;

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 expo-task-manager expo-background-fetch 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

Note: expo-task-manager and expo-background-fetch are only needed when the app has local notifications. Skip them if the user answered NO to the notifications question.

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 (local scheduled notifications)
  • expo-task-manager (background task definitions — if notifications enabled)
  • expo-background-fetch (periodic notification refresh — if notifications enabled)
  • i18next + react-i18next + expo-localization
  • react-native-reanimated
  • expo-video + expo-audio
  • expo-sqlite (for localStorage)
  • expo-linear-gradient (for gradient overlays)
# Data fetching (if data source: swagger / rest / supabase)
bun add axios @tanstack/react-query

# Supabase (if data source: supabase)
bun add @supabase/supabase-js

Note: axios and @tanstack/react-query are only needed when the app has a data source (swagger, rest, or supabase). Skip them if the user chose static or none. If auth (OIDC) is also enabled, @tanstack/react-query is shared — do NOT install it twice.

Data fetching libraries:

  • axios (HTTP client — if data source: swagger/rest/supabase)
  • @tanstack/react-query (server state management — if data source: swagger/rest/supabase)
  • @supabase/supabase-js (Supabase client — if data source: supabase)

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
BannerAD_UNITS.bannerApps → Ad units → Banner
InterstitialAD_UNITS.interstitialApps → Ad units → Interstitial
RewardedAD_UNITS.rewardedApps → Ad units → Rewarded
App OpenAD_UNITS.appOpenApps → Ad units → App open
NativeAD_UNITS.nativeApps → 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.ymlAndroid emulator E2E (ubuntu)
.github/workflows/maestro-ios.ymliOS 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. THIRD ask: "Does the app need Firebase Analytics + Push Notifications?"

  4. FOURTH ask: "Does the app need iOS/Android home screen widgets?"

    • If YES → follow the Widgets section after project setup
    • If NO → skip widgets entirely
  5. FIFTH ask: "Does the app need local push notifications?"

    • If NO → skip notifications entirely
    • If YES → go to question 6
  6. SIXTH ask: "How should notifications work?"

    OptionDescriptionExample Apps
    dailyOnce a day at a fixed timeReminders, daily summaries, motivation
    scheduledMultiple times per day at data-driven timesPrayer times, stock alerts, calendar events
    • daily → implement scheduleDailyNotification + useDailyNotification hook
    • scheduled → implement scheduleNotificationsForDays + data adapter + useScheduledNotifications hook
    • Both → implement both; user picks active mode from settings
    • Follow the Notifications section after project setup
  7. SEVENTH ask: "Does the app have a data source / backend?"

    OptionDescriptionWhat gets created
    swaggerSwagger / OpenAPI spec URL availableapi-client.ts + QueryClientProvider + empty services/api/ — Copilot reads the spec and writes services & types during development
    restREST API base URL, no specapi-client.ts + QueryClientProvider + empty services/api/ — services written manually
    supabaseSupabase projectsupabase.ts client + QueryClientProvider — Supabase URL & anon key taken from env
    staticNo backend, data lives in constants/No API client, no React Query — data files in src/constants/
    noneSkip entirelyNothing extra installed
    • If swagger → also ask: "What is the Swagger/OpenAPI spec URL?"
    • If rest → also ask: "What is the API base URL?"
    • If supabase → also ask: "What is the Supabase URL and anon key?"
    • Follow the Data Source section after project setup
  8. 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 (optional): expo-notifications + expo-task-manager + expo-background-fetch
  • Animations: react-native-reanimated
  • Storage: localStorage via expo-sqlite polyfill
  • Authentication (optional): OIDC via expo-auth-session + expo-secure-store + zustand
  • Analytics & Push (optional): Firebase via @react-native-firebase/app + analytics + messaging
  • Widgets (optional): iOS via expo-widgets (@expo/ui) + Android via custom config plugin + Kotlin AppWidgetProvider
  • Data Fetching (optional): axios + @tanstack/react-query
  • Backend (optional): Supabase (@supabase/supabase-js)

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      # (if notifications enabled)
│   │   └── use-color-scheme.ts
│   ├── lib/
│   │   ├── notifications.ts          # (if notifications enabled)
│   │   ├── background-tasks.ts       # (if notifications enabled)
│   │   ├── purchases.ts
│   │   ├── ads.ts
│   │   ├── api-client.ts             # (if data source: swagger/rest)
│   │   ├── supabase.ts               # (if data source: supabase)
│   │   ├── query-client.ts           # (if data source: swagger/rest/supabase)
│   │   ├── analytics.ts              # (if Firebase enabled)
│   │   ├── messaging.ts              # (if Firebase enabled)
│   │   └── i18n.ts
│   ├── services/
│   │   ├── api/                      # (if data source: swagger/rest/supabase)
│   │   │   └── [resource].ts
│   │   └── identity/                 # (if auth enabled)
│   │       ├── 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
├── widgets/                          # (if widgets enabled)
│   └── MyWidget.tsx                  #   iOS widget component (expo-widgets)
├── modules/                          # (if widgets enabled)
│   └── widget-data/                  #   Android-only native module
│       ├── package.json
│       ├── expo-module.config.json
│       ├── index.ts
│       └── android/
│           ├── build.gradle
│           └── src/main/java/…/WidgetDataModule.kt
├── plugins/                          # (if Firebase or widgets enabled)
│   ├── withFirebaseNotificationColorFix.js
│   ├── withFirebasePodfileFix.js
│   └── with-android-widget.js        # (if widgets enabled)
├── plugin-templates/                 # (if widgets enabled)
│   └── android-widget/
│       ├── res/layout/
│       ├── res/xml/
│       ├── res/drawable/
│       └── src/…/widget/*.kt
├── assets/
│   └── images/
├── ios/
├── android/
├── google-services.json              # (if Firebase enabled, Android — DO NOT COMMIT)
├── GoogleService-Info.plist          # (if Firebase enabled, iOS — DO NOT COMMIT)
├── firebase.json                     # (if Firebase enabled)
├── 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 = [
  Platform.OS === "ios" ? "com.company.appname.monthly" : "abonelik",
  Platform.OS === "ios" ? "com.company.appname.yearly" : "abonelik",
];

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

Only implement this section if the user answered YES to "Does the app need local push notifications?"

iOS 64 Notification Limit

iOS allows at most 64 scheduled notifications at once. Pick the right strategy:

ModeCalculationiOS Limit
daily (1/day)1 × 64 = 64 days✅ Schedule up to 64 days at once
scheduled (N/day)N × days ≤ 64days = floor(64 / N) — background fetch slides the window

Examples:

  • 5 events/day → floor(64/5) = 12 days (60 notifications)
  • 3 events/day → floor(64/3) = 21 days (63 notifications)
  • 1 event/day → 64 days at once, background fetch not required

app.json Configuration (REQUIRED)

{
  "expo": {
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#6C63FF",
          "androidMode": "default",
          "androidCollapsedTitle": "Bildirimler"
        }
      ]
    ],
    "android": {
      "permissions": ["RECEIVE_BOOT_COMPLETED"]
    },
    "ios": {
      "infoPlist": {
        "UIBackgroundModes": ["fetch", "background-processing"]
      }
    }
  }
}

androidCollapsedTitle and notification content strings should be localized per app.

src/lib/notifications.ts — Full Implementation

import * as Notifications from "expo-notifications";
import "expo-sqlite/localStorage/install";

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

export async function requestNotificationPermissions(): Promise<boolean> {
  const { status: existing } = await Notifications.getPermissionsAsync();
  if (existing === "granted") return true;
  const { status } = await Notifications.requestPermissionsAsync();
  return status === "granted";
}

export async function cancelAllNotifications(): Promise<void> {
  await Notifications.cancelAllScheduledNotificationsAsync();
}

export async function getScheduledNotificationCount(): Promise<number> {
  const list = await Notifications.getAllScheduledNotificationsAsync();
  return list.length;
}

// ── MOD A: daily ─────────────────────────────────────────────
/**
 * Schedule one notification per day at a fixed time.
 * iOS: up to 64 days at once (1 × 64 = 64, under the limit).
 * Android: pass days=90 for longer coverage.
 *
 * Example:
 *   scheduleDailyNotification(9, 0, "Daily Reminder", "Check today's goal!")
 */
export async function scheduleDailyNotification(
  hour: number,
  minute: number,
  title: string,
  body: string,
  days: number = 64,
): Promise<void> {
  await cancelAllNotifications();

  const now = new Date();
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());

  for (let dayOffset = 0; dayOffset < days; dayOffset++) {
    const targetDate = new Date(today);
    targetDate.setDate(today.getDate() + dayOffset);
    targetDate.setHours(hour, minute, 0, 0);
    if (targetDate <= now) continue;

    await Notifications.scheduleNotificationAsync({
      content: { title, body, sound: true },
      trigger: {
        type: Notifications.SchedulableTriggerInputTypes.DATE,
        date: targetDate,
      },
    });
  }

  // Cache for background task refresh
  globalThis.localStorage.setItem("notification_mode", "daily");
  globalThis.localStorage.setItem(
    "notification_daily_config",
    JSON.stringify({ hour, minute, title, body, days }),
  );
}

// ── MOD B: scheduled ─────────────────────────────────────────
export interface NotificationEvent {
  time: string; // "HH:mm"
  title: string;
  body: string;
}

export interface DayEvents {
  date: string; // "YYYY-MM-DD"
  times: NotificationEvent[];
}

/**
 * Schedule multiple notifications per day from dynamic data.
 * iOS limit: floor(64 / eventsPerDay) days — background fetch slides the window.
 * Example: 5 events/day → 12 days (60 notifications).
 *
 * events shape:
 * [
 *   {
 *     date: "2026-03-04",
 *     times: [
 *       { time: "06:12", title: "Event A", body: "Event A started." },
 *       { time: "13:05", title: "Event B", body: "Event B started." },
 *     ]
 *   }
 * ]
 */
export async function scheduleNotificationsForDays(
  events: DayEvents[],
  days: number = 12,
): Promise<void> {
  await cancelAllNotifications();

  const now = new Date();
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());

  for (let dayOffset = 0; dayOffset < days; dayOffset++) {
    const targetDate = new Date(today);
    targetDate.setDate(today.getDate() + dayOffset);

    const isoDate = targetDate.toISOString().slice(0, 10);
    const dayData = events.find((e) => e.date === isoDate);
    if (!dayData) continue;

    for (const { time, title, body } of dayData.times) {
      const [hours, minutes] = time.split(":").map(Number);
      const notifTime = new Date(targetDate);
      notifTime.setHours(hours, minutes, 0, 0);
      if (notifTime <= now) continue;

      await Notifications.scheduleNotificationAsync({
        content: { title, body, sound: true },
        trigger: {
          type: Notifications.SchedulableTriggerInputTypes.DATE,
          date: notifTime,
        },
      });
    }
  }

  // Cache for background task refresh
  globalThis.localStorage.setItem("notification_mode", "scheduled");
  globalThis.localStorage.setItem(
    "notification_events_cache",
    JSON.stringify(events),
  );
  globalThis.localStorage.setItem("notification_events_days", String(days));
}

Data Adapter — Converting App Data to DayEvents[]

Write a project-specific adapter to convert your API response to DayEvents[]:

import type { DayEvents } from "@/lib/notifications";

// Example: prayer times API response → DayEvents[]
export function prayerTimesToEvents(apiData: any[]): DayEvents[] {
  return apiData.map((day) => ({
    date: day.tarih, // "YYYY-MM-DD"
    times: [
      {
        time: day.Sabah,
        title: "Sabah Vakti",
        body: "Sabah namazı vakti girdi.",
      },
      { time: day.Ogle, title: "Öğle Vakti", body: "Öğle namazı vakti girdi." },
      {
        time: day.Ikindi,
        title: "İkindi Vakti",
        body: "İkindi namazı vakti girdi.",
      },
      {
        time: day.Aksam,
        title: "Akşam Vakti",
        body: "Akşam namazı vakti girdi.",
      },
      {
        time: day.Yatsi,
        title: "Yatsı Vakti",
        body: "Yatsı namazı vakti girdi.",
      },
    ],
  }));
}

// Example: stock alarms → DayEvents[]
export function stockAlarmsToEvents(alarms: any[]): DayEvents[] {
  const grouped: Record<string, DayEvents> = {};
  for (const alarm of alarms) {
    if (!grouped[alarm.date])
      grouped[alarm.date] = { date: alarm.date, times: [] };
    grouped[alarm.date].times.push({
      time: alarm.time,
      title: alarm.symbol,
      body: `${alarm.symbol} reached target price: ${alarm.price}`,
    });
  }
  return Object.values(grouped);
}

src/lib/background-tasks.ts — Background Refresh (NEW FILE)

import * as BackgroundFetch from "expo-background-fetch";
import * as TaskManager from "expo-task-manager";
import {
  scheduleDailyNotification,
  scheduleNotificationsForDays,
} from "@/lib/notifications";
import "expo-sqlite/localStorage/install";

export const BACKGROUND_FETCH_TASK = "notification-refresh";

// IMPORTANT: defineTask must run at module level (outside React tree).
// Add this to _layout.tsx as a side-effect import:
//   import "@/lib/background-tasks";
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
  try {
    const enabled = globalThis.localStorage.getItem("notifications_enabled");
    if (enabled !== "true") return BackgroundFetch.BackgroundFetchResult.NoData;

    const mode = globalThis.localStorage.getItem("notification_mode");

    if (mode === "daily") {
      const raw = globalThis.localStorage.getItem("notification_daily_config");
      if (!raw) return BackgroundFetch.BackgroundFetchResult.NoData;
      const { hour, minute, title, body, days } = JSON.parse(raw);
      await scheduleDailyNotification(hour, minute, title, body, days);
      return BackgroundFetch.BackgroundFetchResult.NewData;
    }

    if (mode === "scheduled") {
      const cached = globalThis.localStorage.getItem(
        "notification_events_cache",
      );
      if (!cached) return BackgroundFetch.BackgroundFetchResult.NoData;
      const events = JSON.parse(cached);
      const days = parseInt(
        globalThis.localStorage.getItem("notification_events_days") ?? "12",
        10,
      );
      await scheduleNotificationsForDays(events, days);
      return BackgroundFetch.BackgroundFetchResult.NewData;
    }

    return BackgroundFetch.BackgroundFetchResult.NoData;
  } catch {
    return BackgroundFetch.BackgroundFetchResult.Failed;
  }
});

/**
 * Register the background fetch task.
 * Call once in _layout.tsx useEffect on app startup.
 * iOS minimum: 15 minutes (system may optimize to longer intervals).
 * Android: more flexible scheduling.
 */
export async function registerBackgroundFetchAsync(): Promise<void> {
  try {
    const status = await BackgroundFetch.getStatusAsync();
    if (
      status === BackgroundFetch.BackgroundFetchStatus.Restricted ||
      status === BackgroundFetch.BackgroundFetchStatus.Denied
    ) {
      return;
    }
    const isRegistered = await TaskManager.isTaskRegisteredAsync(
      BACKGROUND_FETCH_TASK,
    );
    if (isRegistered) return;

    await BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
      minimumInterval: 15 * 60, // 15 minutes in seconds
      stopOnTerminate: false, // Android: keep running after app close
      startOnBoot: true, // Android: restart task on device reboot
    });
  } catch (err) {
    console.warn("[BackgroundFetch] Registration failed:", err);
  }
}

src/hooks/use-notifications.ts — Hooks

import { useEffect, useState } from "react";
import {
  cancelAllNotifications,
  requestNotificationPermissions,
  scheduleDailyNotification,
  scheduleNotificationsForDays,
  type DayEvents,
} from "@/lib/notifications";
import "expo-sqlite/localStorage/install";

export interface DailyNotificationConfig {
  hour: number;
  minute: number;
  title: string;
  body: string;
  days?: number;
}

// MOD A: once a day, fixed time
export function useDailyNotification(config: DailyNotificationConfig) {
  const [enabled, setEnabled] = useState(
    () => globalThis.localStorage.getItem("notifications_enabled") === "true",
  );

  useEffect(() => {
    if (!enabled) return;
    requestNotificationPermissions().then((granted) => {
      if (granted)
        scheduleDailyNotification(
          config.hour,
          config.minute,
          config.title,
          config.body,
          config.days,
        );
    });
  }, [enabled]);

  const toggle = async (value: boolean) => {
    if (value) {
      const granted = await requestNotificationPermissions();
      if (!granted) return;
      await scheduleDailyNotification(
        config.hour,
        config.minute,
        config.title,
        config.body,
        config.days,
      );
    } else {
      await cancelAllNotifications();
    }
    setEnabled(value);
    globalThis.localStorage.setItem(
      "notifications_enabled",
      value ? "true" : "false",
    );
  };

  return { notificationsEnabled: enabled, toggle };
}

// MOD B: multiple times per day, dynamic data
export function useScheduledNotifications(events?: DayEvents[], days?: number) {
  const [enabled, setEnabled] = useState(
    () => globalThis.localStorage.getItem("notifications_enabled") === "true",
  );

  useEffect(() => {
    if (!enabled || !events?.length) return;
    requestNotificationPermissions().then((granted) => {
      if (granted) scheduleNotificationsForDays(events, days);
    });
  }, [enabled, events]);

  const toggle = async (value: boolean) => {
    if (value) {
      const granted = await requestNotificationPermissions();
      if (!granted) return;
      if (events?.length) await scheduleNotificationsForDays(events, days);
    } else {
      await cancelAllNotifications();
    }
    setEnabled(value);
    globalThis.localStorage.setItem(
      "notifications_enabled",
      value ? "true" : "false",
    );
  };

  return { notificationsEnabled: enabled, toggle };
}

_layout.tsx Integration

import "@/lib/background-tasks"; // side-effect: registers TaskManager task at module load

import { registerBackgroundFetchAsync } from "@/lib/background-tasks";
import { useEffect } from "react";

export default function RootLayout() {
  useEffect(() => {
    registerBackgroundFetchAsync();
  }, []);

  // ... rest of layout
}

Settings Screen Integration

MOD A — daily:

import { useDailyNotification } from "@/hooks/use-notifications";

const { notificationsEnabled, toggle } = useDailyNotification({
  hour: 9,
  minute: 0,
  title: "Daily Reminder",
  body: "Check today's goal!",
});

<Switch value={notificationsEnabled} onValueChange={toggle} />;

MOD B — scheduled (dynamic data):

import { useScheduledNotifications } from "@/hooks/use-notifications";

const events = myDataAdapter(fetchedData); // returns DayEvents[]
const { notificationsEnabled, toggle } = useScheduledNotifications(events, 12);

<Switch value={notificationsEnabled} onValueChange={toggle} />;

localStorage Cache Keys

KeyValueMode
notifications_enabled"true" | "false"Both
notification_mode"daily" | "scheduled"Both
notification_daily_configJSON.stringify({hour, minute, title, body, days})daily only
notification_events_cacheJSON.stringify(DayEvents[])scheduled only
notification_events_days"12" | "21" etc.scheduled only

Important Notes

TopicDetail
iOS 64 limit — daily1 × 64 = 64 days — schedule all at once, background fetch not required
iOS 64 limit — scheduledfloor(64 / N) days — N = events per day; background fetch slides the window forward
Background fetch intervaliOS minimum 15 min (system may optimize to longer intervals); Android more flexible
RECEIVE_BOOT_COMPLETEDAndroid loses scheduled notifications on device reboot; startOnBoot: true keeps the task alive
Import orderbackground-tasks.ts must be imported before any React component renders → top of _layout.tsx
UIBackgroundModes: fetchRequired in app.json infoPlist for iOS background fetch
Expo GoBackground fetch does NOT work in Expo Go — use eas build --profile development
DebuggetScheduledNotificationCount()daily: ≤ 64, scheduled (5 events/day): ≤ 60
Data adapterWrite a project-specific adapter to convert API data to DayEvents[] (examples above)

Data Source (Optional)

Only implement this section if the user chose swagger, rest, or supabase as the data source. Skip entirely for static or none.

Environment Variables

Add to .env:

# REST / Swagger
EXPO_PUBLIC_API_BASE_URL=https://api.example.com

# Supabase
EXPO_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbxxxxxxxxx

src/lib/query-client.ts — React Query Setup

import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      retry: 2,
      refetchOnWindowFocus: false, // not relevant in mobile
    },
    mutations: {
      retry: 1,
    },
  },
});

src/lib/api-client.ts — Axios Instance (swagger / rest)

import axios from "axios";

const apiClient = axios.create({
  baseURL: process.env.EXPO_PUBLIC_API_BASE_URL,
  timeout: 15_000,
  headers: { "Content-Type": "application/json" },
});

// ── Request interceptor: auth token ─────────────────────────
// Only added when OIDC auth is enabled.
// Import is conditional — if auth is NOT enabled, remove this block.
import { useAuthStore } from "@/store/authStore";

apiClient.interceptors.request.use(async (config) => {
  const token = await useAuthStore.getState().getValidAccessToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// ── Response interceptor: error handling ─────────────────────
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (__DEV__) {
      console.warn(
        "[API]",
        error.config?.method?.toUpperCase(),
        error.config?.url,
        error.response?.status,
      );
    }

    // 401 — token expired and refresh failed
    if (error.response?.status === 401) {
      // If auth enabled: logout user
      // useAuthStore.getState().logout();
    }

    return Promise.reject(error);
  },
);

// ── Dev logging ─────────────────────────────────────────────
if (__DEV__) {
  apiClient.interceptors.request.use((config) => {
    console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`);
    return config;
  });
}

export default apiClient;

Auth interceptor: The request interceptor that adds Bearer token is only included when OIDC auth is enabled. If the app has no auth, remove the useAuthStore import and request interceptor block.

src/lib/supabase.ts — Supabase Client (supabase only)

import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

Service Template — src/services/api/[resource].ts

Create one file per resource/entity. This is the pattern Copilot follows:

import apiClient from "@/lib/api-client";
// For Supabase: import { supabase } from "@/lib/supabase";

// ── Types ───────────────────────────────────────────────────
export interface Resource {
  id: string;
  name: string;
  // ... add fields from API response
}

export interface CreateResourceInput {
  name: string;
  // ... add fields for creation
}

// ── API Functions ───────────────────────────────────────────
export async function getResources(): Promise<Resource[]> {
  const { data } = await apiClient.get<Resource[]>("/resources");
  return data;
}

export async function getResourceById(id: string): Promise<Resource> {
  const { data } = await apiClient.get<Resource>(`/resources/${id}`);
  return data;
}

export async function createResource(
  input: CreateResourceInput,
): Promise<Resource> {
  const { data } = await apiClient.post<Resource>("/resources", input);
  return data;
}

export async function updateResource(
  id: string,
  input: Partial<CreateResourceInput>,
): Promise<Resource> {
  const { data } = await apiClient.put<Resource>(`/resources/${id}`, input);
  return data;
}

export async function deleteResource(id: string): Promise<void> {
  await apiClient.delete(`/resources/${id}`);
}

Swagger: When a Swagger/OpenAPI spec URL is provided, Copilot fetches the spec, reads endpoints and schemas, then creates service files and TypeScript types based on the spec. No automatic code generation tool is used — Copilot writes the code manually based on the spec.

React Query Hook Template — src/hooks/use-[resource].ts

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
  getResources,
  getResourceById,
  createResource,
  updateResource,
  deleteResource,
  type Resource,
  type CreateResourceInput,
} from "@/services/api/resources";

const QUERY_KEY = ["resources"];

export function useResources() {
  return useQuery<Resource[]>({
    queryKey: QUERY_KEY,
    queryFn: getResources,
  });
}

export function useResource(id: string) {
  return useQuery<Resource>({
    queryKey: [...QUERY_KEY, id],
    queryFn: () => getResourceById(id),
    enabled: !!id,
  });
}

export function useCreateResource() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (input: CreateResourceInput) => createResource(input),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEY });
    },
  });
}

export function useUpdateResource() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({
      id,
      input,
    }: {
      id: string;
      input: Partial<CreateResourceInput>;
    }) => updateResource(id, input),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEY });
    },
  });
}

export function useDeleteResource() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id: string) => deleteResource(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: QUERY_KEY });
    },
  });
}

_layout.tsx Integration

import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* ... rest of providers */}
    </QueryClientProvider>
  );
}

Usage Example in Screens

import { useResources, useCreateResource } from "@/hooks/use-resources";
import { ActivityIndicator, FlatList, Text, View } from "react-native";

export default function ResourceListScreen() {
  const { data: resources, isLoading, error } = useResources();
  const createMutation = useCreateResource();

  if (isLoading) return <ActivityIndicator />;
  if (error) return <Text>Error: {error.message}</Text>;

  return (
    <FlatList
      data={resources}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text>{item.name}</Text>}
    />
  );
}

Important Notes

TopicDetail
Swagger specCopilot reads the spec URL, parses endpoints & schemas, then writes service files + types manually. No codegen tool is used
Auth interceptorOnly included when OIDC auth is enabled. Uses useAuthStore.getState().getValidAccessToken() for automatic token injection
401 handlingResponse interceptor catches 401 errors. When auth enabled, triggers logout
QueryClientProviderWraps the entire app (outermost provider). Only added when data source is swagger/rest/supabase
staleTimeDefault 5 minutes — adjust per app needs
Supabase + OIDCIf both Supabase and OIDC auth are enabled, Supabase client should use the OIDC access token. Set supabase.auth.setSession() with the OIDC token
Static dataWhen static is chosen, data lives in src/constants/ as TypeScript files. No API client, no React Query
Expo GoWorks fine — no native modules required for data fetching
@tanstack/react-queryShared between data source and auth sections — only installed once

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 { useThemeContext } from "@/context/theme-context";
import { Analytics } from "@/lib/analytics";
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 per platform
const SKUS = {
  monthly:
    Platform.OS === "ios"
      ? "com.company.appname.monthly"
      : "appname_monthly_android",
  yearly:
    Platform.OS === "ios"
      ? "com.company.appname.yearly"
      : "appname_monthly_android", // Same subscription, different base plan
};

// Android base plan IDs (used to find correct offer within subscription)
const ANDROID_BASE_PLANS = {
  monthly: "com-company-appname-monthly-android",
  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: "all-inclusive" },
  { key: "paywall.feature3", icon: "cloud-off" },
];

export default function PaywallScreen() {
  const { t } = useTranslation();
  const { isDark } = useThemeContext();
  const { refreshPremiumStatus, isPremium } = usePurchases();
  const c = isDark ? darkColors : lightColors;

  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 });
        const sub = subscriptions?.find((s) => s.id === purchase.productId);
        Analytics.logPurchaseSuccess(purchase.productId, sub?.displayPrice);
        await refreshPremiumStatus();
        router.replace("/(tabs)");
      } catch (err) {
        console.error("Finish transaction error:", err);
      } finally {
        setPurchasing(false);
      }
    },
    onPurchaseError: (error) => {
      setPurchasing(false);
      const errorCode = (error as any)?.code;
      if (errorCode !== "E_USER_CANCELLED") {
        Analytics.logPurchaseFailed(
          selectedPlan === "monthly" ? SKUS.monthly : SKUS.yearly,
          errorCode,
        );
        Alert.alert(t("errors.generic"), t("errors.purchaseFailed"));
      }
    },
  });

  useEffect(() => {
    if (connected) {
      const skusToFetch = [SKUS.monthly, SKUS.yearly];
      fetchProducts({ skus: skusToFetch, type: "subs" });
    }
  }, [connected]);

  useEffect(() => {
    Analytics.logScreenView("PaywallScreen");
    Analytics.logPaywallView();
  }, []);

  const handleClose = () => {
    if (router.canGoBack()) {
      router.back();
    } else {
      router.replace("/(tabs)");
    }
  };

  const handleSubscribe = async () => {
    if (purchasing) return;
    setPurchasing(true);
    const sku = selectedPlan === "monthly" ? SKUS.monthly : SKUS.yearly;
    Analytics.logPurchaseInitiated(sku, selectedPlan);
    try {
      if (Platform.OS === "ios") {
        await requestPurchase({ request: { apple: { sku } }, type: "subs" });
      } else {
        // Android: Use the correct base plan's offer token
        const basePlanId =
          selectedPlan === "monthly"
            ? ANDROID_BASE_PLANS.monthly
            : ANDROID_BASE_PLANS.yearly;
        const offer = getAndroidOffer(basePlanId);

        if (!offer?.offerTokenAndroid) {
          Alert.alert(t("errors.generic"), t("errors.purchaseFailed"));
          setPurchasing(false);
          return;
        }

        await requestPurchase({
          request: {
            google: {
              skus: [sku],
              subscriptionOffers: [
                { sku, offerToken: offer.offerTokenAndroid },
              ],
            },
          },
          type: "subs",
        });
      }
    } catch {
      setPurchasing(false);
    }
  };

  const handleRestore = async () => {
    if (restoring) return;
    setRestoring(true);
    Analytics.logRestoreInitiated();
    try {
      await restorePurchases();
      await refreshPremiumStatus();
      if (isPremium) {
        Analytics.logRestoreSuccess();
        router.replace("/(tabs)");
      } else {
        Alert.alert("", t("errors.noActivePurchases"));
      }
    } catch (e) {
      Analytics.logRestoreFailed((e as any)?.code);
      Alert.alert(t("errors.generic"), t("errors.restoreFailed"));
    } finally {
      setRestoring(false);
    }
  };

  const monthlyProduct = subscriptions?.find((p) => p.id === SKUS.monthly);
  const yearlyProduct = subscriptions?.find((p) => p.id === SKUS.yearly);

  // Android: Get prices from subscriptionOffers by base plan ID
  const getAndroidOffer = (basePlanId: string) => {
    const sub = subscriptions?.[0];
    // Find the base offer (without promotional discount) for this base plan
    return sub?.subscriptionOffers?.find(
      (o: any) =>
        o.basePlanIdAndroid === basePlanId && o.type !== "promotional",
    );
  };

  const monthlyOffer =
    Platform.OS === "android"
      ? getAndroidOffer(ANDROID_BASE_PLANS.monthly)
      : null;
  const yearlyOffer =
    Platform.OS === "android"
      ? getAndroidOffer(ANDROID_BASE_PLANS.yearly)
      : null;

  // Display prices - use offer prices on Android
  const monthlyPrice =
    Platform.OS === "android"
      ? monthlyOffer?.displayPrice
      : monthlyProduct?.displayPrice;
  const yearlyPrice =
    Platform.OS === "android"
      ? yearlyOffer?.displayPrice
      : yearlyProduct?.displayPrice;

  return (
    <View style={styles.container}>
      <StatusBar barStyle={isDark ? "light-content" : "dark-content"} />
      <LinearGradient colors={c.bgGradient} style={StyleSheet.absoluteFill} />

      <SafeAreaView style={styles.safeArea}>
        {/* Top bar */}
        <View style={styles.topBar}>
          <TouchableOpacity
            testID="close-button"
            onPress={handleClose}
            style={[styles.closeButton, { backgroundColor: c.closeBg }]}
          >
            <MaterialIcons name="close" size={18} color={c.closeIcon} />
          </TouchableOpacity>
        </View>

        <ScrollView
          contentContainerStyle={styles.scroll}
          showsVerticalScrollIndicator={false}
          bounces={false}
        >
          {/* Hero icon */}
          <View style={styles.heroWrap}>
            <LinearGradient
              colors={["#6C63FF", "#5650CC"]}
              style={styles.heroGradient}
            >
              <MaterialIcons name="workspace-premium" size={40} color="#fff" />
            </LinearGradient>
            <View
              style={[styles.heroBadge, { backgroundColor: c.heroBadgeBg }]}
            >
              <MaterialIcons name="verified" size={14} color="#34D399" />
            </View>
          </View>

          <Text style={[styles.title, { color: c.text }]}>
            {t("paywall.title")}
          </Text>
          <Text style={[styles.subtitle, { color: c.muted }]}>
            {t("paywall.subtitle")}
          </Text>

          {/* Features */}
          <View
            style={[
              styles.featuresCard,
              { backgroundColor: c.cardBg, borderColor: c.cardBorder },
            ]}
          >
            {FEATURES.map(({ key, icon }, i) => (
              <View key={key}>
                <View style={styles.featureRow}>
                  <View
                    style={[
                      styles.featureIconWrap,
                      { backgroundColor: c.featureIconBg },
                    ]}
                  >
                    <MaterialIcons name={icon} size={18} color="#A78BFA" />
                  </View>
                  <Text style={[styles.featureText, { color: c.featureText }]}>
                    {t(key)}
                  </Text>
                  <MaterialIcons name="check" size={16} color="#34D399" />
                </View>
                {i < FEATURES.length - 1 && (
                  <View
                    style={[
                      styles.separator,
                      { backgroundColor: c.cardBorder },
                    ]}
                  />
                )}
              </View>
            ))}
          </View>

          {/* Plan selector */}
          <View style={styles.plansRow}>
            {/* Monthly */}
            <TouchableOpacity
              onPress={() => {
                setSelectedPlan("monthly");
                Analytics.logSubscriptionSelected("monthly", monthlyPrice);
              }}
              style={[
                styles.planCard,
                selectedPlan === "monthly"
                  ? styles.planCardSelected
                  : [
                      styles.planCardIdle,
                      {
                        borderColor: c.planIdleBorder,
                        backgroundColor: c.planIdleBg,
                      },
                    ],
              ]}
            >
              {selectedPlan === "monthly" && <View style={styles.planDot} />}
              <Text style={[styles.planLabel, { color: c.planLabel }]}>
                {t("paywall.monthly")}
              </Text>
              <Text style={[styles.planPrice, { color: c.text }]}>
                {monthlyPrice ?? 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");
                  Analytics.logSubscriptionSelected("yearly", yearlyPrice);
                }}
                style={[
                  styles.planCard,
                  selectedPlan === "yearly"
                    ? styles.planCardSelected
                    : [
                        styles.planCardIdle,
                        {
                          borderColor: c.planIdleBorder,
                          backgroundColor: c.planIdleBg,
                        },
                      ],
                ]}
              >
                {selectedPlan === "yearly" && <View style={styles.planDot} />}
                <Text style={[styles.planLabel, { color: c.planLabel }]}>
                  {t("paywall.yearly")}
                </Text>
                <Text style={[styles.planPrice, { color: c.text }]}>
                  {yearlyPrice ?? t("paywall.yearlyPrice")}
                </Text>
                <Text style={[styles.planPerWeek, { color: c.muted }]}>
                  {t("paywall.yearlyPerWeek")}
                </Text>
              </TouchableOpacity>
            </View>
          </View>
        </ScrollView>

        {/* Sticky footer */}
        <View style={[styles.footer, { borderTopColor: c.footerBorder }]}>
          <Pressable
            testID="subscribe-button"
            onPress={handleSubscribe}
            disabled={purchasing}
            style={styles.subscribeTouchable}
          >
            <LinearGradient
              colors={
                purchasing ? ["#374151", "#374151"] : ["#6C63FF", "#5650CC"]
              }
              start={{ x: 0, y: 0 }}
              end={{ x: 1, y: 0 }}
              style={styles.subscribeButton}
            >
              {purchasing ? (
                <ActivityIndicator color="#fff" />
              ) : (
                <Text style={styles.subscribeButtonText}>
                  {t("paywall.subscribe")}
                </Text>
              )}
            </LinearGradient>
          </Pressable>

          <Text style={[styles.autoRenewText, { color: c.muted }]}>
            {t("paywall.autoRenew")}
          </Text>

          <TouchableOpacity
            onPress={handleRestore}
            disabled={restoring}
            style={styles.restoreButton}
          >
            {restoring ? (
              <ActivityIndicator size="small" color={c.muted} />
            ) : (
              <Text style={[styles.restoreText, { color: c.linkText }]}>
                {t("paywall.restore")}
              </Text>
            )}
          </TouchableOpacity>

          <View style={styles.linksRow}>
            <TouchableOpacity
              onPress={() => WebBrowser.openBrowserAsync(TERMS_URL)}
            >
              <Text style={[styles.linkText, { color: c.linkText }]}>
                {t("paywall.terms")}
              </Text>
            </TouchableOpacity>
            <Text style={[styles.linkDot, { color: c.muted }]}>·</Text>
            <TouchableOpacity
              onPress={() => WebBrowser.openBrowserAsync(PRIVACY_URL)}
            >
              <Text style={[styles.linkText, { color: c.linkText }]}>
                {t("paywall.privacy")}
              </Text>
            </TouchableOpacity>
          </View>
        </View>
      </SafeAreaView>
    </View>
  );
}

const lightColors = {
  bgGradient: ["#F0EEFF", "#F9FAFB", "#EEF2FF"] as const,
  text: "#111827",
  muted: "#6B7280",
  closeBg: "rgba(0,0,0,0.07)",
  closeIcon: "#374151",
  heroBadgeBg: "#F9FAFB",
  cardBg: "rgba(0,0,0,0.04)",
  cardBorder: "rgba(0,0,0,0.08)",
  featureIconBg: "rgba(108,99,255,0.12)",
  featureText: "#374151",
  planIdleBorder: "rgba(0,0,0,0.12)",
  planIdleBg: "rgba(0,0,0,0.03)",
  planLabel: "#6B7280",
  footerBorder: "rgba(0,0,0,0.07)",
  linkText: "#6B7280",
};

const darkColors = {
  bgGradient: ["#0A0F1E", "#111827", "#0F172A"] as const,
  text: "#FFFFFF",
  muted: "rgba(255,255,255,0.4)",
  closeBg: "rgba(255,255,255,0.1)",
  closeIcon: "rgba(255,255,255,0.7)",
  heroBadgeBg: "#0F172A",
  cardBg: "rgba(255,255,255,0.05)",
  cardBorder: "rgba(255,255,255,0.08)",
  featureIconBg: "rgba(108,99,255,0.2)",
  featureText: "rgba(255,255,255,0.85)",
  planIdleBorder: "rgba(255,255,255,0.12)",
  planIdleBg: "rgba(255,255,255,0.04)",
  planLabel: "rgba(255,255,255,0.55)",
  footerBorder: "rgba(255,255,255,0.07)",
  linkText: "rgba(255,255,255,0.4)",
};

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,
    alignItems: "center",
    justifyContent: "center",
  },
  scroll: {
    paddingHorizontal: 24,
    paddingBottom: 24,
    alignItems: "center",
  },
  heroWrap: {
    marginTop: 16,
    marginBottom: 24,
    alignItems: "center",
  },
  heroGradient: {
    width: 80,
    height: 80,
    borderRadius: 24,
    alignItems: "center",
    justifyContent: "center",
  },
  heroBadge: {
    position: "absolute",
    bottom: -4,
    right: -4,
    borderRadius: 10,
    padding: 2,
  },
  title: {
    fontSize: 28,
    fontWeight: "700",
    textAlign: "center",
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 16,
    textAlign: "center",
    marginBottom: 24,
  },
  featuresCard: {
    width: "100%",
    borderWidth: 1,
    borderRadius: 14,
    marginBottom: 20,
    overflow: "hidden",
  },
  featureRow: {
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    paddingHorizontal: 16,
    paddingVertical: 12,
  },
  featureIconWrap: {
    width: 32,
    height: 32,
    borderRadius: 8,
    alignItems: "center",
    justifyContent: "center",
  },
  featureText: {
    flex: 1,
    fontSize: 14,
    fontWeight: "500",
  },
  separator: {
    height: StyleSheet.hairlineWidth,
    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: "#6C63FF",
    backgroundColor: "rgba(108,99,255,0.12)",
  },
  planCardIdle: {
    borderWidth: 1,
  },
  planDot: {
    position: "absolute",
    top: 8,
    right: 8,
    width: 8,
    height: 8,
    borderRadius: 4,
    backgroundColor: "#6C63FF",
  },
  planLabel: {
    fontSize: 11,
    fontWeight: "600",
    textTransform: "uppercase",
    letterSpacing: 1,
  },
  planPrice: {
    fontSize: 15,
    fontWeight: "700",
    textAlign: "center",
  },
  planPerWeek: {
    fontSize: 11,
    textAlign: "center",
  },
  footer: {
    borderTopWidth: StyleSheet.hairlineWidth,
    paddingHorizontal: 24,
    paddingBottom: 20,
    paddingTop: 16,
  },
  subscribeTouchable: {
    borderRadius: 14,
    overflow: "hidden",
    marginBottom: 10,
  },
  subscribeButton: {
    alignItems: "center",
    justifyContent: "center",
    paddingVertical: 16,
  },
  subscribeButtonText: {
    color: "#FFFFFF",
    fontSize: 18,
    fontWeight: "700",
  },
  autoRenewText: {
    fontSize: 11,
    textAlign: "center",
    marginBottom: 6,
  },
  restoreButton: {
    alignItems: "center",
    paddingVertical: 8,
    marginBottom: 8,
  },
  restoreText: {
    fontSize: 13,
    textDecorationLine: "underline",
  },
  linksRow: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
    gap: 6,
  },
  linkText: {
    fontSize: 13,
  },
  linkDot: {
    fontSize: 14,
  },
});

Notes:

  • Replace SKUS with the app's actual App Store / Play Store product IDs (iOS uses separate SKUs per plan, Android may use a single subscription with multiple base plans)
  • Replace ANDROID_BASE_PLANS with the actual base plan IDs from Google Play Console
  • Replace TERMS_URL and PRIVACY_URL with actual links
  • Default selected plan is yearly — adjust FEATURES array per app
  • On Android, prices are extracted from subscriptionOffers using basePlanIdAndroid since a single subscription can have multiple base plans (monthly/yearly)
  • On iOS, displayPrice comes directly from the subscription product
  • Analytics integration tracks: screen views, paywall views, subscription selection, purchase initiated/success/failed, restore initiated/success/failed
  • Theme-aware: uses useThemeContext() for light/dark mode with separate color palettes
  • 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.generic, 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

{
  /* QueryClientProvider sadece data source (swagger/rest/supabase) seçilmişse eklenir */
}
<QueryClientProvider client={queryClient}>
  <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>
</QueryClientProvider>;

Note: QueryClientProvider is only added when a data source (swagger/rest/supabase) is selected. If static or none was chosen, omit it.

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
bun add zustand
# @tanstack/react-query is installed via the Data Source section if a data source is enabled.
# If no data source but auth is needed, install it here:
# bun add @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: Never execute remote scripts directly. Use a package manager instead.

# Install via Homebrew (recommended — no remote script execution)
brew install maestro
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: trueFresh 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
backAndroid back button
takeScreenshot: nameCapture screenshot
runFlow: path/to/flow.yamlReuse another flow
optional: trueSkip 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.yamliOS 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]

permissions:
  contents: read

jobs:
  e2e-android:
    runs-on: ubuntu-latest
    timeout-minutes: 60

    steps:
      - name: Checkout
        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

      - name: Setup Java 17
        uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
        with:
          java-version: "17"
          distribution: "temurin"

      - name: Setup Node
        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm install

      - name: Install Maestro
        env:
          MAESTRO_VERSION: "1.40.0"
        run: |
          # Install pinned version from GitHub releases — no remote script execution
          curl -fsSL "https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip" \
            -o maestro.zip
          unzip maestro.zip -d $HOME
          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@b530d96654c385303d652368551fb075bc2f0b6b # 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: maestro-android-results
          path: |
            test-results.xml
            ~/.maestro/tests/

      - name: Upload screenshots
        if: always()
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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]

permissions:
  contents: read

jobs:
  e2e-ios:
    runs-on: macos-15
    timeout-minutes: 90

    steps:
      - name: Checkout
        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

      - name: Setup Java 17 (required by Maestro)
        uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
        with:
          java-version: "17"
          distribution: "temurin"

      - name: Setup Node
        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm install

      - name: Install Maestro
        run: brew install maestro # macOS runner — no remote script execution

      - 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: maestro-ios-results
          path: |
            test-results.xml
            ~/.maestro/tests/

      - name: Upload screenshots
        if: always()
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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


Firebase (Analytics + Push — Optional)

Only implement this section if the user answered YES to "Does the app need Firebase Analytics + Push Notifications?"

Install Firebase Libraries

# Core (required first), then Analytics and Messaging
npx expo install @react-native-firebase/app @react-native-firebase/analytics @react-native-firebase/messaging

# Build properties (skip if already installed for AdMob)
npx expo install expo-build-properties

Credential Files (REQUIRED)

Obtain from Firebase Console → Project Settings → Your App:

FilePlatformSource
google-services.jsonAndroidFirebase Console → Android app → Download
GoogleService-Info.plistiOSFirebase Console → iOS app → Download

Place both files at the project root. Add them to .gitignore:

# Firebase credentials — never commit
google-services.json
GoogleService-Info.plist

Custom Expo Config Plugins

Create a plugins/ folder at the project root with these two files:

plugins/withFirebaseNotificationColorFix.js

const { withAndroidManifest } = require("expo/config-plugins");

/**
 * Fixes manifest merger conflict:
 * react-native-firebase/messaging declares default_notification_color as @color/white
 * but our app declares it as @color/notification_icon_color.
 * Adding tools:replace="android:resource" lets our value win.
 */
const withFirebaseNotificationColorFix = (config) => {
  return withAndroidManifest(config, (config) => {
    const manifest = config.modResults;

    // Ensure xmlns:tools is declared on the root manifest element
    if (!manifest.manifest.$("xmlns:tools")) {
      manifest.manifest.$("xmlns:tools") = "http://schemas.android.com/tools";
    }

    const app = manifest.manifest.application?.[0];
    if (!app) return config;

    const metaDataArray = app["meta-data"] || [];
    const target = metaDataArray.find(
      (item) =>
        item.$?.["android:name"] ===
        "com.google.firebase.messaging.default_notification_color",
    );

    if (target) {
      target.$("tools:replace") = "android:resource";
    }

    return config;
  });
};

module.exports = withFirebaseNotificationColorFix;

plugins/withFirebasePodfileFix.js

const { withDangerousMod } = require("expo/config-plugins");
const path = require("path");
const fs = require("fs");

/**
 * Fixes pod install error:
 *   "The Swift pod `FirebaseCoreInternal` depends upon `GoogleUtilities`,
 *    which does not define modules."
 *
 * Solution: inject `use_modular_headers!` before the target block.
 * This tells CocoaPods to generate module maps for all pods, allowing
 * GoogleUtilities to be imported from Swift without modular headers.
 */
const withFirebasePodfileFix = (config) => {
  return withDangerousMod(config, [
    "ios",
    (config) => {
      const podfilePath = path.join(
        config.modRequest.platformProjectRoot,
        "Podfile",
      );
      let contents = fs.readFileSync(podfilePath, "utf-8");

      const flag = "use_modular_headers!";

      // Only add if not already present
      if (!contents.includes(flag)) {
        // Insert before the first `target '...' do` line
        contents = contents.replace(
          /^(target\s+['"].+['"]\s+do)/m,
          `${flag}\n\n$1`,
        );
        fs.writeFileSync(podfilePath, contents, "utf-8");
      }

      return config;
    },
  ]);
};

module.exports = withFirebasePodfileFix;

app.json Configuration (REQUIRED)

Merge the following into your existing app.json:

{
  "expo": {
    "android": {
      "googleServicesFile": "./google-services.json"
    },
    "ios": {
      "googleServicesFile": "./GoogleService-Info.plist",
      "entitlements": {
        "aps-environment": "production"
      },
      "infoPlist": {
        "UIBackgroundModes": ["remote-notification"]
      }
    },
    "plugins": [
      "@react-native-firebase/app",
      "@react-native-firebase/messaging",
      ["expo-build-properties", { "ios": { "useFrameworks": "static" } }],
      "./plugins/withFirebaseNotificationColorFix",
      "./plugins/withFirebasePodfileFix"
    ]
  }
}

Note: @react-native-firebase/analytics does NOT need a plugins entry — only app and messaging do.

CRITICAL for iOS: expo-build-properties with useFrameworks: static together with withFirebasePodfileFix ensures Firebase iOS SDK (v9+) builds correctly. Without these, pod install will fail with FirebaseCoreInternal / GoogleUtilities errors.

firebase.json (Project Root — GDPR Opt-In)

Create firebase.json at the project root to disable automatic data collection:

{
  "react-native": {
    "analytics_auto_collection_enabled": false,
    "google_analytics_automatic_screen_reporting_enabled": false
  }
}

This disables automatic screen tracking — all events are fired manually via Analytics.* calls.

src/lib/analytics.ts (Full Implementation)

This file implements all Analytics.* calls referenced in paywall.tsx and anywhere else in the app:

import analytics from "@react-native-firebase/analytics";

/**
 * Analytics wrapper around Firebase Analytics.
 * All methods are fire-and-forget with silent error handling —
 * a Firebase failure must never crash the app.
 */
export const Analytics = {
  async logScreenView(screenName: string): Promise<void> {
    try {
      if (__DEV__) console.log("[Analytics] screenView:", screenName);
      await analytics().logScreenView({
        screen_name: screenName,
        screen_class: screenName,
      });
    } catch (e) {
      if (__DEV__) console.warn("[Analytics] logScreenView error:", e);
    }
  },

  async logPaywallView(): Promise<void> {
    try {
      if (__DEV__) console.log("[Analytics] paywall_view");
      await analytics().logEvent("paywall_view");
    } catch (e) {
      if (__DEV__) console.warn("[Analytics] logPaywallView error:", e);
    }
  },

  async logSubscriptionSelected(plan: string, price?: string): Promise<void> {
    try {
      if (__DEV__)
        console.log("[Analytics] subscription_selected:", plan, price);
      await analytics().logEvent("subscription_selected", {
        plan,
        price: price ?? "unknown",
      });
    } catch (e) {
      if (__DEV__)
        console.warn("[Analytics] logSubscriptionSelected error:", e);
    }
  },

  async logPurchaseInitiated(sku: string, plan: string): Promise<void> {
    try {
      if (__DEV__) console.log("[Analytics] purchase_initiated:", sku, plan);
      await analytics().logEvent("purchase_initiated", { sku, plan });
    } catch (e) {
      if (__DEV__) console.warn("[Analytics] logPurchaseInitiated error:", e);
    }
  },

  async logPurchaseSuccess(productId: string, price?: string): Promise<void> {
    try {
      if (__DEV__)
        console.log("[Analytics] purchase_success:", productId, price);
      await analytics().logPurchase({
        currency: "USD",
        value: price ? parseFloat(price.replace(/[^0-9.]/g, "")) : 0,
        items: [{ item_id: productId, item_name: productId }],
      });
    } catch (e) {
      if (__DEV__) console.warn("[Analytics] logPurchaseSuccess error:", e);
    }
  },

  async logPurchaseFailed(sku: string, errorCode?: string): Promise<void> {
    try {
      if (__DEV__) console.log("[Analytics] purchase_failed:", sku, errorCode);
      await analytics().logEvent("purchase_failed", {
        sku,
        error_code: errorCode ?? "unknown",
      });
    } catch (e) {
      if (__DEV__) console.warn("[Analytics] logPurchaseFailed error:", e);
    }
  },

  async logRestoreInitiated(): Promise<void> {
    try {
      if (__DEV__) console.log("[Analytics] restore_initiated");
      await analytics().logEvent("restore_initiated");
    } catch (e) {
      if (__DEV__) console.warn("[Analytics] logRestoreInitiated error:", e);
    }
  },

  async logRestoreSuccess(): Promise<void> {
    try {
      if (__DEV__) console.log("[Analytics] restore_success");
      await analytics().logEvent("restore_success");
    } catch (e) {
      if (__DEV__) console.warn("[Analytics] logRestoreSuccess error:", e);
    }
  },

  async logRestoreFailed(errorCode?: string): Promise<void> {
    try {
      if (__DEV__) console.log("[Analytics] restore_failed:", errorCode);
      await analytics().logEvent("restore_failed", {
        error_code: errorCode ?? "unknown",
      });
    } catch (e) {
      if (__DEV__) console.warn("[Analytics] logRestoreFailed error:", e);
    }
  },
};

src/lib/messaging.ts (FCM Token + Backend)

import messaging from "@react-native-firebase/messaging";
import axios from "axios";
import { Platform } from "react-native";
import "expo-sqlite/localStorage/install";

// ── Replace these with your app's actual values ──────────────
const PUSH_TOKEN_API = "https://your-api.example.com/app_push_token";
const APP_NAME = "your-app-name"; // e.g. "tarih-altin-bilgiler"
// ─────────────────────────────────────────────────────────────

/**
 * Request notification permission (iOS only — Android 13+ handled separately).
 * Returns true if granted/provisional, false if denied.
 */
export async function requestNotificationPermission(): Promise<boolean> {
  const authStatus = await messaging().requestPermission();
  return (
    authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
    authStatus === messaging.AuthorizationStatus.PROVISIONAL
  );
}

/**
 * Get the FCM registration token for this device.
 * Returns null if messaging is not supported or permission was denied.
 */
export async function getFCMToken(): Promise<string | null> {
  try {
    const token = await messaging().getToken();
    return token;
  } catch (e) {
    if (__DEV__) console.warn("[FCM] getToken error:", e);
    return null;
  }
}

/**
 * Send the FCM token to your backend API.
 */
export async function sendTokenToBackend(
  token: string,
  appName: string = APP_NAME,
): Promise<void> {
  try {
    await axios.post(
      PUSH_TOKEN_API,
      {
        expoPushToken: token,
        platform: Platform.OS,
        app: appName,
        v2: 1,
      },
      {
        headers: {
          accept: "application/json",
          "content-type": "application/json",
        },
      },
    );
    if (__DEV__) console.log("[FCM] Token sent to backend:", token);
  } catch (e) {
    if (__DEV__) console.warn("[FCM] sendTokenToBackend error:", e);
  }
}

/**
 * Register foreground message handler.
 * Returns an unsubscribe function — call it on cleanup.
 */
export function registerForegroundHandler(): () => void {
  const unsubscribe = messaging().onMessage(async (remoteMessage) => {
    if (__DEV__) console.log("[FCM] Foreground message:", remoteMessage);
    // Handle foreground notification here (e.g. show in-app banner)
  });
  return unsubscribe;
}

/**
 * Main setup function — call once in _layout.tsx useEffect.
 * Requests permission, gets token, sends to backend, caches locally.
 * Token is only re-sent when it changes (avoids duplicate backend calls).
 */
export async function setupMessaging(
  appName: string = APP_NAME,
): Promise<void> {
  try {
    const granted = await requestNotificationPermission();
    if (!granted) {
      if (__DEV__) console.log("[FCM] Notification permission denied");
      return;
    }

    const token = await getFCMToken();
    if (!token) return;

    // Cache token locally to avoid unnecessary backend calls
    const cachedToken = globalThis.localStorage.getItem("fcm_token");
    if (cachedToken === token) return; // Already sent

    await sendTokenToBackend(token, appName);
    globalThis.localStorage.setItem("fcm_token", token);
  } catch (e) {
    if (__DEV__) console.warn("[FCM] setupMessaging error:", e);
  }
}

Background Handler (index.ts — Entry Point, REQUIRED)

The background handler MUST be registered outside the React tree, in the app entry file:

// index.ts (project root)
import messaging from "@react-native-firebase/messaging";

// Must be registered before any React component renders
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
  console.log("[FCM] Background message:", remoteMessage);
  // Handle background notification (e.g. update badge, trigger local notification)
});

Wiring in _layout.tsx

import { useEffect } from "react";
import { setupMessaging, registerForegroundHandler } from "@/lib/messaging";

// ── Replace with your app name ──
const APP_NAME = "your-app-name";

export default function RootLayout() {
  useEffect(() => {
    // Firebase Cloud Messaging setup
    setupMessaging(APP_NAME);
    const unsubscribeFCM = registerForegroundHandler();
    return () => unsubscribeFCM();
  }, []);

  // ... rest of layout
}

Firebase Important Notes

TopicDetail
Expo GoDoes NOT work — requires custom dev client (eas build --profile development)
PrebuildAlways run npx expo prebuild --clean after adding Firebase
CredentialsAdd google-services.json and GoogleService-Info.plist to .gitignore — never commit
Android color fixwithFirebaseNotificationColorFix resolves manifest merger conflict for notification color
iOS CocoaPodswithFirebasePodfileFix injects use_modular_headers! to fix Swift/GoogleUtilities error
Analytics auto-trackDisabled in firebase.json — all events fired manually via Analytics.*
Token cachingFCM token cached in localStorage — backend called only when token changes
useFrameworks: staticRequired for Firebase iOS SDK v9+ via expo-build-properties

Widgets (iOS & Android — Optional)

Only implement this section if the user answered YES to "Does the app need iOS/Android home screen widgets?"

Architecture Overview

iOS (expo-widgets — no native code needed):
  widgets/MyWidget.tsx
    → createWidget() + @expo/ui/swift-ui components
    → updateSnapshot() / updateTimeline() from React Native side
    → Data sharing via expo-widgets built-in mechanism

Android (custom config plugin + Kotlin):
  React Native App
    → modules/widget-data/index.ts → saveWidgetData(data)
    → WidgetDataModule.kt → SharedPreferences + AppWidgetManager broadcast
    → AppWidgetProvider subclasses read SharedPreferences and render RemoteViews

iOS uses the official expo-widgets package (alpha) — widgets are written in TypeScript using @expo/ui/swift-ui components with the 'widget' directive. No raw Swift code is needed.

Android has no expo-widgets support. A custom Expo native module (modules/widget-data) writes data to SharedPreferences, and a config plugin (with-android-widget.js) injects Kotlin AppWidgetProvider classes + XML layouts into the Android build.

Install Widget Libraries

# iOS — expo-widgets + Expo UI components
npx expo install expo-widgets @expo/ui

# Android — no extra npm packages needed.
# The native module lives in modules/widget-data/ (local module).
# Add to package.json dependencies:
#   "widget-data": "file:./modules/widget-data"

Then run bun install (or npm install) to link the local module.

app.json Configuration (REQUIRED)

Add the expo-widgets plugin and (if Android widgets enabled) the Android widget plugin:

{
  "expo": {
    "plugins": [
      [
        "expo-widgets",
        {
          "groupIdentifier": "group.com.company.appname",
          "widgets": [
            {
              "name": "MyWidget",
              "displayName": "My Widget",
              "description": "Shows key info at a glance",
              "supportedFamilies": ["systemMedium"]
            }
          ]
        }
      ],
      "./plugins/with-android-widget"
    ]
  }
}

Notes:

  • groupIdentifier defaults to group.<bundleIdentifier> if omitted — set it explicitly when you also need App Groups for other features
  • name must be a valid Swift identifier (no spaces) and must match the name passed to createWidget() in your widget file
  • supportedFamilies: at minimum include "systemMedium". Add "systemSmall" and "systemLarge" as needed
  • Available families: systemSmall, systemMedium, systemLarge, systemExtraLarge (iPad), accessoryCircular, accessoryRectangular, accessoryInline (Lock Screen)

iOS Widget — expo-widgets (TypeScript)

Create widgets/MyWidget.tsx:

import { Text, VStack, HStack, Spacer } from "@expo/ui/swift-ui";
import { font, foregroundStyle, padding } from "@expo/ui/swift-ui/modifiers";
import { createWidget, WidgetBase } from "expo-widgets";

// ── Define your widget data shape ──────────────────────────
// Customize these fields based on your app's data model.
type MyWidgetProps = {
  title: string;
  subtitle: string;
  value: string;
  updatedAt: string;
};

const MyWidget = (props: WidgetBase<MyWidgetProps>) => {
  "widget";

  // Responsive layout based on widget family (size)
  if (props.family === "systemSmall") {
    return (
      <VStack modifiers={[padding({ all: 12 })]}>
        <Text
          modifiers={[
            font({ weight: "bold", size: 14 }),
            foregroundStyle("#FFFFFF"),
          ]}
        >
          {props.title}
        </Text>
        <Spacer />
        <Text
          modifiers={[
            font({ weight: "bold", size: 28 }),
            foregroundStyle("#FFFFFF"),
          ]}
        >
          {props.value}
        </Text>
        <Text
          modifiers={[
            font({ size: 11 }),
            foregroundStyle("rgba(255,255,255,0.6)"),
          ]}
        >
          {props.updatedAt}
        </Text>
      </VStack>
    );
  }

  if (props.family === "systemLarge") {
    return (
      <VStack modifiers={[padding({ all: 16 })]}>
        <Text
          modifiers={[
            font({ weight: "bold", size: 18 }),
            foregroundStyle("#FFFFFF"),
          ]}
        >
          {props.title}
        </Text>
        <Text
          modifiers={[
            font({ size: 14 }),
            foregroundStyle("rgba(255,255,255,0.7)"),
          ]}
        >
          {props.subtitle}
        </Text>
        <Spacer />
        <Text
          modifiers={[
            font({ weight: "bold", size: 36 }),
            foregroundStyle("#FFFFFF"),
          ]}
        >
          {props.value}
        </Text>
        <Spacer />
        <Text
          modifiers={[
            font({ size: 11 }),
            foregroundStyle("rgba(255,255,255,0.5)"),
          ]}
        >
          {props.updatedAt}
        </Text>
      </VStack>
    );
  }

  // Default: systemMedium (REQUIRED)
  return (
    <HStack modifiers={[padding({ all: 16 })]}>
      <VStack>
        <Text
          modifiers={[
            font({ weight: "bold", size: 16 }),
            foregroundStyle("#FFFFFF"),
          ]}
        >
          {props.title}
        </Text>
        <Text
          modifiers={[
            font({ size: 13 }),
            foregroundStyle("rgba(255,255,255,0.7)"),
          ]}
        >
          {props.subtitle}
        </Text>
      </VStack>
      <Spacer />
      <VStack>
        <Text
          modifiers={[
            font({ weight: "bold", size: 28 }),
            foregroundStyle("#FFFFFF"),
          ]}
        >
          {props.value}
        </Text>
        <Text
          modifiers={[
            font({ size: 11 }),
            foregroundStyle("rgba(255,255,255,0.5)"),
          ]}
        >
          {props.updatedAt}
        </Text>
      </VStack>
    </HStack>
  );
};

export default createWidget("MyWidget", MyWidget);

Customization:

  • Change MyWidgetProps type to match your app's data (e.g., weather fields, prayer times, step count, portfolio value)
  • The name in createWidget('MyWidget', ...) must match the name in app.json plugin config
  • Add/remove family branches based on supportedFamilies in config
  • Use any @expo/ui/swift-ui component: Text, VStack, HStack, ZStack, Spacer, Image, ProgressView, etc.
  • Apply modifiers: font(), foregroundStyle(), padding(), background(), frame(), cornerRadius(), etc.

Updating Widget Data from React Native

import MyWidget from "../widgets/MyWidget";

// ── Snapshot update (immediate, single entry) ──────────────
MyWidget.updateSnapshot({
  title: "Portfolio",
  subtitle: "Total balance",
  value: "$12,345",
  updatedAt: new Date().toLocaleTimeString(),
});

// ── Timeline update (scheduled entries) ─────────────────────
MyWidget.updateTimeline([
  {
    date: new Date(),
    props: { title: "Now", subtitle: "...", value: "100", updatedAt: "12:00" },
  },
  {
    date: new Date(Date.now() + 3600000), // 1 hour later
    props: { title: "Next", subtitle: "...", value: "200", updatedAt: "13:00" },
  },
]);

// ── Force reload ────────────────────────────────────────────
MyWidget.reload();

When to call widget updates:

  • App launches or returns to foreground
  • After key data changes (new fetch, user action, timer event)
  • At midnight for date-dependent widgets
  • After user changes location / settings that affect widget content

Android Widget — Custom Config Plugin + Kotlin

Since expo-widgets is iOS-only, Android widgets require three pieces:

  1. modules/widget-data/ — Expo native module that writes data to SharedPreferences and triggers widget refresh via AppWidgetManager broadcast
  2. plugins/with-android-widget.js — Config plugin that copies Kotlin sources, XML layouts, colors, and receivers into the Android project at prebuild time
  3. plugin-templates/android-widget/ — Template files (Kotlin AppWidgetProvider subclasses, XML layouts, drawable, widget info XML)

1. Native Module — modules/widget-data/

modules/widget-data/package.json
{
  "name": "widget-data",
  "version": "1.0.0",
  "main": "index.ts",
  "types": "index.ts"
}
modules/widget-data/expo-module.config.json
{
  "platforms": ["android"],
  "android": {
    "modules": ["com.company.appname.widgetdata.WidgetDataModule"]
  }
}

Replace com.company.appname with your actual Android package name.

modules/widget-data/index.ts
import { Platform } from "react-native";
import { NativeModule, requireNativeModule } from "expo-modules-core";

// ── Define your widget data shape ──────────────────────────
// Customize these fields to match your app.
export interface WidgetData {
  [key: string]: string | number | boolean;
}

interface WidgetDataModuleType extends NativeModule {
  saveWidgetData(data: WidgetData): void;
}

let WidgetDataModule: WidgetDataModuleType | null = null;
if (Platform.OS === "android") {
  WidgetDataModule = requireNativeModule<WidgetDataModuleType>("WidgetData");
}

/**
 * Save widget data to Android SharedPreferences and trigger widget refresh.
 * No-op on iOS (iOS uses expo-widgets updateSnapshot/updateTimeline instead).
 */
export function saveWidgetData(data: WidgetData): void {
  if (Platform.OS === "android" && WidgetDataModule) {
    WidgetDataModule.saveWidgetData(data);
  }
}
modules/widget-data/android/build.gradle
plugins {
  id 'com.android.library'
  id 'expo-module-gradle-plugin'
}

android {
  namespace "com.company.appname.widgetdata"
  defaultConfig {
    versionCode 1
    versionName "1.0.0"
  }
}

Replace com.company.appname with your actual Android package name.

modules/widget-data/android/src/main/java/…/WidgetDataModule.kt
package com.company.appname.widgetdata

import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class WidgetDataModule : Module() {

  override fun definition() = ModuleDefinition {
    Name("WidgetData")

    Function("saveWidgetData") { data: Map<String, Any> ->
      val context = appContext.reactContext ?: return@Function

      // Write all key-value pairs to SharedPreferences with "widget_" prefix
      val prefs = context.getSharedPreferences(
        "group.com.company.appname.widget",
        Context.MODE_PRIVATE
      )
      prefs.edit().apply {
        for ((key, value) in data) {
          when (value) {
            is String  -> putString("widget_$key", value)
            is Boolean -> putBoolean("widget_$key", value)
            is Number  -> putString("widget_$key", value.toString())
          }
        }
        apply()
      }

      // Trigger refresh for all registered widget providers
      val widgetClasses = listOf(
        "com.company.appname.widget.AppWidgetMedium"
        // Add more: "…AppWidgetSmall", "…AppWidgetLarge" if you have them
      )
      val manager = AppWidgetManager.getInstance(context)
      for (className in widgetClasses) {
        try {
          val cls = Class.forName(className)
          val ids = manager.getAppWidgetIds(ComponentName(context, cls))
          if (ids.isNotEmpty()) {
            val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).apply {
              putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
              setClass(context, cls)
            }
            context.sendBroadcast(intent)
          }
        } catch (_: ClassNotFoundException) { }
      }
    }
  }
}

Customize: Replace all com.company.appname references with your actual package. Add or remove widget provider class names in widgetClasses.

2. Config Plugin — plugins/with-android-widget.js

This plugin runs during expo prebuild and:

  1. Copies Kotlin widget provider sources from plugin-templates/android-widget/src/android/app/src/main/java/…/widget/
  2. Copies XML resources (layouts, widget info, drawable) from plugin-templates/android-widget/res/android/app/src/main/res/
  3. Adds widget_background and widget_accent color entries to colors.xml and values-night/colors.xml
  4. Adds a widget_description string to strings.xml
  5. Adds <receiver> blocks to AndroidManifest.xml for each widget size
// plugins/with-android-widget.js
const {
  withAndroidManifest,
  withDangerousMod,
} = require("expo/config-plugins");
const path = require("path");
const fs = require("fs");

// ── CUSTOMIZE THESE ──────────────────────────────────────────
const WIDGET_PACKAGE = "com.company.appname.widget"; // Kotlin package for widget classes
const WIDGET_PROVIDERS = [
  // Add/remove entries based on which sizes you implement
  {
    className: "AppWidgetMedium",
    infoResource: "@xml/widget_medium_info",
  },
  // { className: 'AppWidgetSmall', infoResource: '@xml/widget_small_info' },
  // { className: 'AppWidgetLarge', infoResource: '@xml/widget_large_info' },
];
// ─────────────────────────────────────────────────────────────

function withAndroidWidgetFiles(config) {
  return withDangerousMod(config, [
    "android",
    (config) => {
      const projectRoot = config.modRequest.projectRoot;
      const androidRoot = path.join(
        config.modRequest.platformProjectRoot,
        "app",
        "src",
        "main",
      );

      // Copy Kotlin sources
      const srcTemplate = path.join(
        projectRoot,
        "plugin-templates",
        "android-widget",
        "src",
      );
      const srcDest = path.join(
        androidRoot,
        "java",
        ...WIDGET_PACKAGE.split("."),
      );
      if (fs.existsSync(srcTemplate)) {
        fs.mkdirSync(srcDest, { recursive: true });
        for (const file of fs.readdirSync(srcTemplate)) {
          fs.copyFileSync(
            path.join(srcTemplate, file),
            path.join(srcDest, file),
          );
        }
        console.log("[with-android-widget] Copied Kotlin widget sources");
      }

      // Copy res/ (layouts, xml, drawable)
      const resTemplate = path.join(
        projectRoot,
        "plugin-templates",
        "android-widget",
        "res",
      );
      const resDest = path.join(androidRoot, "res");
      if (fs.existsSync(resTemplate)) {
        copyDirRecursive(resTemplate, resDest);
        console.log("[with-android-widget] Copied widget resource files");
      }

      // Add widget colors to colors.xml
      ensureColor(
        path.join(resDest, "values", "colors.xml"),
        "widget_background",
        "#F2F2F7",
      );
      ensureColor(
        path.join(resDest, "values", "colors.xml"),
        "widget_accent",
        "#6C63FF",
      );
      const nightDir = path.join(resDest, "values-night");
      fs.mkdirSync(nightDir, { recursive: true });
      ensureColor(
        path.join(nightDir, "colors.xml"),
        "widget_background",
        "#1C1C1E",
      );

      // Add widget description to strings.xml
      ensureString(
        path.join(resDest, "values", "strings.xml"),
        "widget_description",
        "Shows key info at a glance",
      );

      return config;
    },
  ]);
}

function withAndroidWidgetReceivers(config) {
  return withAndroidManifest(config, (config) => {
    const app = config.modResults.manifest.application?.[0];
    if (!app) return config;
    if (!app.receiver) app.receiver = [];

    for (const provider of WIDGET_PROVIDERS) {
      const fullName = `${WIDGET_PACKAGE}.${provider.className}`;
      const exists = app.receiver.some(
        (r) => r.$?.["android:name"] === fullName,
      );
      if (exists) continue;

      app.receiver.push({
        $: {
          "android:name": fullName,
          "android:exported": "true",
          "android:label": "@string/app_name",
        },
        "intent-filter": [
          {
            action: [
              {
                $: {
                  "android:name": "android.appwidget.action.APPWIDGET_UPDATE",
                },
              },
            ],
          },
        ],
        "meta-data": [
          {
            $: {
              "android:name": "android.appwidget.provider",
              "android:resource": provider.infoResource,
            },
          },
        ],
      });
    }
    return config;
  });
}

// ── Helpers ──────────────────────────────────────────────────
function copyDirRecursive(src, dest) {
  fs.mkdirSync(dest, { recursive: true });
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
    const s = path.join(src, entry.name);
    const d = path.join(dest, entry.name);
    entry.isDirectory() ? copyDirRecursive(s, d) : fs.copyFileSync(s, d);
  }
}

function ensureColor(filePath, name, value) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  if (!fs.existsSync(filePath)) {
    fs.writeFileSync(
      filePath,
      `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n</resources>`,
    );
  }
  let content = fs.readFileSync(filePath, "utf-8");
  if (!content.includes(`name="${name}"`)) {
    content = content.replace(
      "</resources>",
      `    <color name="${name}">${value}</color>\n</resources>`,
    );
    fs.writeFileSync(filePath, content, "utf-8");
  }
}

function ensureString(filePath, name, value) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  if (!fs.existsSync(filePath)) {
    fs.writeFileSync(
      filePath,
      `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n</resources>`,
    );
  }
  let content = fs.readFileSync(filePath, "utf-8");
  if (!content.includes(`name="${name}"`)) {
    content = content.replace(
      "</resources>",
      `    <string name="${name}">${value}</string>\n</resources>`,
    );
    fs.writeFileSync(filePath, content, "utf-8");
  }
}

module.exports = (config) => {
  config = withAndroidWidgetFiles(config);
  config = withAndroidWidgetReceivers(config);
  return config;
};

Customize:

  • Replace WIDGET_PACKAGE with your actual Android package + .widget suffix
  • Add/remove entries in WIDGET_PROVIDERS for each widget size
  • Adjust color values (widget_background, widget_accent) to match your app theme

3. Template Files — plugin-templates/android-widget/

Create the following skeleton files. The config plugin copies them into the Android project at prebuild.

plugin-templates/android-widget/res/xml/widget_medium_info.xml
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:minWidth="220dp"
    android:minHeight="110dp"
    android:targetCellWidth="4"
    android:targetCellHeight="2"
    android:updatePeriodMillis="1800000"
    android:initialLayout="@layout/widget_medium"
    android:resizeMode="horizontal|vertical"
    android:widgetCategory="home_screen"
    android:description="@string/widget_description" />
plugin-templates/android-widget/res/layout/widget_medium.xml
<?xml version="1.0" encoding="utf-8"?>
<!-- Customize this layout for your app's widget content -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/widget_background"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/widget_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Widget Title"
        android:textColor="@color/widget_accent"
        android:textSize="14sp"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/widget_value"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:text="--"
        android:textSize="24sp"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/widget_subtitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:text="Updated: --"
        android:textSize="11sp" />
</LinearLayout>
plugin-templates/android-widget/res/drawable/widget_background.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/widget_background" />
    <corners android:radius="16dp" />
</shape>
plugin-templates/android-widget/src/AppWidgetMedium.kt
package com.company.appname.widget

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews

/**
 * Medium home screen widget.
 * Reads data from SharedPreferences written by WidgetDataModule.
 *
 * CUSTOMIZE: Change the package name, layout references, and
 * SharedPreferences keys to match your app's data model.
 */
class AppWidgetMedium : AppWidgetProvider() {

  override fun onUpdate(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetIds: IntArray
  ) {
    val prefs = context.getSharedPreferences(
      "group.com.company.appname.widget",
      Context.MODE_PRIVATE
    )

    for (id in appWidgetIds) {
      // Replace R.layout.widget_medium with your actual layout resource
      val views = RemoteViews(context.packageName, R.layout.widget_medium)

      // Read data from SharedPreferences — customize keys per your app
      views.setTextViewText(
        R.id.widget_title,
        prefs.getString("widget_title", "--") ?: "--"
      )
      views.setTextViewText(
        R.id.widget_value,
        prefs.getString("widget_value", "--") ?: "--"
      )
      views.setTextViewText(
        R.id.widget_subtitle,
        prefs.getString("widget_updatedAt", "") ?: ""
      )

      appWidgetManager.updateAppWidget(id, views)
    }
  }
}

Customize: Replace com.company.appname, layout IDs, and SharedPreferences keys. Add AppWidgetSmall.kt and AppWidgetLarge.kt with their own layouts if needed.

Cross-Platform Data Update Pattern

Call widget updates from a shared location in your app (e.g., after data fetch):

import { Platform } from "react-native";
import MyWidget from "../widgets/MyWidget";
import { saveWidgetData } from "../modules/widget-data";

/**
 * Update home screen widget with latest data.
 * Customize the data fields based on your app.
 */
export function updateWidget(data: {
  title: string;
  subtitle: string;
  value: string;
  updatedAt: string;
}) {
  if (Platform.OS === "ios") {
    // expo-widgets handles everything — just pass props
    MyWidget.updateSnapshot(data);
  } else {
    // Android: write to SharedPreferences + trigger broadcast
    saveWidgetData(data);
  }
}

Adding More Widget Sizes

Medium is required. To add small or large:

  1. iOS: Add "systemSmall" / "systemLarge" to supportedFamilies in app.json, then add the corresponding if (props.family === 'systemSmall') branch in widgets/MyWidget.tsx
  2. Android: Create additional layout XML (widget_small.xml), info XML (widget_small_info.xml), and Kotlin provider class (AppWidgetSmall.kt). Add the new provider to WIDGET_PROVIDERS in with-android-widget.js and to widgetClasses in WidgetDataModule.kt

Widget Customization Checklist

When adapting widgets for a new app:

  • Define MyWidgetProps type (iOS) and SharedPreferences keys (Android) based on your app's data model
  • Replace all com.company.appname with your actual bundle ID / package name
  • Update groupIdentifier in app.json to match your App Group
  • Customize widget displayName and description in app.json
  • Adjust accent colors: iOS via foregroundStyle() modifiers, Android via colors.xml + Kotlin hex values
  • Customize Android XML layout (widget_medium.xml) text views and structure
  • Add "widget-data": "file:./modules/widget-data" to package.json dependencies
  • Run npx expo prebuild --clean after any widget config changes

Widget Important Notes

TopicDetail
expo-widgets statusAlpha — iOS only. API may change. Use development builds, not Expo Go
Android approachCustom config plugin + Kotlin AppWidgetProvider. No community package needed
Minimum iOS versioniOS 15+ (WidgetKit requires iOS 14+, expo-widgets targets iOS 15+)
Minimum Android versionAPI 21+ (standard AppWidget)
Data sharing (iOS)Handled by expo-widgets internally via App Groups — no manual UserDefaults code needed
Data sharing (Android)SharedPreferences written by native module, read by AppWidgetProvider
Widget refresh (iOS)updateSnapshot() for immediate, updateTimeline() for scheduled, reload() for force refresh
Widget refresh (Android)AppWidgetManager broadcast from native module. System minimum is 30 min for updatePeriodMillis
Expo GoDoes NOT work — requires custom dev client (eas build --profile development)
PrebuildAlways run npx expo prebuild --clean after adding or modifying widget configuration
Multiple widgetsiOS: add more entries to widgets[] in app.json. Android: add more providers + layouts + config plugin entries

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
  • Widget renders correctly on all enabled sizes — iOS and Android (if widgets enabled)

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.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

Self Updater

⭐ OPEN SOURCE! GitHub: github.com/GhostDragon124/openclaw-self-updater ⭐ ONLY skill with Cron-aware + Idle detection! Auto-updates OpenClaw core & skills, an...

Registry SourceRecently Updated
1101Profile unavailable
Coding

ClawHub CLI Assistant

Use the ClawHub CLI to publish, inspect, version, update, sync, and troubleshoot OpenClaw skills from the terminal.

Registry SourceRecently Updated
1.9K2Profile unavailable
Coding

SkillTree Learning Progress Tracker

Track learning across topics like an RPG skill tree. Prerequisites, milestones, suggested next steps. Gamified learning path.

Registry SourceRecently Updated
890Profile unavailable
Coding

Speak Turbo - Talk to your Claude 90ms latency!

Give your agent the ability to speak to you real-time. Talk to your Claude! Ultra-fast TTS, text-to-speech, voice synthesis, audio output with ~90ms latency....

Registry SourceRecently Updated
4480Profile unavailable