From a4c7318fd4451fe98913f6b37ae2ee213168ece3 Mon Sep 17 00:00:00 2001 From: Vickrum Sukhlani <108380995+IniquityV@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:37:49 -0500 Subject: [PATCH 1/3] Collect gender during onboarding and from existing users Add a SetupGender step to profile setup between academic information and resume, writing the selection to PrivateUserInfo.gender. "Prefer not to say" is the opt-out, so the step cannot be skipped and every user who sees it writes a value. Existing accounts never pass through ProfileSetup again, so GenderPromptModal is mounted in MainStack to collect the value in-app. It renders only when gender is undefined and cannot be dismissed, and it writes to Firestore before updating local state so a failed write leaves the prompt open. Also extract the profile-setup progress dashes into a ProgressDashes component so adding the sixth step did not mean editing every screen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T9reuKppqcRRaeTGPyxPbV --- src/components/GenderPromptModal.tsx | 134 ++++++++++++++++++++ src/navigation/MainStack.tsx | 4 + src/navigation/ProfileSetupStack.tsx | 3 +- src/screens/onboarding/ProfileSetup.tsx | 161 ++++++++++++++++++------ src/types/navigation.ts | 1 + src/types/user.ts | 9 +- 6 files changed, 272 insertions(+), 40 deletions(-) create mode 100644 src/components/GenderPromptModal.tsx diff --git a/src/components/GenderPromptModal.tsx b/src/components/GenderPromptModal.tsx new file mode 100644 index 00000000..3f0ed824 --- /dev/null +++ b/src/components/GenderPromptModal.tsx @@ -0,0 +1,134 @@ +import { View, Text, Pressable, ActivityIndicator, TouchableOpacity, useColorScheme } from 'react-native'; +import React, { useContext, useState } from 'react'; +import { Octicons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { UserContext } from '../context/UserContext'; +import { setPrivateUserData } from '../api/firebaseUtils'; +import { GENDER_OPTIONS } from '../types/user'; +import DismissibleModal from './DismissibleModal'; + +/** + * A one-time prompt asking existing users for their gender. + * + * Users who created their account before the gender step was added to onboarding never + * pass through ProfileSetup again, so this collects the value from them inside the main app. + * + * The modal cannot be dismissed: there is no close button, and `setVisible` is a no-op so + * neither a backdrop tap nor the Android hardware back button will close it. "Prefer not to + * say" is the opt-out. Because every option writes a non-empty value, answering permanently + * closes the gate and the prompt can never re-appear. + * + * Once new-user onboarding has been live long enough that virtually no accounts are missing + * a gender value, this component and its mount in MainStack can be deleted. + */ +const GenderPromptModal = () => { + const userContext = useContext(UserContext); + const { userInfo, setUserInfo } = userContext!; + + const fixDarkMode = userInfo?.private?.privateInfo?.settings?.darkMode; + const useSystemDefault = userInfo?.private?.privateInfo?.settings?.useSystemDefault; + const colorScheme = useColorScheme(); + const darkMode = useSystemDefault ? colorScheme === 'dark' : fixDarkMode; + + const [selectedGender, setSelectedGender] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Checked against undefined rather than falsiness so that any written answer closes the gate. + const needsGender = userInfo?.private?.privateInfo?.gender === undefined; + + const handleSave = async () => { + if (!selectedGender || loading) return; + + setLoading(true); + setError(null); + + try { + // Firestore first. If this throws the modal stays open, so local state can never + // claim success for a write that did not land. + await setPrivateUserData({ gender: selectedGender }); + + const updatedUserInfo = { + ...userInfo, + private: { + ...userInfo?.private, + privateInfo: { ...userInfo?.private?.privateInfo, gender: selectedGender }, + }, + }; + + await AsyncStorage.setItem("@user", JSON.stringify(updatedUserInfo)); + setUserInfo(updatedUserInfo); + } catch (err) { + console.error("Error saving gender:", err); + setError("Could not save. Check your connection and try again."); + } finally { + setLoading(false); + } + }; + + const GenderOption = ({ option }: { option: string }) => { + const isActive = selectedGender === option; + return ( + setSelectedGender(option)} + className='flex-row items-center py-3' + > + + + + {option} + + ); + }; + + if (!needsGender) { + return null; + } + + return ( + { }} // Intentionally inert: this prompt must be answered. + > + + + + + One quick question + + + + + Help us better understand our chapter. Select your gender to continue. + + + + {GENDER_OPTIONS.map((option) => ( + + ))} + + + {error && ( + {error} + )} + + + {loading + ? + : Save + } + + + + ); +}; + +export default GenderPromptModal; diff --git a/src/navigation/MainStack.tsx b/src/navigation/MainStack.tsx index d82fa294..81a7cc84 100644 --- a/src/navigation/MainStack.tsx +++ b/src/navigation/MainStack.tsx @@ -14,6 +14,7 @@ import { Image, Text, View, useColorScheme } from "react-native"; import { auth } from "../config/firebaseConfig"; import { Images } from "../../assets"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import GenderPromptModal from "../components/GenderPromptModal"; const MainStack = () => { @@ -94,6 +95,9 @@ const HomeBottomTabs = () => { + + {/* One-time gender prompt for users who onboarded before the gender step existed. */} + ); }; diff --git a/src/navigation/ProfileSetupStack.tsx b/src/navigation/ProfileSetupStack.tsx index 220b5cdc..bb74aeab 100644 --- a/src/navigation/ProfileSetupStack.tsx +++ b/src/navigation/ProfileSetupStack.tsx @@ -1,7 +1,7 @@ import React from "react"; import { createStackNavigator } from '@react-navigation/stack'; import { ProfileSetupStackParams } from "../types/navigation"; -import { SetupNameAndBio, SetupProfilePicture, SetupAcademicInformation, SetupResume, SetupInterests } from "../screens/onboarding/ProfileSetup"; +import { SetupNameAndBio, SetupProfilePicture, SetupAcademicInformation, SetupGender, SetupResume, SetupInterests } from "../screens/onboarding/ProfileSetup"; import LoginScreen from "../screens/onboarding/Login"; @@ -13,6 +13,7 @@ const ProfileSetupStack = () => { + diff --git a/src/screens/onboarding/ProfileSetup.tsx b/src/screens/onboarding/ProfileSetup.tsx index 168e7c9c..b486ecf6 100644 --- a/src/screens/onboarding/ProfileSetup.tsx +++ b/src/screens/onboarding/ProfileSetup.tsx @@ -13,7 +13,7 @@ import { getBlobFromURI, selectFile, selectImage } from '../../api/fileSelection import { updateProfile } from 'firebase/auth'; import { CommonMimeTypes, validateName } from '../../helpers/validation'; import { handleLinkPress } from '../../helpers/links'; -import { MAJORS, classYears } from '../../types/user'; +import { MAJORS, classYears, GENDER_OPTIONS } from '../../types/user'; import { ProfileSetupStackParams } from '../../types/navigation'; import { Images } from '../../../assets'; import UploadFileIcon from '../../../assets/file-arrow-up-solid.svg'; @@ -31,6 +31,23 @@ import { LinearGradient } from 'expo-linear-gradient'; const safeAreaViewStyle = "flex-1 justify-between bg-dark-navy py-10 px-8"; +const TOTAL_SETUP_STEPS = 6; + +/** + * The row of dashes at the top of every profile-setup screen, filled in up to the current step. + * Kept in one place so adding or reordering a step does not mean editing every screen. + */ +const ProgressDashes = ({ step }: { step: number }) => ( + + {Array.from({ length: TOTAL_SETUP_STEPS }, (_, i) => ( + + ))} + +); + /** In this screen, the user will set their name and bio. The screen only let the user continue if their name is not empty. */ const SetupNameAndBio = ({ navigation }: NativeStackScreenProps) => { const [name, setName] = useState(""); @@ -63,13 +80,7 @@ const SetupNameAndBio = ({ navigation }: NativeStackScreenProps - - - - - - - + @@ -230,13 +241,7 @@ const SetupProfilePicture = ({ navigation }: NativeStackScreenProps - - - - - - - + @@ -341,13 +346,7 @@ const SetupAcademicInformation = ({ navigation }: NativeStackScreenProps - - - - - - - + @@ -399,7 +398,7 @@ const SetupAcademicInformation = ({ navigation }: NativeStackScreenProps) => { + const [gender, setGender] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const GenderButton = ({ option }: { option: string }) => { + const isSelected = gender === option; + return ( + setGender(option)} + className='flex-row rounded-xl w-full mb-4' + > + + + {isSelected && } + + {option} + + + ); + }; + + return ( + + + + + {/* Header */} + + navigation.goBack()} + activeOpacity={1} + > + + + + + + + + Gender + This helps us better understand our chapter. It is kept private and is not shown on your profile. + + + + {GENDER_OPTIONS.map((option) => ( + + ))} + + {error && ( + {error} + )} + + { + if (gender === "" || loading) { + return; + } + + setLoading(true); + setError(null); + + try { + if (auth.currentUser) { + await setPrivateUserData({ gender: gender }); + } + navigation.navigate("SetupResume"); + } catch (err) { + console.error("Error saving gender:", err); + setError("Could not save. Check your connection and try again."); + } finally { + setLoading(false); + } + }} + label='Continue' + opacity={gender === "" ? 1 : 0.8} + buttonClassName={`justify-center items-center mt-8 rounded-xl h-14 ${gender === "" ? "bg-grey-dark" : "bg-primary-orange"}`} + textClassName={`text-white font-semibold text-2xl text-white`} + underlayColor={`${gender === "" ? "" : "#EF9260"}`} + /> + + {loading && ( + + )} + + + + + ); +}; + const SetupResume = ({ navigation }: NativeStackScreenProps) => { const [resumeURL, setResumeURL] = useState(null); const [loading, setLoading] = useState(false); @@ -484,13 +581,7 @@ const SetupResume = ({ navigation }: NativeStackScreenProps - - - - - - - + @@ -660,13 +751,7 @@ const SetupInterests = ({ navigation }: NativeStackScreenProps - - - - - - - + @@ -743,4 +828,4 @@ const SetupInterests = ({ navigation }: NativeStackScreenProps { export const classYears = generateClassYears(); +/** + * Selectable gender values. "Prefer not to say" is the opt-out, so every user who is + * prompted writes a value and is never prompted again. + */ +export const GENDER_OPTIONS = ["Male", "Female", "Other", "Prefer not to say"] as const; + export const MAJORS: Array<{ major: string, iso: string }> = [ { major: 'Aerospace Engineering', iso: 'AERO' }, { major: 'Architectural Engineering', iso: 'AREN' }, From 6907a395a1be9e32eb01f08875d609a3c2f12689 Mon Sep 17 00:00:00 2001 From: Vickrum Sukhlani <108380995+IniquityV@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:12:39 -0500 Subject: [PATCH 2/3] Add a local Firestore emulator that runs in Docker Brings up the Firebase Auth and Firestore emulators in a container so the app can be developed and tested without touching the production project. - firebase-emulator/Dockerfile pre-downloads the emulator JARs at image build time so `up` does not refetch them per container. - firebase-emulator/start.sh passes --import only when saved state exists, because Firebase treats a missing import directory as a fatal error on a clean checkout. It lives in its own file rather than inline in the compose command because a YAML folded scalar silently splits the flags onto separate lines. - docker-compose.yml sets stop_signal: SIGINT so --export-on-exit actually flushes; Compose sends SIGTERM by default, which loses the data. - firebase-emulator/seed.js writes two idempotent accounts, one with a gender value and one without, so the existing-user gender prompt can be exercised locally. It refuses to run unless FIRESTORE_EMULATOR_HOST is set. - firebase.json exposes the UI, hub, and logging ports on 0.0.0.0 so they are reachable from the host. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J7Xu8Pxwqs2czY79M239vU --- .dockerignore | 4 ++ .gitattributes | 3 + .gitignore | 5 +- README.md | 67 +++++++++++++++++++ docker-compose.yml | 71 ++++++++++++++++++++ firebase-emulator/Dockerfile | 34 ++++++++++ firebase-emulator/seed.js | 121 +++++++++++++++++++++++++++++++++++ firebase-emulator/start.sh | 30 +++++++++ firebase.json | 9 +++ package.json | 4 +- 10 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 docker-compose.yml create mode 100644 firebase-emulator/Dockerfile create mode 100644 firebase-emulator/seed.js create mode 100644 firebase-emulator/start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c1f93349 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +# firebase-emulator/Dockerfile COPYs nothing — the config it needs is bind-mounted at +# runtime by docker-compose.yml. Excluding everything keeps the build context empty so +# `docker compose build` does not stream node_modules to the daemon. +* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7e5cc93f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# The emulator entrypoint is executed by /bin/sh inside a Linux container. If Git +# checks it out with CRLF on Windows, the shell fails on the carriage returns. +firebase-emulator/*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 0263685c..2462e7c6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ npm-debug.* .env* google-services.json -GoogleService-Info.plist \ No newline at end of file +GoogleService-Info.plist + +# Firebase emulator state written by `docker compose` (--export-on-exit) +firebase-emulator/data/ diff --git a/README.md b/README.md index cf39515c..67d659ab 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,73 @@ Create an Internal build by running the following script and provide the link to yarn dev-client-sim ``` +## Local Firestore in Docker + +A minimal Firebase Emulator Suite (Firestore + Auth) runs in a container, so you can +develop against a throwaway database instead of the production project. Docker is the +only prerequisite — no Java, no `firebase-tools`, and no credentials of any kind. + +``` +$ docker compose up # or: yarn emulators +``` + +| Service | URL | +| ------------ | ----------------------- | +| Emulator UI | | +| Firestore | `localhost:8080` | +| Auth | `localhost:9099` | + +Two accounts are seeded automatically, both with the password `password123`: + +| Email | Purpose | +| ------------------- | ------------------------------------------------------------ | +| `member@tamu.edu` | Has **no** `gender` field, so the gender prompt appears | +| `officer@tamu.edu` | Already answered the gender question, and has officer roles | + +### Pointing the app at it + +The app connects to the emulators only when `FIREBASE_EMULATOR_ADDRESS` is set (see +`src/config/firebaseConfig.ts`). Add this to your `.env`: + +``` +FIREBASE_EMULATOR_ADDRESS=127.0.0.1 +FIREBASE_AUTH_PORT=9099 +FIREBASE_FIRESTORE_PORT=8080 +``` + +Then restart Metro with a cleared cache — these values are inlined at build time by +`babel-plugin-inline-dotenv`, so an already-running bundler will keep using the old ones: + +``` +$ npx expo start --dev-client --clear +``` + +**On a physical device, `127.0.0.1` is the phone, not your computer.** Use your machine's +LAN address instead (`ipconfig` on Windows, `ifconfig` on macOS), for example +`FIREBASE_EMULATOR_ADDRESS=192.168.1.42`. An Android emulator uses `10.0.2.2`. + +Remove these three lines from `.env` to go back to the real backend. + +### Data and rules + +State persists between runs: it is exported to `firebase-emulator/data/` on shutdown +and re-imported on the next start. That directory is gitignored. To wipe it: + +``` +$ yarn emulators:reset +``` + +Because the export happens on `SIGINT`, stop the emulators with `docker compose down` +or `Ctrl-C` rather than killing the container, or the session's data is lost. + +Rules come from `firebase-emulator/firestore.rules`, which is deliberately wide open +(`allow read, write: if true`) and does **not** mirror production. Edits to it hot-reload +in the running emulator, so this is not the place to test whether real rules permit a +write. The container only ever runs `emulators:start`, so it cannot deploy anything. + +> The `functions` emulator is intentionally excluded to keep startup fast; add it to +> `--only` in `firebase-emulator/start.sh` if you need to work on Cloud Functions. + ## Test - TODO: Need more details TEMP LINK: https://github.com/TAMUSHPE/MobileApp/pull/378 ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..f6a817f8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +# `docker compose up` = Firebase Emulator Suite (Firestore + Auth) + seed data. +# No real credentials anywhere in this file — emulator-only by design. +# +# The Expo app itself is NOT containerised: run `npm start` on the host as usual and +# point it at these ports with the FIREBASE_* variables in .env (see README section +# "Local Firestore in Docker"). Nothing here can touch the production project; the +# container only ever runs `emulators:start`, never `firebase deploy`. +services: + emulators: + build: + context: . + dockerfile: firebase-emulator/Dockerfile + # Startup logic lives in a script, not inline here: a YAML folded scalar does not + # fold lines indented deeper than the first, so a multi-line inline command would + # silently split apart and drop every flag. Invoked via `sh` rather than executed + # directly, because the exec bit does not reliably survive a Windows bind mount. + command: sh ./firebase-emulator/start.sh + working_dir: /workspace + # Only the emulator's own config is mounted — not the repo. Bind-mounting the whole + # project would drag node_modules across the Windows filesystem boundary for no gain, + # since nothing in here imports the app's code. + # firebase-emulator/ is writable so --export-on-exit can persist state into the repo, + # and so edits to firestore.rules on the host hot-reload in the running emulator. + volumes: + - ./firebase.json:/workspace/firebase.json:ro + - ./.firebaserc:/workspace/.firebaserc:ro + - ./firebase-emulator:/workspace/firebase-emulator + ports: + - "4000:4000" # Emulator UI -> http://localhost:4000 + - "4400:4400" # Emulator hub (the UI in your browser calls this directly) + - "4500:4500" # Logging + - "8080:8080" # Firestore + - "9099:9099" # Auth + # Firestore's root path answers 200. The Auth emulator's root may not, so this uses + # `curl -s` without -f there: any response at all proves the port is listening. + healthcheck: + test: + - CMD-SHELL + - >- + curl -sf http://localhost:8080/ >/dev/null + && curl -s --connect-timeout 1 http://localhost:9099/ >/dev/null + interval: 2s + timeout: 5s + retries: 45 + start_period: 5s + # firebase-tools flushes the export on SIGINT. Compose sends SIGTERM by default, + # which would kill it before it writes, silently losing everything you did. + stop_signal: SIGINT + stop_grace_period: 30s + + # One-shot: populates fixtures, then exits. Safe to re-run — the script uses fixed + # document IDs and tolerates Auth users that already exist, so re-seeding just + # re-applies the same deterministic data on top of whatever was re-imported. + seed: + build: + context: . + dockerfile: firebase-emulator/Dockerfile + command: node firebase-emulator/seed.js + working_dir: /workspace + volumes: + - ./firebase-emulator:/workspace/firebase-emulator + environment: + # firebase-admin routes to the emulators (and skips credential lookup entirely) + # purely because these are set. There is no key file and none is needed. + FIRESTORE_EMULATOR_HOST: emulators:8080 + FIREBASE_AUTH_EMULATOR_HOST: emulators:9099 + GOOGLE_CLOUD_PROJECT: tamushpemobileapp + depends_on: + emulators: + condition: service_healthy + restart: "no" diff --git a/firebase-emulator/Dockerfile b/firebase-emulator/Dockerfile new file mode 100644 index 00000000..7b84b83a --- /dev/null +++ b/firebase-emulator/Dockerfile @@ -0,0 +1,34 @@ +# Dev container for the Firebase Emulator Suite (Firestore + Auth). +# +# This image does NOT build or run the Expo app. Metro and the native runtime stay +# on the host; only the backend the app talks to lives in here. See docker-compose.yml. +# +# No secrets are baked in and no real credentials are ever needed: the emulators +# ignore API keys and accept unauthenticated admin access from inside the network. +FROM node:20-slim + +# The Firestore emulator is a Java program; the Emulator UI and Auth emulator are not. +# curl is used by the compose healthcheck. +RUN apt-get update \ + && apt-get install -y --no-install-recommends default-jre-headless curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# firebase-tools runs the emulators. firebase-admin is only used by the seed script, +# which talks to the emulators over the *_EMULATOR_HOST variables and so needs no +# service-account key. +RUN npm install -g firebase-tools@13 firebase-admin@12 + +# Global installs are not on Node's default resolution path for a script run out of +# /workspace, so seed.js can `require("firebase-admin")`. +ENV NODE_PATH=/usr/local/lib/node_modules + +# Pre-download the emulator JARs at build time. Without this, every `docker compose up` +# on a fresh container re-downloads them, because the cache lives in $HOME and would be +# thrown away with the container. +RUN firebase setup:emulators:firestore \ + && firebase setup:emulators:ui + +WORKDIR /workspace + +# 4000 Emulator UI · 4400 hub · 4500 logging · 8080 Firestore · 9099 Auth +EXPOSE 4000 4400 4500 8080 9099 diff --git a/firebase-emulator/seed.js b/firebase-emulator/seed.js new file mode 100644 index 00000000..2df7ca29 --- /dev/null +++ b/firebase-emulator/seed.js @@ -0,0 +1,121 @@ +/** + * Seeds the local Firebase emulators with a handful of deterministic fixtures. + * + * Run by the `seed` service in docker-compose.yml. It reaches the emulators only + * through FIRESTORE_EMULATOR_HOST / FIREBASE_AUTH_EMULATOR_HOST, so firebase-admin + * never looks for a service-account key and this script can never touch production. + * + * Idempotent: document IDs are fixed and writes use set(), and an Auth user that + * already exists is treated as success. Re-running is a no-op in effect. + */ +const admin = require("firebase-admin"); + +const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT || "tamushpemobileapp"; +const PASSWORD = "password123"; + +if (!process.env.FIRESTORE_EMULATOR_HOST) { + console.error("Refusing to run: FIRESTORE_EMULATOR_HOST is not set, so this would write to a real project."); + process.exit(1); +} + +admin.initializeApp({ projectId: PROJECT_ID }); +const db = admin.firestore(); +const auth = admin.auth(); + +/** + * Fixtures mirror the shapes in src/types/user.ts. `gender` lives on privateInfo. + * + * Two users exist on purpose: one already answered the gender question and one has + * never been asked. The second is what GenderPromptModal keys off — it shows only + * when privateInfo.gender is `undefined` — so signing in as that account is the way + * to exercise the prompt locally. + */ +const USERS = [ + { + uid: "seed-member-no-gender", + email: "member@tamu.edu", + displayName: "Seed Member", + publicInfo: { + name: "Seed Member", + bio: "Existing account created before the gender step shipped.", + major: "Computer Science", + classYear: "2027", + roles: { reader: true }, + points: 12, + pointsThisMonth: 4, + interests: ["Software"], + isStudent: true, + isEmailPublic: false, + }, + privateInfo: { + completedAccountSetup: true, + settings: { darkMode: false, useSystemDefault: true }, + // No `gender` key at all — this is the account that triggers the prompt. + }, + }, + { + uid: "seed-officer-with-gender", + email: "officer@tamu.edu", + displayName: "Seed Officer", + publicInfo: { + name: "Seed Officer", + bio: "Account that has already answered the gender question.", + major: "Mechanical Engineering", + classYear: "2026", + roles: { reader: true, officer: true }, + points: 140, + pointsThisMonth: 30, + interests: ["Leadership"], + isStudent: true, + isEmailPublic: true, + }, + privateInfo: { + completedAccountSetup: true, + settings: { darkMode: true, useSystemDefault: false }, + gender: "Prefer not to say", + }, + }, +]; + +const seedUser = async (user) => { + try { + await auth.createUser({ + uid: user.uid, + email: user.email, + emailVerified: true, + password: PASSWORD, + displayName: user.displayName, + }); + } catch (err) { + // A re-run hits an existing account; anything else is a real failure. + if (err.code !== "auth/uid-already-exists" && err.code !== "auth/email-already-exists") { + throw err; + } + } + + await db.doc(`users/${user.uid}`).set( + { uid: user.uid, email: user.email, ...user.publicInfo }, + { merge: true } + ); + await db.doc(`users/${user.uid}/private/privateInfo`).set(user.privateInfo, { merge: true }); + + console.log(` ${user.email} (${user.uid}) — gender: ${user.privateInfo.gender ?? "not set"}`); +}; + +const main = async () => { + console.log(`Seeding project "${PROJECT_ID}" via ${process.env.FIRESTORE_EMULATOR_HOST}`); + + for (const user of USERS) { + await seedUser(user); + } + + // Read by fetchLatestVersion() in src/api/firebaseUtils.ts for the update banner. + await db.doc("config/global").set({ latestVersion: "1.1.4" }, { merge: true }); + + console.log(`Done. Sign in with any seeded email and the password "${PASSWORD}".`); +}; + +main().then(() => process.exit(0)).catch((err) => { + console.error("Seed failed:", err); + process.exit(1); +}); diff --git a/firebase-emulator/start.sh b/firebase-emulator/start.sh new file mode 100644 index 00000000..b773775d --- /dev/null +++ b/firebase-emulator/start.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Entrypoint for the `emulators` service in docker-compose.yml. +# +# This lives in a script rather than inline in the compose file on purpose: a YAML +# folded scalar (`command: >`) does NOT fold lines that are indented deeper than the +# first one, so a multi-line `sh -c "firebase emulators:start ..."` silently splits +# into separate commands and every flag after the first line is lost. +set -e + +DATA_DIR=./firebase-emulator/data + +# Firebase treats a missing --import directory as a fatal error, so the flag can only +# be passed once an export actually exists. On a clean checkout it must be omitted. +if [ -f "$DATA_DIR/firebase-export-metadata.json" ]; then + echo "==> importing saved emulator state from $DATA_DIR" + set -- --import="$DATA_DIR" +else + echo "==> no saved state found; starting with an empty database" + set -- +fi + +# --only keeps this minimal and, importantly, stops Firebase from starting the +# functions/pubsub/storage emulators declared in firebase.json. The functions source +# directory is not mounted into this container at all. The Emulator UI starts +# regardless of --only. +exec firebase emulators:start \ + --project tamushpemobileapp \ + --only firestore,auth \ + --export-on-exit="$DATA_DIR" \ + "$@" diff --git a/firebase.json b/firebase.json index 2444e20e..56385c37 100644 --- a/firebase.json +++ b/firebase.json @@ -38,8 +38,17 @@ }, "ui": { "host": "0.0.0.0", + "port": 4000, "enabled": true }, + "hub": { + "host": "0.0.0.0", + "port": 4400 + }, + "logging": { + "host": "0.0.0.0", + "port": 4500 + }, "singleProjectMode": true }, "storage": { diff --git a/package.json b/package.json index b73604ba..a8714ad0 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "go": "npx expo start --go", "go-tunnel": "npx expo start --tunnel --go", "go-localhost": "npx expo start --localhost --go", - "dev-client-sim": "eas build --profile development-sim" + "dev-client-sim": "eas build --profile development-sim", + "emulators": "docker compose up", + "emulators:reset": "docker compose down -v && rm -rf firebase-emulator/data" }, "dependencies": { "@expo/config-plugins": "~10.1.1", From d23fda1ec986a420109cfc611038238b6720054e Mon Sep 17 00:00:00 2001 From: Vickrum Sukhlani <108380995+IniquityV@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:16:50 -0500 Subject: [PATCH 3/3] Disable the gender options while the write is in flight Both gender pickers left their options tappable during the save. Because the handler closes over the value that was selected when it was pressed, changing the selection mid-write meant the highlighted option could end up different from what was actually stored: the popup then closes and the onboarding screen advances, so there is no chance to correct it. Marking the options disabled while loading matches the Save/Continue buttons, which were already guarded, and the opacity-50 treatment follows the existing idiom in SettingsComponents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J7Xu8Pxwqs2czY79M239vU --- src/components/GenderPromptModal.tsx | 3 ++- src/screens/onboarding/ProfileSetup.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/GenderPromptModal.tsx b/src/components/GenderPromptModal.tsx index 3f0ed824..816b7704 100644 --- a/src/components/GenderPromptModal.tsx +++ b/src/components/GenderPromptModal.tsx @@ -71,7 +71,8 @@ const GenderPromptModal = () => { return ( setSelectedGender(option)} - className='flex-row items-center py-3' + disabled={loading} + className={`flex-row items-center py-3 ${loading ? 'opacity-50' : ''}`} > diff --git a/src/screens/onboarding/ProfileSetup.tsx b/src/screens/onboarding/ProfileSetup.tsx index b486ecf6..c008d2f3 100644 --- a/src/screens/onboarding/ProfileSetup.tsx +++ b/src/screens/onboarding/ProfileSetup.tsx @@ -430,7 +430,8 @@ const SetupGender = ({ navigation }: NativeStackScreenProps setGender(option)} - className='flex-row rounded-xl w-full mb-4' + disabled={loading} + className={`flex-row rounded-xl w-full mb-4 ${loading ? 'opacity-50' : ''}`} >