diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index c43dbc6..8f5435e 100755 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -8,4 +8,8 @@ expo-env.d.ts *.ipa *.apk *.jks -*.keystore \ No newline at end of file +*.keystore +# Fastlane +fastlane-key.json +fastlane/report.xml +fastlane/README.md diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 54edbe9..bf620c3 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -91,7 +91,7 @@ android { applicationId 'com.sauci.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 44 + versionCode 45 versionName "1.0.5" } signingConfigs { diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 3258b83..bd9f434 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -183,7 +183,7 @@ "backgroundColor": "#1a1a2e" }, "package": "com.sauci.app", - "versionCode": 44, + "versionCode": 45, "googleServicesFile": "./google-services.json", "permissions": [ "RECEIVE_BOOT_COMPLETED", diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 7b38faf..6bcd0f9 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -64,12 +64,15 @@ type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'content' | 'notificatio export default function OnboardingScreen() { const router = useRouter(); - const { user, fetchUser } = useAuthStore(); + // Only subscribe to stable values to prevent re-renders from background fetchUser calls + const userId = useAuthStore((s) => s.user?.id); + const fetchUser = useAuthStore((s) => s.fetchUser); + const initialUser = useRef(useAuthStore.getState().user); const [stage, setStage] = useState('avatar'); - const [name, setName] = useState(user?.name || ''); - const [gender, setGender] = useState(user?.gender || null); + const [name, setName] = useState(initialUser.current?.name || ''); + const [gender, setGender] = useState(initialUser.current?.gender || null); const [usageReason, setUsageReason] = useState(null); - const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false); + const [hideNsfw, setHideNsfw] = useState(initialUser.current?.hide_nsfw ?? false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [showPaywall, setShowPaywall] = useState(false); @@ -82,7 +85,7 @@ export default function OnboardingScreen() { showPicker: handleAvatarPress, uploadAvatar, } = useAvatarPicker({ - userId: user?.id, + userId, onSelect: () => setError(null), }); @@ -147,8 +150,8 @@ export default function OnboardingScreen() { setIsLoading(true); try { const token = await registerForPushNotificationsAsync(); - if (token && user?.id) { - await savePushToken(user.id, token); + if (token && userId) { + await savePushToken(userId, token); } } catch (err) { console.error('Failed to enable notifications:', err); @@ -159,8 +162,8 @@ export default function OnboardingScreen() { }; const handleComplete = async () => { - console.log('[Onboarding] handleComplete called, user:', user?.id, 'name:', name); - if (!user?.id) { + console.log('[Onboarding] handleComplete called, user:', userId, 'name:', name); + if (!userId) { console.error('[Onboarding] No user ID available'); setError('Not logged in. Please restart the app.'); return; @@ -204,7 +207,7 @@ export default function OnboardingScreen() { const { error: updateError } = await supabase .from('profiles') .update(updateData) - .eq('id', user.id); + .eq('id', userId); if (updateError) { console.error('[Onboarding] Profile update error:', updateError); diff --git a/apps/mobile/fastlane/Appfile b/apps/mobile/fastlane/Appfile new file mode 100644 index 0000000..5084007 --- /dev/null +++ b/apps/mobile/fastlane/Appfile @@ -0,0 +1,2 @@ +json_key_file(ENV["GOOGLE_PLAY_KEY_FILE"] || File.expand_path("../fastlane-key.json", __dir__)) +package_name("com.sauci.app") diff --git a/apps/mobile/fastlane/Fastfile b/apps/mobile/fastlane/Fastfile new file mode 100644 index 0000000..441406f --- /dev/null +++ b/apps/mobile/fastlane/Fastfile @@ -0,0 +1,48 @@ +default_platform(:android) + +platform :android do + desc "Upload AAB to Google Play internal testing track" + lane :upload do |options| + track = options[:track] || "internal" + + # Find the AAB file - check project root for EAS builds or gradle output + project_root = File.expand_path("..", __dir__) + aab_path = options[:aab] + + unless aab_path + # Check for EAS build AABs in project root, pick the newest + eas_builds = Dir.glob(File.join(project_root, "build-*.aab")).sort_by { |f| File.mtime(f) } + gradle_builds = Dir.glob(File.join(project_root, "android/app/build/outputs/bundle/release/*.aab")) + aab_path = eas_builds.last || gradle_builds.first + end + + # Resolve relative paths from project root + aab_path = File.expand_path(aab_path, project_root) if aab_path && !aab_path.start_with?("/") + + UI.user_error!("No AAB file found. Build first or specify with aab: parameter") unless aab_path && File.exist?(aab_path) + + UI.message("Uploading #{aab_path} to #{track} track...") + + upload_to_play_store( + track: track, + aab: aab_path, + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true, + skip_upload_changelogs: false, + release_status: "draft", + ) + + UI.success("Successfully uploaded to #{track} track!") + end + + desc "Upload AAB to production track" + lane :upload_production do |options| + upload(options.merge(track: "production")) + end + + desc "Upload AAB to beta track" + lane :upload_beta do |options| + upload(options.merge(track: "beta")) + end +end diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0390043..8a550cc 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -15,7 +15,7 @@ "test:ci": "jest --runInBand --ci", "release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local", "release:ios:preview": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile preview --local", - "release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local", + "release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local && bundle exec fastlane android upload", "release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local" }, "dependencies": { diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index ad94d4c..fd67964 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -4,14 +4,14 @@ import { Events } from "../lib/analytics"; import type { Profile, Couple } from "@/types"; -interface AuthState { - user: Profile | null; - couple: Couple | null; - partner: Profile | null; - isLoading: boolean; - isAuthenticated: boolean; - isAnonymous: boolean; - +interface AuthState { + user: Profile | null; + couple: Couple | null; + partner: Profile | null; + isLoading: boolean; + isAuthenticated: boolean; + isAnonymous: boolean; + // Actions fetchUser: () => Promise; @@ -38,19 +38,19 @@ export const useAuthStore = create((set, get) => ({ user: null, couple: null, partner: null, - isLoading: true, - isAuthenticated: false, - isAnonymous: false, + isLoading: true, + isAuthenticated: false, + isAnonymous: false, fetchUser: async () => { try { // First check if we have a session in storage const { data: { session } } = await supabase.auth.getSession(); - if (!session?.user) { - set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); - return; - } + if (!session?.user) { + set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); + return; + } // Validate the session by fetching the user from the server // This will fail if the session was deleted server-side @@ -59,8 +59,8 @@ export const useAuthStore = create((set, get) => ({ if (authError || !authUser) { console.log("[Auth] Session invalid, signing out:", authError?.message); - // Session is invalid - clear everything - set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); + // Session is invalid - clear everything + set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); // Clear other stores const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); useMatchStore.getState().clearMatches(); @@ -80,13 +80,20 @@ export const useAuthStore = create((set, get) => ({ .eq("id", authUser.id) .maybeSingle(); - const isAnonymous = !!(authUser as any).is_anonymous; - - set({ - user: profile, - isAuthenticated: true, - isAnonymous, - }); + const isAnonymous = !!(authUser as any).is_anonymous; + + // Only update user if data actually changed to avoid unnecessary re-renders + const currentUser = get().user; + const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile); + if (userChanged) { + set({ + user: profile, + isAuthenticated: true, + isAnonymous, + }); + } else if (!get().isAuthenticated) { + set({ isAuthenticated: true, isAnonymous }); + } // If user has a couple, fetch couple data; otherwise clear couple/partner if (profile?.couple_id) { @@ -148,14 +155,14 @@ export const useAuthStore = create((set, get) => ({ Events.signOut(); // Clear local state FIRST to ensure UI updates even if Supabase call fails - set({ - user: null, - couple: null, - partner: null, - isAuthenticated: false, - isAnonymous: false, - isLoading: false, - }); + set({ + user: null, + couple: null, + partner: null, + isAuthenticated: false, + isAnonymous: false, + isLoading: false, + }); // Clear other stores const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); useMatchStore.getState().clearMatches(); @@ -178,14 +185,14 @@ export const useAuthStore = create((set, get) => ({ }, setUser: (user) => { - set({ - user, - isAuthenticated: !!user, - ...(user === null && { isAnonymous: false }), - isLoading: false, - // Clear couple/partner when user is null (signed out) - ...(user === null && { couple: null, partner: null }) - }); + set({ + user, + isAuthenticated: !!user, + ...(user === null && { isAnonymous: false }), + isLoading: false, + // Clear couple/partner when user is null (signed out) + ...(user === null && { couple: null, partner: null }) + }); // Clear other stores when user signs out if (user === null) { const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();