Guidance for Claude Code (and humans) working in this repo. Read this first. For detailed conventions and “do not touch” rules, also see AGENTS.md. For shipping to the stores, see STORE_RELEASE.md.
Sağlık Skoru — an Expo / React Native (SDK 55, new architecture) mobile app that scans a product’s ingredient label (camera/gallery) or barcode and returns an AI-generated health score, ingredient breakdown, allergens, and a verdict. Backed by Firebase (Auth + Firestore + Cloud Functions). Monetized with RevenueCat (subscriptions + token packs) and AdMob (rewarded ads). i18n in 6 languages. Gamification: tokens, badges, streaks, weekly leaderboard.
com.wyclaew.saglikskoru (Android package, iOS bundleIdentifier)saglikskoru-4d0a85a77b1d2-7fee-4637-9332-a9108b5b1d6fApp.tsx (root). No expo-router file routing despite the dep —
all navigation is a @react-navigation stack + material-top-tabs defined in App.tsx.
Provider order: GestureHandlerRootView → SafeAreaProvider → ThemeProvider →
LanguageProvider → TokenProvider.App.tsx): loading → onboarding (first launch) → login → email
verification gate (password providers) → banned gate → main app.src/context/TokenContext.tsx is the hub for tokens, premium, admin, banned,
badges, athlete mode, trial, and the daily-scan economy. Access via useToken().
ThemeContext, LanguageContext for theme + i18n.src/screens/* — ScanScreen (main, ~1.6k lines: scan flow, profile modal,
daily reward), ResultScreen (analysis display + save/share/leaderboard), SavedScreen,
LeaderboardScreen, StoreScreen (IAP/ads/promo/restore), PaywallScreen, PremiumScreen,
AdminScreen, LoginScreen, OnboardingScreen, AllergyDietScreen.src/services/* — analyze.ts (the ONE client entry to product analysis),
revenuecat.ts (IAP, simulation toggle, restore), adsService.ts (AdMob shim safe for
Expo Go), promoCode.ts, streak.ts, leaderboard.ts, historyService.ts.functions/src/index.ts (callables + scheduler) and
functions/src/productScoring.ts (the analyzeProduct function). Deployed via
firebase deploy --only functions.firestore.rules (field-level write protection on users).There is one analysis path. Do not reintroduce parallel ones.
ScanScreen → analyzeProduct() in src/services/analyze.ts (vision mode with an
image, OR text mode with productName + ingredientsText from OpenFoodFacts for barcodes).analyze.ts calls the analyzeProduct Cloud Function (functions/src/productScoring.ts).products/{cacheKey}_{LANG}:
img_<sha256(image)> (so identical photos hit cache,
different photos never collide — this fixed the old manual_TR collision bug).gemini-2.5-flash, JSON-only) and persists. Invalid /
low-confidence (<80) results get save_rejected: true and are NOT saved.analyze.ts bridges the result to the UI shape; ScanScreen navigates to ResultScreen
with analysisResult. ResultScreen only reads route.params.analysisResult — never
pass it an analyzeInput and expect it to run analysis (the old broken barcode path did).The Gemini schema returns: is_valid_product, confidence_score, overall_health_score,
product_name, sub_scores, dietary_flags, allergens, ingredients[],
analysis_points{pros,cons}, healthy_alternative, summary_text.
A user can NOT write their own tokens, premiumUntil, isAdmin, badges, status, etc.
firestore.rules only lets an owner update a safe allow-list of fields (athleteMode,
allergies, displayName, photoURL, language, …). All value-bearing mutations go through Cloud
Function callables (which run with admin privileges):
| Action | Callable | Client entry |
|---|---|---|
| Scan charge (5 premium/trial, 10 free) | inside analyzeProduct |
analyze.ts (throws insufficient-tokens) |
| Daily login reward (24h cooldown) | claimDailyReward |
useToken().claimDailyReward() |
| Rewarded-ad credit (rate-limited) | claimAdReward |
useToken().claimAdReward() |
| 7-day streak reward (~weekly cooldown) | claimStreakReward |
streak.ts |
| Unlock badge + bonus (idempotent) | grantBadge |
useToken().grantBadge(id) |
| Weekly winner reward | claimWeeklyReward |
useToken().claimWeeklyReward() |
| Redeem promo code | redeemPromo |
promoCode.ts |
| Premium days (sim/admin only) | grantPremiumDays |
useToken().setIsPremium(true, days, bonus) |
| Token pack (sim/admin only) | grantTokenPack |
useToken().purchaseTokens(amount, packId) |
| Delete account (store requirement) | deleteAccount |
profile modal in ScanScreen |
Reward amounts and cooldowns are constants in functions/src/index.ts. The tokens UI value
refreshes via the Firestore onSnapshot listener after a callable runs — clients no longer
optimistically write it.
Premium source of truth: derived from THREE inputs in TokenContext —
Firestore premiumUntil (admin/promo grants), RevenueCat entitlement (rcPremium, real IAP),
or active trial. In production, real purchase premium comes from the RevenueCat
entitlement, not a Firestore write (grantPremiumDays is disabled in prod unless admin).
EXPO_PUBLIC_IAP_SIMULATION ("1"/unset = simulation, "0" = real
RevenueCat). Set per build profile in eas.json (preview = sim, production = real). Drives
SIMULATION_MODE in revenuecat.ts AND the server-side IAP_SIMULATION guard. ⚠ Token
packs require RevenueCat receipt verification before being enabled for real money — see
STORE_RELEASE.md.app.json (react-native-google-mobile-ads plugin) are placeholders —
fill real ones. Rewarded ad unit comes from EXPO_PUBLIC_ADMOB_REWARDED (falls back to
Google test unit). Ads are non-personalized by default (requestNonPersonalizedAdsOnly).EXPO_PUBLIC_ prefix required)EXPO_PUBLIC_FIREBASE_* (have hardcoded fallbacks in src/config/firebase.ts).EXPO_PUBLIC_GOOGLE_{WEB,ANDROID,IOS}_CLIENT_ID.EXPO_PUBLIC_REVENUECAT_{GOOGLE,APPLE}_KEY.EXPO_PUBLIC_ADMOB_REWARDED.EXPO_PUBLIC_IAP_SIMULATION.EXPO_PUBLIC_USE_EMULATOR=1 + optional EXPO_PUBLIC_EMULATOR_HOST.GEMINI_API_KEY, a Firebase Functions
secret: firebase functions:secrets:set GEMINI_API_KEY. (The old EXPO_PUBLIC_GEMINI_API_KEY
was leaked in eas.json and removed — rotate it.)users/{uid} — profile, tokens, premiumUntil, badges, athleteMode, allergies, flags.app_config/ai — runtime AI provider/model, switchable from the Admin panel
(fields: provider = gemini|claude, geminiModel, claudeModel). analyzeProduct
reads it (60s cache) and calls Gemini (@google/generative-ai) or Claude
(@anthropic-ai/sdk) via one callAI() helper. Claude needs the ANTHROPIC_API_KEY
Firebase secret; Gemini needs GEMINI_API_KEY. Default provider is Gemini.products/{cacheKey}_{LANG} — cached AI analyses (server-written only). Carries
verified, flag_count, flagged_by[], source, model, scanned_count. Community
verification: reportProduct flags a wrong entry; 3 independent flags delete it so it
re-analyses fresh (self-healing). This cache is NOT tied to any user — deleting a user
never removes cached product analyses. Model is GEMINI_MODEL (functions/.env, default
gemini-2.0-flash — gemini-2.5-flash 404s for new API keys).
Write-path hardening (do not weaken): only confidence ≥80 analyses persist
(65–79 is shown to the user with not_saved: true but never cached); forceRefresh
(photo re-verification of a barcode) REQUIRES label photos, is capped at 10/user/day,
never starts verified, preserves flag_count/flagged_by, and stamps overwritten_by;
unverified entries auto-upgrade to verified after 3 flag-free scans; when photos and
OFF text are both present the prompt treats photos as authoritative; barcode analyses
are auto-translated to all 6 languages on first analysis (translateAndStore).users/{uid}/saved_products/{id} — a user’s personal cloud-synced saved list (deleted with the account).promo_codes/{CODE} — admin-managed; redeemed via redeemPromo callable.leaderboard/*, leaderboard_metadata/*, leaderboard_rewards/* — gamification.app_feedback, app_bug_reports, analysis_feedback, product_feedbacks — create by users,
read/manage by admins.# App
npm start # Metro / Expo dev server
npm run android | ios # native run (dev build)
npx tsc --noEmit # typecheck (must stay at 0 errors)
npx expo lint # eslint
# Functions
cd functions
npm run build # tsc
npm run serve # emulator (pair with EXPO_PUBLIC_USE_EMULATOR=1 on the client)
npm run deploy # firebase deploy --only functions
# Security rules
firebase deploy --only firestore:rules
⚠️
npm run deploycan silently skip your changes. Firebase decides what to redeploy from afirebase-functions-hashlabel on each deployed function, and that hash does not always change whenfunctions/srcdoes — a barefirebase deploy --only functionsthen printsSkipped (No changes detected)for every function and your fix never ships. Deploying a NAMED function bypasses the check:firebase deploy --only functions:analyzeProduct,functions:getReferralCodeAlways verify what actually landed — a skipped deploy still exits 0:
gcloud functions describe <name> --gen2 --region=us-central1 \ --project=saglikskoru-4d0a8 --format="value(updateTime)"
ios.googleServicesFile must point at ./GoogleService-Info.plist. The
RNFirebase config plugin throws without it and prebuild dies before writing
anything; @react-native-google-signin also reads its URL scheme from that
same file.expo-build-properties declares GoogleUtilities, GoogleDataTransport,
nanopb and RecaptchaInterop with modular_headers: true. Firebase’s Swift
pods (FirebaseCoreInternal, FirebaseCrashlytics, FirebaseSessions,
AppCheckCore) cannot import them as static libraries otherwise and
pod install fails. Do not replace this with useFrameworks: "static" —
that is what rnfirebase.io documents, but it flips every pod to static
frameworks and react-native-google-mobile-ads pins static_framework = false
with the override only reachable via a Podfile global that
expo-build-properties cannot set. (The docs’ “dynamic + SPM” route needs a
newer RNFirebase; 25.1.0’s plugin is ConfigPlugin<void> — no disableSPM.)ios.supportsTablet is false on purpose: the layouts were never designed for
iPad, and turning it on makes App Store Connect demand 13” iPad screenshots.expo-build-properties → android.useLegacyPackaging: true is deliberate.
With the default (libs loaded straight out of the APK splits), x86_64
devices that run ARM code through translation — Chromebooks, emulators,
Play pre-launch test devices — crashed on launch with
SoLoaderDSONotFoundError: libc++_shared.so in MainApplication.onCreate.
The AAB ships that lib for all four ABIs (checked with unzip -l), so this
is a load-path problem, not a packaging one. Don’t flip it back to save
install size.enableMinifyInReleaseBuilds + enableShrinkResourcesInReleaseBuilds are on
(R8) because Play flagged the release for DEX obfuscation at 2%. Before
shipping an AAB after a native dependency change, install it on the emulator
(bundletool build-apks --mode=universal) and run a scan end to end: a
missing keep rule shows up only at runtime.react-i18next t(). Add new keys to all six
src/locales/{en,tr,es,fr,zh,ja}.json. Fallback language is tr.react-native-safe-area-context only; full-screen gates outside the nav tree
wrap in SafeAreaProvider + SafeAreaView.@ts-ignore over getReactNativePersistence in firebase.ts."Gıda", "Kozmetik", …).src/services/telemetry.ts wraps @react-native-firebase/crashlytics + /analytics
(NATIVE modules — auto no-op in Expo Go / web via lazy-require guards). initTelemetry()
runs at startup (App.tsx), routes uncaught JS errors to Crashlytics, and enables collection;
setTelemetryUser(uid) ties reports to the user; logEvent() records events
(scan_completed, purchase_tokens, purchase_premium). Config plugins are in app.json.
recurLeaderboardRewards (hourly) — rolls expired recurring leaderboard_rewards into next month.pruneProductCache (weekly) — deletes OLD (>120d) + rarely-scanned (≤2) + unverified
products cache entries to cap Firestore growth; they self-heal (re-analyse on next scan).admobSSV (onRequest, public) — AdMob rewarded Server-Side Verification callback.
Verifies Google’s ECDSA signature, credits AD_REWARD idempotently per transaction_id.
URL: https://us-central1-saglikskoru-4d0a8.cloudfunctions.net/admobSSV. DORMANT until you
set that URL in the AdMob console AND set app_config/ads.ssvEnabled = true (then
claimAdReward no-ops to avoid double credit). Client sends the uid via
setServerSideVerificationOptions.resetLeaderboardWeekly (hourly, acts once/week) — ranks the board, grants top-3 token rewards,
sets weekly_winner on user docs, clears entries, stamps leaderboard_metadata/status. (Moved
off the client so it runs reliably for everyone.)analyzeProduct + firestore.rules deployed. Default provider
is Gemini (gemini-2.0-flash). Admin panel → Yapay Zekâ Modeli card writes app_config/ai
(see AdminScreen.tsx saveAIConfig). Switch takes effect within ~60s (in-memory cache in
getAIConfig).ANTHROPIC_API_KEY secret is a PLACEHOLDER. To actually use Claude, run
firebase functions:secrets:set ANTHROPIC_API_KEY with a real key + redeploy analyzeProduct.
Until then Gemini works; Claude calls fail gracefully.isAdmin: true (set it in Firestore console on your uid).tsc 0, functions build 0, expo lint 0 errors.functions/.env IAP_SIMULATION=0 (real sales mode). Real purchases now credit
server-side via the revenueCatWebhook function (premium → premiumUntil, token packs →
tokens). Webhook URL: https://us-central1-saglikskoru-4d0a8.cloudfunctions.net/revenueCatWebhook;
auth via the REVENUECAT_WEBHOOK_AUTH secret (must match the Authorization header set in
the RevenueCat dashboard). RevenueCat app_user_id = Firebase uid (loginToRevenueCat).eas build) to fix the Expo Go font/icon issue and test Ads/IAP/notifications on-device.revenueCatWebhook) mirrors premium → premiumUntil and credits
token packs from verified receipts — closes both the premium-pricing gap and the token-pack
receipt-verification gap. grantTokenPack/grantPremiumDays remain admin/sim-only.claimAdReward.app.json display name is now “Sağlık Skoru”; EAS production env vars
(Google/RevenueCat client keys) set via eas env (project scope, production).