[object Object]

← 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

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 →