Skip to content

Commit 4c6d2bd

Browse files
feat: firebase auth implementation (#160)
all the auth
1 parent 55cce5f commit 4c6d2bd

File tree

18 files changed

+459
-83
lines changed

18 files changed

+459
-83
lines changed
120 KB
Loading

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"expo-dev-client": "~4.0.25",
2828
"expo-font": "~12.0.9",
2929
"expo-image": "~1.13.0",
30-
"expo-image-picker": "~15.0.7",
30+
"expo-image-picker": "~15.1.0",
3131
"expo-linking": "~6.3.1",
3232
"expo-router": "~3.5.23",
3333
"expo-splash-screen": "0.27.7",

pnpm-lock.yaml

Lines changed: 10 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/common/firebase.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { Env } from '@env';
22
import { initializeApp } from 'firebase/app';
3+
import {
4+
connectAuthEmulator,
5+
getReactNativePersistence,
6+
initializeAuth,
7+
} from 'firebase/auth';
38
import {
49
connectFirestoreEmulator,
510
initializeFirestore,
611
} from 'firebase/firestore';
712
import { connectFunctionsEmulator, getFunctions } from 'firebase/functions';
13+
import { connectStorageEmulator, getStorage } from 'firebase/storage';
814
import { Platform } from 'react-native';
915

16+
import { reactNativeAsyncStorage } from '../../core/storage';
1017
const firebaseConfig = {
1118
apiKey: Env.EXPO_PUBLIC_FIREBASE_API_KEY,
1219
authDomain: Env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
@@ -21,12 +28,23 @@ export const app = initializeApp(firebaseConfig);
2128
export const db = initializeFirestore(app, {
2229
experimentalForceLongPolling: true,
2330
});
24-
const functions = getFunctions(app);
31+
export const functions = getFunctions(app);
32+
export const auth = initializeAuth(app, {
33+
persistence: getReactNativePersistence(reactNativeAsyncStorage),
34+
});
35+
export const storage = getStorage(app);
2536

2637
// Connect to emulator
2738
// 10.0.2.2 is a special IP address to connect to the 'localhost' of the host computer from an Android emulator
2839
const emulatorHost = Platform.OS === 'ios' ? '127.0.0.1' : '10.0.2.2';
40+
const FIRESTORE_PORT = 8080;
41+
const FUNCTIONS_PORT = 5001;
42+
const AUTH_PORT = 9099;
43+
const STORAGE_PORT = 9199;
44+
2945
if (__DEV__) {
30-
connectFirestoreEmulator(db, emulatorHost, 8080);
31-
connectFunctionsEmulator(functions, emulatorHost, 5001);
46+
connectFirestoreEmulator(db, emulatorHost, FIRESTORE_PORT);
47+
connectFunctionsEmulator(functions, emulatorHost, FUNCTIONS_PORT);
48+
connectAuthEmulator(auth, `http://${emulatorHost}:${AUTH_PORT}`);
49+
connectStorageEmulator(storage, emulatorHost, STORAGE_PORT);
3250
}

src/api/common/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './add-test-delay';
22
export * from './api-provider';
3+
export * from './firebase';
34
export * from './utils';

src/api/users/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
export * from './firebase-queries';
12
export * from './types';
23
export * from './use-friends';
34
export * from './use-picture';
45
export * from './use-remove-friend';
56
export * from './use-send-friend-request';
67
export * from './use-user';
7-
export * from './firebase-queries';

src/app/(tabs)/_layout.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
/* eslint-disable react/no-unstable-nested-components */
2-
32
import { Redirect, SplashScreen, Tabs } from 'expo-router';
43
import { Bell, Layers, Settings, Users } from 'lucide-react-native';
5-
import React, { useCallback, useEffect } from 'react';
4+
import React, { useCallback, useEffect, useState } from 'react';
65

7-
import { useAuth, useIsFirstTime, useThemeConfig } from '@/core';
6+
import {
7+
checkIfUserExistsInFirestore,
8+
useAuth,
9+
useIsFirstTime,
10+
useThemeConfig,
11+
} from '@/core';
812
import { LucideIcon } from '@/ui/lucide-icon';
913

1014
export default function TabLayout() {
1115
const status = useAuth.use.status();
1216
const [isFirstTime] = useIsFirstTime();
17+
const [userExists, setUserExists] = useState<boolean | null>(null);
1318
const hideSplash = useCallback(async () => {
1419
await SplashScreen.hideAsync();
1520
}, []);
21+
1622
useEffect(() => {
1723
if (status !== 'idle') {
1824
setTimeout(() => {
@@ -21,6 +27,12 @@ export default function TabLayout() {
2127
}
2228
}, [hideSplash, status]);
2329

30+
useEffect(() => {
31+
if (status === 'signIn') {
32+
checkIfUserExistsInFirestore().then(setUserExists);
33+
}
34+
}, [status]);
35+
2436
const theme = useThemeConfig();
2537

2638
if (isFirstTime) {
@@ -30,6 +42,11 @@ export default function TabLayout() {
3042
return <Redirect href="/auth" />;
3143
}
3244

45+
// we only redirect when userExists is not null (we have acc checked and set the value)
46+
if (status === 'signIn' && userExists === false) {
47+
return <Redirect href="/auth/create-profile" />;
48+
}
49+
3350
return (
3451
<Tabs
3552
screenOptions={{

src/app/(tabs)/settings.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
/* eslint-disable react/react-in-jsx-scope */
22
import { Env } from '@env';
3+
import { useState } from 'react';
34
import { Linking } from 'react-native';
45

56
import { Item } from '@/components/settings/item';
67
import { ItemsContainer } from '@/components/settings/items-container';
78
import { ThemeItem } from '@/components/settings/theme-item';
89
import { useAuth } from '@/core';
9-
import { Header, ScreenContainer, ScrollView, View } from '@/ui';
10+
import { Button, Header, ScreenContainer, ScrollView, View } from '@/ui';
1011

1112
export default function Settings() {
1213
const signOut = useAuth.use.signOut();
13-
// const { colorScheme } = useColorScheme();
14-
// const iconColor = colorScheme === 'dark' ? colors.neutral[400] : colors.neutral[500];
14+
const [isLoggingOut, setIsLoggingOut] = useState(false);
15+
const handleLogout = async () => {
16+
setIsLoggingOut(true);
17+
try {
18+
await signOut();
19+
} catch (error) {
20+
console.error('Logout error:', error);
21+
} finally {
22+
setIsLoggingOut(false);
23+
}
24+
};
1525
return (
1626
<ScreenContainer>
1727
<Header title="Settings" />
@@ -83,9 +93,12 @@ export default function Settings() {
8393
</ItemsContainer>
8494

8595
<View className="my-8">
86-
<ItemsContainer>
87-
<Item text="Logout" onPress={signOut} />
88-
</ItemsContainer>
96+
<Button
97+
label="Logout"
98+
onPress={handleLogout}
99+
loading={isLoggingOut}
100+
variant="item"
101+
/>
89102
</View>
90103
</View>
91104
</ScrollView>

src/app/auth/create-profile.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { ref, uploadBytes } from '@firebase/storage';
2+
import { zodResolver } from '@hookform/resolvers/zod';
3+
import * as ImagePicker from 'expo-image-picker';
4+
import { useRouter } from 'expo-router';
5+
import {
6+
collection,
7+
doc,
8+
getDocs,
9+
query,
10+
setDoc,
11+
where,
12+
} from 'firebase/firestore';
13+
import { Upload } from 'lucide-react-native';
14+
import { useState } from 'react';
15+
import { useForm } from 'react-hook-form';
16+
import { Image, KeyboardAvoidingView } from 'react-native';
17+
import * as z from 'zod';
18+
19+
import { db, storage } from '@/api';
20+
import { useAuth } from '@/core';
21+
import { Button, ControlledInput, Header, ScreenContainer, View } from '@/ui';
22+
const profileSchema = z.object({
23+
displayName: z.string().min(2, 'Display name is required'),
24+
username: z.string().min(2, 'Username is required'),
25+
});
26+
27+
type ProfileFormType = z.infer<typeof profileSchema>;
28+
29+
export default function CreateProfile() {
30+
const router = useRouter();
31+
const currentUid = useAuth.getState().user?.uid;
32+
const defaultProfilePic = require('/assets/images/default_profile_pic.jpg');
33+
const [profileImage, setProfileImage] = useState<string | null>(null);
34+
const [isUploading, setIsUploading] = useState(false);
35+
const [isCreatingProfile, setIsCreatingProfile] = useState(false);
36+
37+
const { handleSubmit, control, setError } = useForm<ProfileFormType>({
38+
resolver: zodResolver(profileSchema),
39+
defaultValues: {
40+
displayName: '',
41+
username: '',
42+
},
43+
});
44+
45+
const handleImageUpload = async () => {
46+
setIsUploading(true);
47+
try {
48+
const image:
49+
| ImagePicker.ImagePickerResult
50+
| ImagePicker.ImagePickerCanceledResult =
51+
await ImagePicker.launchImageLibraryAsync({
52+
mediaTypes: ImagePicker.MediaTypeOptions.Images,
53+
allowsEditing: true,
54+
aspect: [4, 3],
55+
});
56+
if (image.canceled === true) return;
57+
setProfileImage(image.assets[0].uri);
58+
} catch (error) {
59+
console.error('Image upload failed:', error);
60+
} finally {
61+
setIsUploading(false);
62+
}
63+
};
64+
65+
const uploadProfileImage = async () => {
66+
if (!profileImage || !currentUid) return;
67+
68+
try {
69+
const response = await fetch(profileImage);
70+
const blob = await response.blob();
71+
const storageRef = ref(storage, `profilePics/${currentUid}`);
72+
await uploadBytes(storageRef, blob);
73+
return;
74+
} catch (error) {
75+
console.error('Failed to upload profile image:', error);
76+
return;
77+
}
78+
};
79+
80+
const onSubmit = async (data: ProfileFormType) => {
81+
setIsCreatingProfile(true);
82+
if (!currentUid) return;
83+
try {
84+
// check if username taken
85+
const usernameQuery = query(
86+
collection(db, 'users'),
87+
where('username', '==', data.username),
88+
);
89+
const usernameSnapshot = await getDocs(usernameQuery);
90+
91+
if (!usernameSnapshot.empty) {
92+
setError('username', {
93+
type: 'custom',
94+
message: 'This username is already taken',
95+
});
96+
setIsCreatingProfile(false);
97+
return;
98+
}
99+
100+
// make user in firestore
101+
const userDocRef = doc(db, 'users', currentUid);
102+
103+
await setDoc(userDocRef, {
104+
displayName: data.displayName,
105+
username: data.username,
106+
createdAt: new Date().toLocaleDateString('en-CA'),
107+
});
108+
109+
// upload profile pic to storage
110+
await uploadProfileImage();
111+
112+
router.push('/');
113+
} catch (error) {
114+
console.error('Error updating profile', error);
115+
setError('root', {
116+
type: 'custom',
117+
message: 'Something went wrong creating profile',
118+
});
119+
} finally {
120+
setIsCreatingProfile(false);
121+
}
122+
};
123+
124+
return (
125+
<ScreenContainer>
126+
<KeyboardAvoidingView
127+
style={{ flex: 1 }}
128+
behavior="padding"
129+
keyboardVerticalOffset={10}
130+
>
131+
<View className="flex flex-1 flex-col gap-4">
132+
<Header title="Create Profile" />
133+
134+
<View className="flex flex-row items-center gap-4 px-2">
135+
<Image
136+
source={profileImage ? { uri: profileImage } : defaultProfilePic}
137+
className="h-14 w-14 rounded-full"
138+
/>
139+
<Button
140+
label="Upload Profile Picture"
141+
icon={Upload}
142+
variant="outline"
143+
className="flex-1"
144+
textClassName="font-bold"
145+
loading={isUploading}
146+
onPress={handleImageUpload}
147+
/>
148+
</View>
149+
<ControlledInput
150+
control={control}
151+
name="displayName"
152+
label="Display Name"
153+
/>
154+
<ControlledInput
155+
control={control}
156+
name="username"
157+
label="Unique Username"
158+
/>
159+
160+
<Button
161+
label="Complete Profile"
162+
loading={isCreatingProfile}
163+
onPress={handleSubmit(onSubmit)}
164+
/>
165+
</View>
166+
</KeyboardAvoidingView>
167+
</ScreenContainer>
168+
);
169+
}

0 commit comments

Comments
 (0)