feat(mobile/ios): iOS simulator build profile and native Sign in with Apple

- eas.json: new `preview-simulator` profile (extends preview, ios.simulator)
  for EAS cloud simulator builds without an Apple Developer account
- app.json: iOS deployment target 17.0 via expo-build-properties
  (@clerk/expo's ClerkKit SPM package requires iOS 17 — pod install failed
  on the default 16.4), iPhone-only (supportsTablet: false),
  usesAppleSignIn, ITSAppUsesNonExemptEncryption: false
- AppleSignInButton: native Apple sheet via Clerk's useSignInWithApple
  (App Store Guideline 4.8, since Google login is offered); same
  legal-consent handling as GoogleSignInButton; iOS-only
- Not yet testable end-to-end: needs Apple Developer account, the
  Sign in with Apple capability on the App ID and Apple enabled in Clerk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-26 09:07:49 +02:00
1 parent 070c389786
commit 1e7d76aa79
9 files changed
+175 -9

No files matched your search

+16 -3
View File
@@ -8,8 +8,12 @@
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "org.pawfeed.app"
"supportsTablet": false,
"bundleIdentifier": "org.pawfeed.app",
"usesAppleSignIn": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"package": "org.pawfeed.app",
@@ -64,7 +68,16 @@
}
],
"expo-web-browser",
"@sentry/react-native"
"expo-apple-authentication",
"@sentry/react-native",
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "17.0"
}
}
]
],
"extra": {
"router": {},
+5
View File
@@ -8,6 +8,7 @@ import { TextField } from "../../src/components/TextField";
import { PasswordField } from "../../src/components/PasswordField";
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
import { useTheme } from "../../src/context/ThemeContext";
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
@@ -107,6 +108,10 @@ export default function SignInScreen() {
onError={setError}
onNeedsConsent={() => setError(t("auth.googleNeedsSignUpError"))}
/>
<AppleSignInButton
onError={setError}
onNeedsConsent={() => setError(t("auth.appleNeedsSignUpError"))}
/>
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
<TextField
autoCapitalize="none"
+2
View File
@@ -9,6 +9,7 @@ import { TextField } from "../../src/components/TextField";
import { PasswordField } from "../../src/components/PasswordField";
import { LanguageSwitcher } from "../../src/components/LanguageSwitcher";
import { GoogleSignInButton } from "../../src/components/GoogleSignInButton";
import { AppleSignInButton } from "../../src/components/AppleSignInButton";
import { useTheme } from "../../src/context/ThemeContext";
import { env } from "../../src/lib/env";
import { getTypography, type ColorTokens, type Typography } from "../../src/theme/tokens";
@@ -127,6 +128,7 @@ export default function SignUpScreen() {
<LanguageSwitcher />
<Text style={styles.title}>{t("auth.signUpTitle")}</Text>
<GoogleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
<AppleSignInButton onError={setError} legalAccepted={legalAccepted} disabled={!legalAccepted} />
<Text style={styles.divider}>{t("auth.orDivider")}</Text>
<TextField
placeholder={t("auth.firstNamePlaceholder")}
+6
View File
@@ -20,6 +20,12 @@
},
"channel": "preview"
},
"preview-simulator": {
"extends": "preview",
"ios": {
"simulator": true
}
},
"production": {
"android": {
"buildType": "app-bundle"
+32 -4
View File
@@ -17,8 +17,11 @@
"@trpc/client": "11.17.0",
"@trpc/tanstack-react-query": "11.17.0",
"expo": "~57.0.22",
"expo-apple-authentication": "~57.0.2",
"expo-auth-session": "~57.0.12",
"expo-build-properties": "~57.0.22",
"expo-constants": "~57.0.16",
"expo-crypto": "~57.0.3",
"expo-dev-client": "~57.0.19",
"expo-file-system": "~57.0.6",
"expo-font": "~57.0.3",
@@ -6311,6 +6314,17 @@
}
}
},
"node_modules/expo-apple-authentication": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-57.0.2.tgz",
"integrity": "sha512-00KnlyRceRKski+ljZz8q8JhJZ4D3ZScU+Y0RqS6ElJ8GaCWfd7Qh5tMr47yIU/dKDrOnkzkFUM+6vyHS5HI7Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-application": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz",
@@ -6353,11 +6367,16 @@
"react-native": "*"
}
},
"node_modules/expo-auth-session/node_modules/expo-crypto": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz",
"integrity": "sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==",
"node_modules/expo-build-properties": {
"version": "57.0.22",
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.22.tgz",
"integrity": "sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ==",
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^57.0.2",
"resolve-from": "^5.0.0",
"semver": "^7.6.0"
},
"peerDependencies": {
"expo": "*"
}
@@ -6375,6 +6394,15 @@
"react-native": "*"
}
},
"node_modules/expo-crypto": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.3.tgz",
"integrity": "sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-client": {
"version": "57.0.19",
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.19.tgz",
+3
View File
@@ -12,8 +12,11 @@
"@trpc/client": "11.17.0",
"@trpc/tanstack-react-query": "11.17.0",
"expo": "~57.0.22",
"expo-apple-authentication": "~57.0.2",
"expo-auth-session": "~57.0.12",
"expo-build-properties": "~57.0.22",
"expo-constants": "~57.0.16",
"expo-crypto": "~57.0.3",
"expo-dev-client": "~57.0.19",
"expo-file-system": "~57.0.6",
"expo-font": "~57.0.3",
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { Platform, View, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import * as AppleAuthentication from "expo-apple-authentication";
import { useSignInWithApple } from "@clerk/expo/apple";
import * as Sentry from "@sentry/react-native";
import { useTheme } from "../context/ThemeContext";
import { radii } from "../theme/tokens";
type Props = {
onError: (message: string) => void;
/** Same contract as GoogleSignInButton's `legalAccepted` — see there. */
legalAccepted?: boolean;
/** Same contract as GoogleSignInButton's `onNeedsConsent` — see there. */
onNeedsConsent?: () => void;
disabled?: boolean;
};
/**
* "Sign in with Apple" — required by App Store Review Guideline 4.8 because
* the app offers Google login. iOS only: renders nothing on Android or on an
* iOS device where Apple Authentication isn't available.
*
* Uses Clerk's native `useSignInWithApple()` (expo-apple-authentication
* sheet + `oauth_token_apple` token exchange) instead of the browser-based
* `oauth_apple` SSO flow — Apple expects the native sheet, and it avoids the
* redirect-URL pitfalls the Google SSO flow had.
*
* Uses Apple's own AppleAuthenticationButton (HIG-mandated look), not a
* custom Pressable like GoogleSignInButton. Legal-consent handling mirrors
* GoogleSignInButton: Clerk's sign-up comes back `missing_requirements`
* with `legal_accepted` for brand-new accounts.
*/
export function AppleSignInButton(props: Props) {
const [isAvailable, setIsAvailable] = useState(false);
useEffect(() => {
if (Platform.OS !== "ios") return;
AppleAuthentication.isAvailableAsync().then(setIsAvailable).catch(() => setIsAvailable(false));
}, []);
if (!isAvailable) return null;
return <AppleSignInButtonInner {...props} />;
}
function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabled }: Props) {
const { t } = useTranslation();
const { scheme } = useTheme();
const router = useRouter();
const { startAppleAuthenticationFlow } = useSignInWithApple();
const [isPending, setIsPending] = useState(false);
const onPress = async () => {
setIsPending(true);
try {
const result = await startAppleAuthenticationFlow();
const { setActive, signUp } = result;
let createdSessionId = result.createdSessionId;
if (!createdSessionId && signUp?.status === "missing_requirements" && signUp.missingFields.includes("legal_accepted")) {
if (!legalAccepted) {
onNeedsConsent?.();
return;
}
const updatedSignUp = await signUp.update({ legalAccepted: true });
createdSessionId = updatedSignUp.createdSessionId;
}
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId });
router.replace("/(app)");
}
// No createdSessionId otherwise (user cancelled the Apple sheet).
} catch (err) {
Sentry.captureException(err);
onError(err instanceof Error ? err.message : t("auth.appleSignInError"));
} finally {
setIsPending(false);
}
};
const isInactive = isPending || !!disabled;
return (
<View style={isInactive && styles.disabled} pointerEvents={isInactive ? "none" : "auto"}>
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={
scheme === "dark"
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
}
cornerRadius={radii.lg}
style={styles.button}
onPress={onPress}
/>
</View>
);
}
const styles = StyleSheet.create({
button: { height: 48, width: "100%" },
disabled: { opacity: 0.5 },
});
+3 -1
View File
@@ -240,7 +240,9 @@
"googleButton": "Mit Google anmelden",
"orDivider": "oder",
"googleSignInError": "Anmeldung mit Google fehlgeschlagen. Bitte versuche es erneut.",
"googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen."
"googleNeedsSignUpError": "Für dieses Google-Konto existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen.",
"appleSignInError": "Anmeldung mit Apple fehlgeschlagen. Bitte versuche es erneut.",
"appleNeedsSignUpError": "Für diese Apple-ID existiert noch kein PawFeed-Account. Bitte über \"Registrieren\" fortfahren, um den Nutzungsbedingungen zuzustimmen."
},
"onboarding": {
"addPetTitle": "Tier hinzufügen",
+3 -1
View File
@@ -240,7 +240,9 @@
"googleButton": "Continue with Google",
"orDivider": "or",
"googleSignInError": "Google sign-in failed. Please try again.",
"googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service."
"googleNeedsSignUpError": "There's no PawFeed account for this Google account yet. Please continue via \"Sign up\" to accept the Terms of Service.",
"appleSignInError": "Apple sign-in failed. Please try again.",
"appleNeedsSignUpError": "There's no PawFeed account for this Apple ID yet. Please continue via \"Sign up\" to accept the Terms of Service."
},
"onboarding": {
"addPetTitle": "Add pet",