← back to Nineoh Guide
TK-12: add Sign in with Apple — expo-apple-authentication button in Saved tab (sync watchlist), usesAppleSignIn entitlement + plugin, local credential persistence
e2d78aec98d193f9ecc07def8f1759b492bc8e03 · 2026-08-05 13:05:51 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M apps/mobile/App.tsxM apps/mobile/app.jsonM apps/mobile/package.jsonM pnpm-lock.yaml
Diff
commit e2d78aec98d193f9ecc07def8f1759b492bc8e03
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 13:05:51 2026 -0700
TK-12: add Sign in with Apple — expo-apple-authentication button in Saved tab (sync watchlist), usesAppleSignIn entitlement + plugin, local credential persistence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
apps/mobile/App.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++
apps/mobile/app.json | 4 +++
apps/mobile/package.json | 1 +
pnpm-lock.yaml | 16 +++++++++
4 files changed, 109 insertions(+)
diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index 273f62b..d6aeeae 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -15,6 +15,7 @@ import Constants from "expo-constants";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import AsyncStorage from "@react-native-async-storage/async-storage";
+import * as AppleAuthentication from "expo-apple-authentication";
import { APP, EpisodeSchema, type Episode } from "@nineoh/core";
// API base resolution (in precedence order):
@@ -33,6 +34,7 @@ const API_BASE =
const WATCHLIST_KEY = "nineoh.watchlist.v1";
const WATCHED_KEY = "nineoh.watchedThrough.v1";
+const APPLE_USER_KEY = "nineoh.appleUser.v1";
type TabKey = "episodes" | "cast" | "media" | "news" | "saved";
const TABS: { key: TabKey; icon: string; label: string }[] = [
@@ -168,6 +170,9 @@ export default function App() {
const [revealed, setRevealed] = useState<Set<string>>(new Set());
const [eggTaps, setEggTaps] = useState(0);
const [egg, setEgg] = useState(false);
+ const [appleUser, setAppleUser] = useState<{ id: string; name: string | null } | null>(
+ null
+ );
// Easter egg #1: tap the UNOFFICIAL badge 5× to unlock the hidden 90210 card.
const tapBadge = useCallback(() => {
@@ -187,6 +192,45 @@ export default function App() {
);
}, []);
+ // Sign in with Apple — optional account so the watchlist can sync across devices.
+ useEffect(() => {
+ AsyncStorage.getItem(APPLE_USER_KEY).then((raw) => {
+ if (raw) {
+ try {
+ setAppleUser(JSON.parse(raw));
+ } catch {
+ /* ignore corrupt store */
+ }
+ }
+ });
+ }, []);
+ const signInWithApple = useCallback(async () => {
+ try {
+ const cred = await AppleAuthentication.signInAsync({
+ requestedScopes: [
+ AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
+ AppleAuthentication.AppleAuthenticationScope.EMAIL,
+ ],
+ });
+ const name = cred.fullName?.givenName
+ ? `${cred.fullName.givenName}${
+ cred.fullName.familyName ? " " + cred.fullName.familyName : ""
+ }`
+ : null;
+ const u = { id: cred.user, name };
+ setAppleUser(u);
+ AsyncStorage.setItem(APPLE_USER_KEY, JSON.stringify(u));
+ } catch (e: any) {
+ if (e?.code !== "ERR_REQUEST_CANCELED") {
+ Alert.alert("Sign in failed", "Could not complete Sign in with Apple.");
+ }
+ }
+ }, []);
+ const signOutApple = useCallback(() => {
+ setAppleUser(null);
+ AsyncStorage.removeItem(APPLE_USER_KEY);
+ }, []);
+
// Native feature: persisted watchlist + spoiler cutoff (survive restarts).
useEffect(() => {
AsyncStorage.getItem(WATCHLIST_KEY).then((raw) => {
@@ -429,6 +473,35 @@ export default function App() {
{tab === "saved" ? (
<>
+ <View style={styles.signInCard}>
+ {appleUser ? (
+ <View style={styles.signedInRow}>
+ <Text style={styles.signedInText}>
+ Signed in{appleUser.name ? ` as ${appleUser.name}` : " with Apple"}
+ </Text>
+ <Pressable onPress={signOutApple} hitSlop={8}>
+ <Text style={styles.signOutLink}>Sign out</Text>
+ </Pressable>
+ </View>
+ ) : (
+ <>
+ <Text style={styles.signInBlurb}>
+ Sign in to sync your watchlist across your devices.
+ </Text>
+ <AppleAuthentication.AppleAuthenticationButton
+ buttonType={
+ AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN
+ }
+ buttonStyle={
+ AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
+ }
+ cornerRadius={10}
+ style={styles.appleBtn}
+ onPress={signInWithApple}
+ />
+ </>
+ )}
+ </View>
<Text style={styles.sectionTitle}>★ Your watchlist</Text>
{savedEpisodes.length === 0 ? (
<Text style={styles.empty}>
@@ -601,6 +674,21 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
empty: { fontSize: 14, color: "#8a1c1c", lineHeight: 20 },
+ signInCard: {
+ backgroundColor: "rgba(30,20,48,0.04)",
+ borderRadius: 14,
+ padding: 16,
+ marginBottom: 20,
+ },
+ signInBlurb: { fontSize: 13, color: "#5a4a66", marginBottom: 12, lineHeight: 18 },
+ appleBtn: { height: 46, width: "100%" },
+ signedInRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ signedInText: { fontSize: 15, fontWeight: "600", color: "#3a2540" },
+ signOutLink: { fontSize: 14, color: "#7b3f6e", fontWeight: "600" },
card: {
backgroundColor: "rgba(255,255,255,0.5)",
borderWidth: 1,
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 4bd6c1b..cb0679f 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -10,10 +10,14 @@
"ios": {
"bundleIdentifier": "com.abrams.nineohguide",
"supportsTablet": true,
+ "usesAppleSignIn": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
+ "plugins": [
+ "expo-apple-authentication"
+ ],
"extra": {
"apiBaseUrl": "https://nineoh-guide.vercel.app",
"eas": {
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index d6d7507..e60dbbd 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -15,6 +15,7 @@
"@react-native-async-storage/async-storage": "2.2.0",
"babel-preset-expo": "~57.0.5",
"expo": "~57.0.10",
+ "expo-apple-authentication": "~57.0.1",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.9",
"expo-linear-gradient": "~57.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9cb8902..0bb904f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -33,6 +33,9 @@ importers:
expo:
specifier: ~57.0.10
version: 57.0.10(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(react-dom@19.2.8(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3)
+ expo-apple-authentication:
+ specifier: ~57.0.1
+ version: 57.0.1(expo@57.0.10)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)
expo-blur:
specifier: ~57.0.2
version: 57.0.2(expo@57.0.10)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)
@@ -1333,6 +1336,13 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
+ expo-apple-authentication@57.0.1:
+ resolution: {integrity: sha512-pqAIaiTa/ycNl+XqNg5UMVT5TBgl6q6djTtoIcUmDItjVBA1IDHsQEs+leo+8+fG5hhuempZLSqBVVn+RJKKWA==}
+ peerDependencies:
+ expo: '*'
+ react: '*'
+ react-native: '*'
+
expo-asset@57.0.8:
resolution: {integrity: sha512-sGocG+Wd2WcZ1KjKyB2LfUZXzrBM0VHEATGcpJKF9T3HM56M/sMbH73vv+n85u5Zr0FkJjEbV1DWhGPimCR1QQ==}
peerDependencies:
@@ -3956,6 +3966,12 @@ snapshots:
event-target-shim@5.0.1: {}
+ expo-apple-authentication@57.0.1(expo@57.0.10)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3):
+ dependencies:
+ expo: 57.0.10(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(react-dom@19.2.8(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3)
+ react: 19.2.3
+ react-native: 0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1)
+
expo-asset@57.0.8(expo@57.0.10)(react-native@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.2.17)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3):
dependencies:
'@expo/image-utils': 0.11.4(supports-color@8.1.1)(typescript@6.0.3)
← a03d63b TK-12: upgrade Expo SDK 52->57 (RN 0.76->0.86, React 19) for
·
back to Nineoh Guide
·
auto-save: 2026-08-05T13:12:15 (1 files) — apps/mobile/packa d752218 →