New onboarding flow, calendar sync logic refactor

This commit is contained in:
Milan Paunovic
2024-11-01 03:18:50 +01:00
parent 539cbd9f10
commit 7f68f3acf8
18 changed files with 1153 additions and 671 deletions

246
app/(unauth)/cal_sync.tsx Normal file
View File

@ -0,0 +1,246 @@
import {SafeAreaView} from "react-native-safe-area-context";
import {Button, Text, View} from "react-native-ui-lib";
import React from "react";
import {useCalSync} from "@/hooks/useCalSync";
import GoogleIcon from "@/assets/svgs/GoogleIcon";
import AppleIcon from "@/assets/svgs/AppleIcon";
import OutlookIcon from "@/assets/svgs/OutlookIcon";
import {useAuthContext} from "@/contexts/AuthContext";
import {StyleSheet} from "react-native";
export default function Screen() {
const {profileData, setRedirectOverride} = useAuthContext()
const {handleStartGoogleSignIn, handleAppleSignIn, handleMicrosoftSignIn} = useCalSync()
const hasSomeCalendarsSynced =
!!profileData?.appleAccounts || !!profileData?.microsoftAccounts || !!profileData?.googleAccounts
return (
<SafeAreaView style={{flex: 1}}>
<View style={{flex: 1, padding: 21, paddingBottom: 45, paddingTop: "20%", alignItems: "center"}}>
<View gap-13 width={"100%"} marginB-20>
<Text style={{fontSize: 40, fontFamily: 'Manrope_600SemiBold'}}>
Let's get started!
</Text>
<Text color={"#919191"} style={{fontSize: 20}}>
Add your calendar below to sync events to your Cally calendar
</Text>
</View>
<View width={"100%"} gap-1>
{!profileData?.googleAccounts && (
<Button
onPress={() => handleStartGoogleSignIn()}
label={"Connect Google account"}
labelStyle={styles.addCalLbl}
labelProps={{
numberOfLines: 2,
}}
iconSource={() => (
<View marginR-15>
<GoogleIcon/>
</View>
)}
style={styles.addCalBtn}
color="black"
text70BL
/>
)}
{profileData?.googleAccounts
? Object.keys(profileData?.googleAccounts)?.map((googleMail) => {
const googleToken = profileData?.googleAccounts?.[googleMail]?.accessToken;
return (
googleToken && (
<Button
key={googleMail}
disabled
label={`Connected ${googleMail}`}
labelStyle={styles.addCalLbl}
labelProps={{
numberOfLines: 2,
}}
iconSource={() => (
<View marginR-15>
<GoogleIcon/>
</View>
)}
style={styles.addCalBtn}
color="black"
text70BL
/>
)
);
})
: null}
{!profileData?.appleAccounts && (
<Button
onPress={() => handleAppleSignIn()}
label={"Connect Apple"}
labelStyle={styles.addCalLbl}
labelProps={{
numberOfLines: 2,
}}
iconSource={() => (
<View marginR-15>
<AppleIcon/>
</View>
)}
style={styles.addCalBtn}
color="black"
text70BL
/>
)}
{profileData?.appleAccounts
? Object.keys(profileData?.appleAccounts)?.map((appleEmail) => {
const appleToken = profileData?.appleAccounts?.[appleEmail!];
return (
appleToken && (
<Button
key={appleEmail}
disabled
label={`Connected Apple Calendar`}
labelStyle={styles.addCalLbl}
labelProps={{
numberOfLines: 2,
}}
iconSource={() => (
<View marginR-15>
<AppleIcon/>
</View>
)}
style={styles.addCalBtn}
color="black"
text70BL
/>
)
);
})
: null}
{!profileData?.microsoftAccounts && (
<Button
onPress={() => handleMicrosoftSignIn()}
label={"Connect Outlook"}
labelStyle={styles.addCalLbl}
labelProps={{
numberOfLines: 2,
}}
iconSource={() => (
<View marginR-15>
<OutlookIcon/>
</View>
)}
style={styles.addCalBtn}
color="black"
text70BL
/>
)}
{profileData?.microsoftAccounts
? Object.keys(profileData?.microsoftAccounts)?.map(
(microsoftEmail) => {
const microsoftToken =
profileData?.microsoftAccounts?.[microsoftEmail];
return (
microsoftToken && (
<Button
key={microsoftEmail}
label={`Connected ${microsoftEmail}`}
labelStyle={styles.addCalLbl}
labelProps={{
numberOfLines: 2,
}}
iconSource={() => (
<View marginR-15>
<OutlookIcon/>
</View>
)}
style={styles.addCalBtn}
color="black"
text70BL
/>
)
);
}
)
: null}
</View>
<View flexG/>
<View width={"100%"}>
<Button
label={hasSomeCalendarsSynced ? "Continue" : "Skip this step"}
onPress={() => setRedirectOverride(false)}
marginT-50
labelStyle={{
fontFamily: "PlusJakartaSans_600SemiBold",
fontSize: 16,
}}
style={{height: 50}}
backgroundColor="#fd1775"
/>
</View>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
addCalBtn: {
backgroundColor: "#ffffff",
marginBottom: 15,
justifyContent: "flex-start",
paddingLeft: 25,
},
backBtn: {
backgroundColor: "red",
marginLeft: -2,
justifyContent: "flex-start",
},
card: {
backgroundColor: "white",
width: "100%",
padding: 20,
paddingBottom: 30,
marginTop: 20,
borderRadius: 12,
},
noPaddingCard: {
backgroundColor: "white",
width: "100%",
marginTop: 20,
borderRadius: 12,
},
colorBox: {
aspectRatio: 1,
justifyContent: "center",
alignItems: "center",
width: 51,
borderRadius: 12,
},
checkbox: {
borderRadius: 50,
},
addCalLbl: {
fontSize: 16,
fontFamily: "PlusJakartaSan_500Medium",
flexWrap: "wrap",
width: "75%",
textAlign: "left",
lineHeight: 20,
overflow: "visible",
},
subTitle: {
fontFamily: "Manrope_600SemiBold",
fontSize: 18,
},
cardTitle: {
fontFamily: "Manrope_500Medium",
fontSize: 15,
},
});

View File

@ -1,5 +1,5 @@
import Entry from "@/components/pages/main/Entry"; import Entry from "@/components/pages/main/Entry";
export default function Screen() { export default function Screen() {
return <Entry />; return <Entry />;
} }

View File

@ -0,0 +1,166 @@
import {SafeAreaView} from "react-native-safe-area-context";
import {Button, Colors, Dialog, LoaderScreen, Text, View} from "react-native-ui-lib";
import React, {useState} from "react";
import {useRouter} from "expo-router";
import QRIcon from "@/assets/svgs/QRIcon";
import Toast from "react-native-toast-message";
import {Camera, CameraView} from "expo-camera";
import {useLoginWithQrCode} from "@/hooks/firebase/useLoginWithQrCode";
export default function Screen() {
const router = useRouter()
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [showCameraDialog, setShowCameraDialog] = useState<boolean>(false);
const {mutateAsync: signInWithQrCode, isLoading} = useLoginWithQrCode();
const handleQrCodeScanned = async ({data}: { data: string }) => {
setShowCameraDialog(false);
try {
await signInWithQrCode({userId: data});
Toast.show({
type: "success",
text1: "Login successful with QR code!",
});
} catch (err) {
Toast.show({
type: "error",
text1: "Error logging in with QR code",
text2: `${err}`,
});
}
};
const getCameraPermissions = async (callback: () => void) => {
const {status} = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === "granted");
if (status === "granted") {
callback();
}
};
const handleOpenQrCodeDialog = () => {
getCameraPermissions(() => setShowCameraDialog(true));
}
return (
<SafeAreaView style={{flex: 1}}>
<View style={{flex: 1, padding: 21, paddingBottom: 45, paddingTop: "20%", alignItems: "center"}}>
<View center>
<Text style={{fontSize: 30, fontFamily: 'Manrope_600SemiBold'}}>
Get started with Cally
</Text>
</View>
<View width={"100%"} gap-30>
<View>
<Button
label="Scan QR Code"
marginT-50
labelStyle={{
fontFamily: "PlusJakartaSans_400Regular",
fontSize: 16,
marginLeft: 10
}}
iconSource={() => <QRIcon color={"#07B8C7"}/>}
onPress={handleOpenQrCodeDialog}
style={{height: 50}}
color={Colors.black}
backgroundColor={Colors.white}
/>
{/* GOOGLE LOGIN HERE */}
</View>
<View row center gap-20>
<View flexG style={{backgroundColor: "#E2E2E2", height: 2}}/>
<Text style={{fontSize: 16, fontFamily: 'PlusJakartaSans_300Light', color: "#7A7A7A"}}>
or
</Text>
<View flexG style={{backgroundColor: "#E2E2E2", height: 2}}/>
</View>
<View>
<Button
label="Contine with Email"
labelStyle={{
fontFamily: "PlusJakartaSans_400Regular",
fontSize: 16,
marginLeft: 10
}}
onPress={() => router.push("/(unauth)/sign_up")}
style={{height: 50, borderStyle: "solid", borderColor: "#E2E2E2", borderWidth: 2}}
color={Colors.black}
backgroundColor={"transparent"}
/>
</View>
</View>
<View flexG/>
<View row centerH gap-5>
<Text style={{
fontFamily: "PlusJakartaSans_300Light",
fontSize: 16,
color: "#484848"
}} center>
Already have an account?
</Text>
<Button
label="Log in"
link
onPress={() => router.push("/(unauth)/sign_in")}
labelStyle={[
{
fontFamily: "PlusJakartaSans_500Medium",
fontSize: 16,
color: "#919191",
},
{fontSize: 16, textDecorationLine: "none", color: "#fd1775"},
]}
/>
</View>
</View>
{/* Legacy, move into separate component */}
{/* Camera Dialog */}
<Dialog
visible={showCameraDialog}
onDismiss={() => setShowCameraDialog(false)}
bottom
width="100%"
height="70%"
containerStyle={{padding: 15, backgroundColor: "white"}}
>
<Text center style={{fontSize: 16}} marginB-15>
Scan a QR code presented from your family member of provider.
</Text>
{hasPermission === null ? (
<Text>Requesting camera permissions...</Text>
) : !hasPermission ? (
<Text>No access to camera</Text>
) : (
<CameraView
style={{flex: 1, borderRadius: 15}}
onBarcodeScanned={handleQrCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}}
/>
)}
<Button
label="Cancel"
onPress={() => setShowCameraDialog(false)}
backgroundColor="#fd1775"
style={{margin: 10, marginBottom: 30}}
/>
</Dialog>
{isLoading && (
<LoaderScreen overlay message={"Signing in..."} backgroundColor={Colors.white} color={Colors.grey40}/>
)}
</SafeAreaView>
)
}

View File

@ -1,10 +1,44 @@
import Entry from "@/components/pages/main/Entry";
import {SafeAreaView} from "react-native-safe-area-context"; import {SafeAreaView} from "react-native-safe-area-context";
import {Button, Image, Text, View} from "react-native-ui-lib";
import React from "react";
import {useRouter} from "expo-router";
export default function Screen() { export default function Screen() {
const router = useRouter()
return ( return (
<SafeAreaView> <SafeAreaView style={{flex: 1}}>
<Entry/> <View style={{flex: 1, padding: 21, paddingBottom: 45, paddingTop: "20%", alignItems: "center"}}>
<View>
<Image source={require("../../assets/images/splash.png")}/>
</View>
<View center gap-13>
<Text style={{fontSize: 40, fontFamily: 'Manrope_600SemiBold', marginLeft: 5}}>
Welcome to Cally
</Text>
<Text center color={"#919191"} style={{fontSize: 20, maxWidth: 250}}>
Lightening Mental Loads,
One Family at a Time
</Text>
</View>
<View flexG/>
<View width={"100%"}>
<Button
label="Continue"
marginT-50
labelStyle={{
fontFamily: "PlusJakartaSans_600SemiBold",
fontSize: 16,
}}
onPress={() => router.push("/(unauth)/get_started")}
style={{height: 50}}
backgroundColor="#fd1775"
/>
</View>
</View>
</SafeAreaView> </SafeAreaView>
) )
} }

View File

@ -0,0 +1,6 @@
import React from "react";
import {ResetPasswordPage} from "@/components/pages/main/ResetPasswordPage";
export default function Screen() {
return <ResetPasswordPage/>
}

6
app/(unauth)/sign_in.tsx Normal file
View File

@ -0,0 +1,6 @@
import SignInPage from "@/components/pages/main/SignInPage";
import React from "react";
export default function Screen() {
return <SignInPage/>
}

8
app/(unauth)/sign_up.tsx Normal file
View File

@ -0,0 +1,8 @@
import React from "react";
import SignUpPage from "@/components/pages/main/SignUpPage";
export default function Screen() {
return (
<SignUpPage/>
)
}

View File

@ -13,7 +13,7 @@ import {
import {useAuthContext} from "@/contexts/AuthContext"; import {useAuthContext} from "@/contexts/AuthContext";
import {CalendarEvent} from "@/components/pages/calendar/interfaces"; import {CalendarEvent} from "@/components/pages/calendar/interfaces";
import {Text} from "react-native-ui-lib"; import {Text} from "react-native-ui-lib";
import { isWithinInterval, subDays, addDays, compareAsc } from "date-fns"; import {addDays, compareAsc, isWithinInterval, subDays} from "date-fns";
interface EventCalendarProps { interface EventCalendarProps {
calendarHeight: number; calendarHeight: number;
@ -37,21 +37,10 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const setEventForEdit = useSetAtom(eventForEditAtom); const setEventForEdit = useSetAtom(eventForEditAtom);
const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom); const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom);
const [isRendering, setIsRendering] = useState(true);
const [offsetMinutes, setOffsetMinutes] = useState(getTotalMinutes()); const [offsetMinutes, setOffsetMinutes] = useState(getTotalMinutes());
const todaysDate = new Date(); const todaysDate = new Date();
useEffect(() => {
if (events && mode) {
setIsRendering(true);
const timeout = setTimeout(() => {
setIsRendering(false);
}, 10);
return () => clearTimeout(timeout);
}
}, [events, mode]);
const handlePressEvent = useCallback( const handlePressEvent = useCallback(
(event: CalendarEvent) => { (event: CalendarEvent) => {
if (mode === "day" || mode === "week") { if (mode === "day" || mode === "week") {
@ -129,7 +118,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
}, [mode]); }, [mode]);
const { enrichedEvents, filteredEvents } = useMemo(() => { const {enrichedEvents, filteredEvents} = useMemo(() => {
const startTime = Date.now(); // Start timer const startTime = Date.now(); // Start timer
const startOffset = mode === "month" ? 40 : mode === "week" ? 10 : 1; const startOffset = mode === "month" ? 40 : mode === "week" ? 10 : 1;
@ -156,16 +145,15 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
overlapCount: 0 overlapCount: 0
}); });
// Sort events for this dateKey from oldest to newest by event.start
acc[dateKey].sort((a, b) => compareAsc(a.start, b.start)); acc[dateKey].sort((a, b) => compareAsc(a.start, b.start));
return acc; return acc;
}, {} as Record<string, CalendarEvent[]>); }, {} as Record<string, CalendarEvent[]>);
const endTime = Date.now(); // End timer const endTime = Date.now();
console.log("memoizedEvents computation time:", endTime - startTime, "ms"); console.log("memoizedEvents computation time:", endTime - startTime, "ms");
return { enrichedEvents, filteredEvents }; return {enrichedEvents, filteredEvents};
}, [events, selectedDate, mode]); }, [events, selectedDate, mode]);
const renderCustomDateForMonth = (date: Date) => { const renderCustomDateForMonth = (date: Date) => {
@ -220,7 +208,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
setOffsetMinutes(getTotalMinutes()); setOffsetMinutes(getTotalMinutes());
}, [events, mode]); }, [events, mode]);
if (isLoading || isRendering) { if (isLoading) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#0000ff"/> <ActivityIndicator size="large" color="#0000ff"/>

View File

@ -103,6 +103,7 @@ export const ManuallyAddEventModal = () => {
const {mutateAsync: createEvent, isLoading: isAdding, isError} = useCreateEvent(); const {mutateAsync: createEvent, isLoading: isAdding, isError} = useCreateEvent();
const {data: members} = useGetFamilyMembers(true); const {data: members} = useGetFamilyMembers(true);
const titleRef = useRef<TextFieldRef>(null)
const isLoading = isDeleting || isAdding const isLoading = isDeleting || isAdding
@ -135,6 +136,14 @@ export const ManuallyAddEventModal = () => {
setRepeatInterval([]); setRepeatInterval([]);
}, [editEvent, selectedNewEventDate]); }, [editEvent, selectedNewEventDate]);
useEffect(() => {
if(show && !editEvent) {
setTimeout(() => {
titleRef?.current?.focus()
}, 500);
}
}, [selectedNewEventDate]);
if (!show) return null; if (!show) return null;
const formatDateTime = (date?: Date | string) => { const formatDateTime = (date?: Date | string) => {
@ -342,6 +351,7 @@ export const ManuallyAddEventModal = () => {
<ScrollView style={{minHeight: "85%"}}> <ScrollView style={{minHeight: "85%"}}>
<TextField <TextField
placeholder="Add event title" placeholder="Add event title"
ref={titleRef}
value={title} value={title}
onChangeText={(text) => { onChangeText={(text) => {
setTitle(text); setTitle(text);

View File

@ -1,11 +1,9 @@
import {Button, ButtonSize, Text, TextField, View} from "react-native-ui-lib"; import {Button, Text, TextField, View} from "react-native-ui-lib";
import React, {useState} from "react"; import React, {useState} from "react";
import {useSignIn} from "@/hooks/firebase/useSignIn";
import {StyleSheet} from "react-native"; import {StyleSheet} from "react-native";
import {useResetPassword} from "@/hooks/firebase/useResetPassword"; import {useResetPassword} from "@/hooks/firebase/useResetPassword";
import {isLoading} from "expo-font";
export const ResetPasswordPage = ({setTab}: { setTab: React.Dispatch<React.SetStateAction<"register" | "login" | "reset-password">> }) => { export const ResetPasswordPage = () => {
const [email, setEmail] = useState<string>(""); const [email, setEmail] = useState<string>("");
const {mutateAsync: resetPassword, error, isError, isLoading} = useResetPassword(); const {mutateAsync: resetPassword, error, isError, isLoading} = useResetPassword();

View File

@ -2,7 +2,7 @@ import {
Button, Button,
ButtonSize, ButtonSize,
Colors, Colors,
Dialog, KeyboardAwareScrollView,
LoaderScreen, LoaderScreen,
Text, Text,
TextField, TextField,
@ -13,29 +13,20 @@ import React, {useRef, useState} from "react";
import {useSignIn} from "@/hooks/firebase/useSignIn"; import {useSignIn} from "@/hooks/firebase/useSignIn";
import {KeyboardAvoidingView, Platform, StyleSheet} from "react-native"; import {KeyboardAvoidingView, Platform, StyleSheet} from "react-native";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import {useLoginWithQrCode} from "@/hooks/firebase/useLoginWithQrCode";
import {Camera, CameraView} from "expo-camera";
import KeyboardManager from "react-native-keyboard-manager"; import KeyboardManager from "react-native-keyboard-manager";
import {SafeAreaView} from "react-native-safe-area-context";
import {useRouter} from "expo-router";
KeyboardManager.setEnableAutoToolbar(true); KeyboardManager.setEnableAutoToolbar(true);
const SignInPage = ({ const SignInPage = () => {
setTab,
}: {
setTab: React.Dispatch<
React.SetStateAction<"register" | "login" | "reset-password">
>;
}) => {
const [email, setEmail] = useState<string>(""); const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>("");
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [showCameraDialog, setShowCameraDialog] = useState<boolean>(false);
const passwordRef = useRef<TextFieldRef>(null); const passwordRef = useRef<TextFieldRef>(null);
const {mutateAsync: signIn, error, isError, isLoading: isSigninIn} = useSignIn(); const {mutateAsync: signIn, error, isError, isLoading} = useSignIn();
const {mutateAsync: signInWithQrCode, isLoading: isQRCodeLoggingIn} = useLoginWithQrCode();
const isLoading = isSigninIn || isQRCodeLoggingIn const router = useRouter()
const handleSignIn = async () => { const handleSignIn = async () => {
await signIn({email, password}); await signIn({email, password});
@ -53,169 +44,117 @@ const SignInPage = ({
} }
}; };
const handleQrCodeScanned = async ({data}: { data: string }) => {
setShowCameraDialog(false);
try {
await signInWithQrCode({userId: data});
Toast.show({
type: "success",
text1: "Login successful with QR code!",
});
} catch (err) {
Toast.show({
type: "error",
text1: "Error logging in with QR code",
text2: `${err}`,
});
}
};
const getCameraPermissions = async (callback: () => void) => {
const {status} = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === "granted");
if (status === "granted") {
callback();
}
};
return ( return (
<View padding-10 centerV height={"100%%"} style={{justifyContent: "center"}}> <SafeAreaView style={{flex: 1}}>
<KeyboardAvoidingView <KeyboardAwareScrollView contentContainerStyle={{flexGrow: 1}} enableOnAndroid>
contentContainerStyle={{justifyContent: "center"}} <View style={{flex: 1, padding: 21, paddingBottom: 45, paddingTop: "20%"}}>
keyboardVerticalOffset={50} <View gap-13 width={"100%"} marginB-20>
behavior={Platform.OS === "ios" ? "padding" : "height"} <Text style={{fontSize: 40, fontFamily: 'Manrope_600SemiBold'}}>
> Jump back into Cally
<TextField </Text>
placeholder="Email" <Text color={"#919191"} style={{fontSize: 20}}>
keyboardType={"email-address"} Please enter your details.
returnKeyType={"next"} </Text>
textContentType={"emailAddress"} </View>
defaultValue={email}
onChangeText={setEmail}
style={styles.textfield}
autoComplete={"email"}
autoCorrect={false}
onSubmitEditing={() => {
// Move focus to the description field
passwordRef.current?.focus();
}}
/>
<TextField
ref={passwordRef}
placeholder="Password"
textContentType={"oneTimeCode"}
value={password}
onChangeText={setPassword}
secureTextEntry
style={styles.textfield}
autoCorrect={false}
/>
<Button
label="Log in"
marginT-50
labelStyle={{
fontFamily: "PlusJakartaSans_600SemiBold",
fontSize: 16,
}}
onPress={handleSignIn}
style={{marginBottom: 20, height: 50}}
backgroundColor="#fd1775"
/>
<Button
label="Log in with a QR Code"
labelStyle={{
fontFamily: "PlusJakartaSans_600SemiBold",
fontSize: 16,
}}
onPress={() => {
getCameraPermissions(() => setShowCameraDialog(true));
}}
style={{marginBottom: 20, height: 50}}
backgroundColor="#fd1775"
/>
{isError && ( <KeyboardAvoidingView style={{width: "100%"}}
<Text center style={{marginBottom: 20}}>{`${ contentContainerStyle={{justifyContent: "center"}}
error?.toString()?.split("]")?.[1] keyboardVerticalOffset={50}
}`}</Text> behavior={Platform.OS === "ios" ? "padding" : "height"}
)} >
<TextField
placeholder="Email"
keyboardType={"email-address"}
returnKeyType={"next"}
textContentType={"emailAddress"}
defaultValue={email}
onChangeText={setEmail}
style={styles.textfield}
autoComplete={"email"}
autoCorrect={false}
onSubmitEditing={() => {
// Move focus to the description field
passwordRef.current?.focus();
}}
/>
<TextField
ref={passwordRef}
placeholder="Password"
textContentType={"oneTimeCode"}
value={password}
onChangeText={setPassword}
secureTextEntry
style={styles.textfield}
autoCorrect={false}
/>
</KeyboardAvoidingView>
<View flexG/>
<View row centerH marginB-5 gap-5>
<Text style={styles.jakartaLight}>Don't have an account?</Text>
<Button <Button
onPress={() => setTab("register")} label="Log in"
label="Sign Up" marginT-50
labelStyle={[ labelStyle={{
styles.jakartaMedium, fontFamily: "PlusJakartaSans_600SemiBold",
{textDecorationLine: "none", color: "#fd1575"}, fontSize: 16,
]}
link
size={ButtonSize.xSmall}
padding-0
margin-0
text70
left
color="#fd1775"
/>
</View>
<View row centerH marginB-5 gap-5>
<Text text70>Forgot your password?</Text>
<Button
onPress={() => setTab("reset-password")}
label="Reset password"
labelStyle={[
styles.jakartaMedium,
{textDecorationLine: "none", color: "#fd1575"},
]}
link
size={ButtonSize.xSmall}
padding-0
margin-0
text70
left
avoidInnerPadding
color="#fd1775"
/>
</View>
</KeyboardAvoidingView>
{/* Camera Dialog */}
<Dialog
visible={showCameraDialog}
onDismiss={() => setShowCameraDialog(false)}
bottom
width="100%"
height="70%"
containerStyle={{padding: 15, backgroundColor: "white"}}
>
{hasPermission === null ? (
<Text>Requesting camera permissions...</Text>
) : !hasPermission ? (
<Text>No access to camera</Text>
) : (
<CameraView
style={{flex: 1, borderRadius: 15}}
onBarcodeScanned={handleQrCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}} }}
onPress={handleSignIn}
style={{marginBottom: 20, height: 50}}
backgroundColor="#fd1775"
/> />
)}
<Button
label="Cancel"
onPress={() => setShowCameraDialog(false)}
backgroundColor="#fd1775"
style={{margin: 10, marginBottom: 30}}
/>
</Dialog>
{isError && (
<Text center style={{marginBottom: 20}}>{`${
error?.toString()?.split("]")?.[1]
}`}</Text>
)}
{isLoading && ( <View row centerH marginB-5 gap-5>
<LoaderScreen overlay message={"Signing in..."} backgroundColor={Colors.white} color={Colors.grey40}/> <Text style={styles.jakartaLight}>Don't have an account?</Text>
)} <Button
onPress={() => router.replace("/(unauth)/sign_up")}
label="Sign Up"
labelStyle={[
styles.jakartaMedium,
{textDecorationLine: "none", color: "#fd1575"},
]}
link
size={ButtonSize.xSmall}
padding-0
margin-0
text70
left
color="#fd1775"
/>
</View>
</View> {/*<View row centerH marginB-5 gap-5>*/}
{/* <Text text70>Forgot your password?</Text>*/}
{/* <Button*/}
{/* onPress={() => router.replace("/(unauth)/sign_up")}*/}
{/* label="Reset password"*/}
{/* labelStyle={[*/}
{/* styles.jakartaMedium,*/}
{/* {textDecorationLine: "none", color: "#fd1575"},*/}
{/* ]}*/}
{/* link*/}
{/* size={ButtonSize.xSmall}*/}
{/* padding-0*/}
{/* margin-0*/}
{/* text70*/}
{/* left*/}
{/* avoidInnerPadding*/}
{/* color="#fd1775"*/}
{/* />*/}
{/*</View>*/}
{isLoading && (
<LoaderScreen overlay message={"Signing in..."} backgroundColor={Colors.white}
color={Colors.grey40}/>
)}
</View>
</KeyboardAwareScrollView>
</SafeAreaView>
); );
}; };

View File

@ -3,6 +3,9 @@ import {
Button, Button,
ButtonSize, ButtonSize,
Checkbox, Checkbox,
Colors,
KeyboardAwareScrollView,
LoaderScreen,
Text, Text,
TextField, TextField,
TextFieldRef, TextFieldRef,
@ -10,19 +13,15 @@ import {
View, View,
} from "react-native-ui-lib"; } from "react-native-ui-lib";
import {useSignUp} from "@/hooks/firebase/useSignUp"; import {useSignUp} from "@/hooks/firebase/useSignUp";
import {StyleSheet} from "react-native"; import {KeyboardAvoidingView, StyleSheet} from "react-native";
import {AntDesign} from "@expo/vector-icons"; import {AntDesign} from "@expo/vector-icons";
import KeyboardManager from "react-native-keyboard-manager"; import KeyboardManager from "react-native-keyboard-manager";
import {SafeAreaView} from "react-native-safe-area-context";
import {useRouter} from "expo-router";
KeyboardManager.setEnableAutoToolbar(true); KeyboardManager.setEnableAutoToolbar(true);
const SignUpPage = ({ const SignUpPage = () => {
setTab,
}: {
setTab: React.Dispatch<
React.SetStateAction<"register" | "login" | "reset-password">
>;
}) => {
const [email, setEmail] = useState<string>(""); const [email, setEmail] = useState<string>("");
const [firstName, setFirstName] = useState<string>(""); const [firstName, setFirstName] = useState<string>("");
const [lastName, setLastName] = useState<string>(""); const [lastName, setLastName] = useState<string>("");
@ -31,170 +30,196 @@ const SignUpPage = ({
const [isPasswordVisible, setIsPasswordVisible] = useState<boolean>(false); const [isPasswordVisible, setIsPasswordVisible] = useState<boolean>(false);
const [allowFaceID, setAllowFaceID] = useState<boolean>(false); const [allowFaceID, setAllowFaceID] = useState<boolean>(false);
const [acceptTerms, setAcceptTerms] = useState<boolean>(false); const [acceptTerms, setAcceptTerms] = useState<boolean>(false);
const {mutateAsync: signUp} = useSignUp(); const {mutateAsync: signUp, isLoading} = useSignUp();
const lnameRef = useRef<TextFieldRef>(null); const lnameRef = useRef<TextFieldRef>(null);
const emailRef = useRef<TextFieldRef>(null); const emailRef = useRef<TextFieldRef>(null);
const passwordRef = useRef<TextFieldRef>(null); const passwordRef = useRef<TextFieldRef>(null);
const router = useRouter()
const handleSignUp = async () => { const handleSignUp = async () => {
await signUp({email, password, firstName, lastName}); await signUp({email, password, firstName, lastName});
router.replace("/(unauth)/cal_sync")
}; };
return ( return (
<View height={"100%"} padding-15 marginT-30> <SafeAreaView style={{flex: 1}}>
<Text style={styles.title}>Get started with Cally</Text> <KeyboardAwareScrollView contentContainerStyle={{flexGrow: 1}} enableOnAndroid>
<Text style={styles.subtitle} marginT-15 color="#919191"> <View style={{flex: 1, padding: 21, paddingBottom: 45, paddingTop: "20%"}}>
Please enter your details. <View gap-13 width={"100%"} marginB-20>
</Text> <Text style={{fontSize: 40, fontFamily: 'Manrope_600SemiBold'}}>
<TextField Get started with Cally
marginT-30
autoFocus
placeholder="First name"
value={firstName}
onChangeText={setFirstName}
style={styles.textfield}
onSubmitEditing={() => {
lnameRef.current?.focus();
}}
blurOnSubmit={false}
accessibilityLabel="First name input"
accessibilityHint="Enter your first name"
accessible
returnKeyType="next"
textContentType="givenName"
importantForAccessibility="yes"
/>
<TextField
ref={lnameRef}
placeholder="Last name"
value={lastName}
onChangeText={setLastName}
style={styles.textfield}
onSubmitEditing={() => {
emailRef.current?.focus();
}}
blurOnSubmit={false}
accessibilityLabel="Last name input"
accessibilityHint="Enter your last name"
accessible
returnKeyType="next"
textContentType="familyName"
importantForAccessibility="yes"
/>
<TextField
placeholder="Email"
keyboardType={"email-address"}
returnKeyType={"next"}
textContentType={"emailAddress"}
defaultValue={email}
onChangeText={setEmail}
style={styles.textfield}
autoComplete={"email"}
autoCorrect={false}
ref={emailRef}
onSubmitEditing={() => {
passwordRef.current?.focus();
}}
/>
<View
centerV
style={[styles.textfield, {padding: 0, paddingHorizontal: 30}]}
>
<TextField
ref={passwordRef}
placeholder="Password"
style={styles.jakartaLight}
value={password}
onChangeText={setPassword}
secureTextEntry={!isPasswordVisible}
trailingAccessory={
<TouchableOpacity
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
>
<AntDesign
name={isPasswordVisible ? "eye" : "eyeo"}
size={24}
color="gray"
/>
</TouchableOpacity>
}
/>
</View>
<View gap-5 marginT-15>
<View row centerV>
<Checkbox
style={[styles.check]}
color="#919191"
value={allowFaceID}
onValueChange={(value) => {
setAllowFaceID(value);
}}
/>
<Text style={styles.jakartaLight} marginL-10>
Allow FaceID for login in future
</Text>
</View>
<View row centerV>
<Checkbox
style={styles.check}
color="#919191"
value={acceptTerms}
onValueChange={(value) => setAcceptTerms(value)}
/>
<View row>
<Text style={styles.jakartaLight} marginL-10>
I accept the
</Text> </Text>
<TouchableOpacity> <Text color={"#919191"} style={{fontSize: 20}}>
<Text text90 style={styles.jakartaMedium}> Please enter your details.
{" "} </Text>
terms and conditions </View>
<KeyboardAvoidingView style={{width: '100%'}}>
<TextField
marginT-30
autoFocus
placeholder="First name"
value={firstName}
onChangeText={setFirstName}
style={styles.textfield}
onSubmitEditing={() => {
lnameRef.current?.focus();
}}
blurOnSubmit={false}
accessibilityLabel="First name input"
accessibilityHint="Enter your first name"
accessible
returnKeyType="next"
textContentType="givenName"
importantForAccessibility="yes"
/>
<TextField
ref={lnameRef}
placeholder="Last name"
value={lastName}
onChangeText={setLastName}
style={styles.textfield}
onSubmitEditing={() => {
emailRef.current?.focus();
}}
blurOnSubmit={false}
accessibilityLabel="Last name input"
accessibilityHint="Enter your last name"
accessible
returnKeyType="next"
textContentType="familyName"
importantForAccessibility="yes"
/>
<TextField
placeholder="Email"
keyboardType={"email-address"}
returnKeyType={"next"}
textContentType={"emailAddress"}
defaultValue={email}
onChangeText={setEmail}
style={styles.textfield}
autoComplete={"email"}
autoCorrect={false}
ref={emailRef}
onSubmitEditing={() => {
passwordRef.current?.focus();
}}
/>
<View
centerV
style={[styles.textfield, {padding: 0, paddingHorizontal: 30}]}
>
<TextField
ref={passwordRef}
placeholder="Password"
style={styles.jakartaLight}
value={password}
onChangeText={setPassword}
secureTextEntry={!isPasswordVisible}
trailingAccessory={
<TouchableOpacity
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
>
<AntDesign
name={isPasswordVisible ? "eye" : "eyeo"}
size={24}
color="gray"
/>
</TouchableOpacity>
}
/>
</View>
</KeyboardAvoidingView>
<View gap-5 marginT-15>
<View row centerV>
<Checkbox
style={[styles.check]}
color="#919191"
value={allowFaceID}
onValueChange={(value) => {
setAllowFaceID(value);
}}
/>
<Text style={styles.jakartaLight} marginL-10>
Allow FaceID for login in future
</Text> </Text>
</TouchableOpacity> </View>
<Text style={styles.jakartaLight}> and </Text> <View row centerV>
<TouchableOpacity> <Checkbox
<Text text90 style={styles.jakartaMedium}> style={styles.check}
{" "} color="#919191"
privacy policy value={acceptTerms}
onValueChange={(value) => setAcceptTerms(value)}
/>
<View row style={{flexWrap: "wrap", marginLeft: 10}}>
<Text style={styles.jakartaLight}>
I accept the
</Text>
<TouchableOpacity>
<Text text90 style={styles.jakartaMedium}>
{" "}
terms and conditions
</Text>
</TouchableOpacity>
<Text style={styles.jakartaLight}> and </Text>
<TouchableOpacity>
<Text text90 style={styles.jakartaMedium}>
{" "}
privacy policy
</Text>
</TouchableOpacity>
</View>
</View>
</View>
<View flexG style={{minHeight: 50}}/>
<View>
<Button
label="Register"
disabled={!acceptTerms}
labelStyle={{
fontFamily: "PlusJakartaSans_600SemiBold",
fontSize: 16,
}}
onPress={handleSignUp}
backgroundColor={"#fd1775"}
style={{marginBottom: 0, height: 50}}
/>
<View row centerH marginT-10 marginB-2 gap-5>
<Text style={[styles.jakartaLight, {fontSize: 16, color: "#484848"}]} center>
Already have an account?
</Text> </Text>
</TouchableOpacity>
<Button
label="Log in"
labelStyle={[
styles.jakartaMedium,
{fontSize: 16, textDecorationLine: "none", color: "#fd1775"},
]}
flexS
margin-0
link
color="#fd1775"
size={ButtonSize.small}
text70
onPress={() => router.replace("/(unauth)/sign_in")}
/>
</View>
</View> </View>
</View> </View>
</View> </KeyboardAwareScrollView>
<View flex-1/>
<View style={styles.bottomView}>
<Button
label="Register"
labelStyle={{
fontFamily: "PlusJakartaSans_600SemiBold",
fontSize: 16,
}}
onPress={handleSignUp}
style={{marginBottom: 0, backgroundColor: "#fd1775", height: 50}}
/>
<View row centerH marginT-10 marginB-2 gap-5>
<Text style={[styles.jakartaLight, {fontSize: 16, color: "#484848"}]} center>
Already have an account?
</Text>
<Button {isLoading && (
label="Log in" <LoaderScreen overlay message={"Signing up..."} backgroundColor={Colors.white}
labelStyle={[ color={Colors.grey40}/>
styles.jakartaMedium, )}
{fontSize: 16, textDecorationLine: "none", color: "#fd1775"}, </SafeAreaView>
]}
flexS
margin-0
link
color="#fd1775"
size={ButtonSize.small}
text70
onPress={() => setTab("login")}
/>
</View>
</View>
</View>
); );
}; };
@ -211,8 +236,6 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
color: "#919191", color: "#919191",
}, },
//mora da se izmeni kako treba
bottomView: {marginTop: "auto", marginBottom: 30, marginTop: "auto"},
jakartaLight: { jakartaLight: {
fontFamily: "PlusJakartaSans_300Light", fontFamily: "PlusJakartaSans_300Light",
fontSize: 13, fontSize: 13,

View File

@ -1,5 +1,5 @@
import {AntDesign, Ionicons} from "@expo/vector-icons"; import {AntDesign, Ionicons} from "@expo/vector-icons";
import React, {useCallback, useEffect, useState} from "react"; import React, {useCallback, useState} from "react";
import {Button, Checkbox, Text, View} from "react-native-ui-lib"; import {Button, Checkbox, Text, View} from "react-native-ui-lib";
import {ActivityIndicator, ScrollView, StyleSheet} from "react-native"; import {ActivityIndicator, ScrollView, StyleSheet} from "react-native";
import {TouchableOpacity} from "react-native-gesture-handler"; import {TouchableOpacity} from "react-native-gesture-handler";
@ -9,56 +9,19 @@ import debounce from "debounce";
import AppleIcon from "@/assets/svgs/AppleIcon"; import AppleIcon from "@/assets/svgs/AppleIcon";
import GoogleIcon from "@/assets/svgs/GoogleIcon"; import GoogleIcon from "@/assets/svgs/GoogleIcon";
import OutlookIcon from "@/assets/svgs/OutlookIcon"; import OutlookIcon from "@/assets/svgs/OutlookIcon";
import * as AuthSession from "expo-auth-session";
import * as Google from "expo-auth-session/providers/google";
import * as WebBrowser from "expo-web-browser";
import {useFetchAndSaveGoogleEvents} from "@/hooks/useFetchAndSaveGoogleEvents";
import {useFetchAndSaveOutlookEvents} from "@/hooks/useFetchAndSaveOutlookEvents";
import {useFetchAndSaveAppleEvents} from "@/hooks/useFetchAndSaveAppleEvents";
import * as AppleAuthentication from "expo-apple-authentication";
import ExpoLocalization from "expo-localization/src/ExpoLocalization"; import ExpoLocalization from "expo-localization/src/ExpoLocalization";
import {colorMap} from "@/constants/colorMap"; import {colorMap} from "@/constants/colorMap";
import {useAtom} from "jotai"; import {useSetAtom} from "jotai";
import {settingsPageIndex} from "../calendar/atoms"; import {settingsPageIndex} from "../calendar/atoms";
import CalendarSettingsDialog from "./calendar_components/CalendarSettingsDialog"; import CalendarSettingsDialog from "./calendar_components/CalendarSettingsDialog";
import {useClearTokens} from "@/hooks/firebase/useClearTokens"; import {useClearTokens} from "@/hooks/firebase/useClearTokens";
import {useCalSync} from "@/hooks/useCalSync";
const googleConfig = {
androidClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
iosClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
webClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
scopes: [
"email",
"profile",
"https://www.googleapis.com/auth/calendar.events.owned",
],
extraParams: {
access_type: "offline",
},
};
const microsoftConfig = {
clientId: "13c79071-1066-40a9-9f71-b8c4b138b4af",
redirectUri: AuthSession.makeRedirectUri({path: "settings"}),
scopes: [
"openid",
"profile",
"email",
"offline_access",
"Calendars.ReadWrite",
"User.Read",
],
authorizationEndpoint:
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
tokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
};
const CalendarSettingsPage = () => { const CalendarSettingsPage = () => {
const {profileData} = useAuthContext(); const {profileData} = useAuthContext();
const [pageIndex, setPageIndex] = useAtom(settingsPageIndex); const setPageIndex = useSetAtom(settingsPageIndex);
const [firstDayOfWeek, setFirstDayOfWeek] = useState<string>( const [firstDayOfWeek, setFirstDayOfWeek] = useState<string>(
profileData?.firstDayOfWeek ?? profileData?.firstDayOfWeek ??
ExpoLocalization.getCalendars()[0].firstWeekday === 1 ExpoLocalization.getCalendars()[0].firstWeekday === 1
@ -80,8 +43,8 @@ const CalendarSettingsPage = () => {
setModalVisible(true); setModalVisible(true);
}; };
const handleConfirm = () => { const handleConfirm = async () => {
clearToken(selectedService, selectedEmail); await clearToken({email: selectedEmail, provider: selectedService});
setModalVisible(false); setModalVisible(false);
}; };
@ -98,196 +61,21 @@ const CalendarSettingsPage = () => {
const {mutateAsync: updateUserData} = useUpdateUserData(); const {mutateAsync: updateUserData} = useUpdateUserData();
const {mutateAsync: clearToken} = useClearTokens(); const {mutateAsync: clearToken} = useClearTokens();
const {mutateAsync: fetchAndSaveGoogleEvents, isLoading: isSyncingGoogle} =
useFetchAndSaveGoogleEvents();
const { const {
mutateAsync: fetchAndSaveOutlookEvents, isSyncingGoogle,
isLoading: isSyncingOutlook, isSyncingOutlook,
} = useFetchAndSaveOutlookEvents(); isConnectedToGoogle,
const {mutateAsync: fetchAndSaveAppleEvents, isLoading: isSyncingApple} = isConnectedToMicrosoft,
useFetchAndSaveAppleEvents(); isConnectedToApple,
handleAppleSignIn,
WebBrowser.maybeCompleteAuthSession(); isSyncingApple,
const [_, response, promptAsync] = Google.useAuthRequest(googleConfig); handleMicrosoftSignIn,
fetchAndSaveOutlookEvents,
useEffect(() => { fetchAndSaveGoogleEvents,
signInWithGoogle(); handleStartGoogleSignIn,
}, [response]); fetchAndSaveAppleEvents
} = useCalSync()
const signInWithGoogle = async () => {
try {
if (response?.type === "success") {
const { accessToken, refreshToken } = response?.authentication!;
const userInfoResponse = await fetch(
"https://www.googleapis.com/oauth2/v3/userinfo",
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
const userInfo = await userInfoResponse.json();
const googleMail = userInfo.email;
let googleAccounts = profileData?.googleAccounts || {};
const updatedGoogleAccounts = {
...googleAccounts,
[googleMail]: { accessToken, refreshToken },
};
await updateUserData({
newUserData: { googleAccounts: updatedGoogleAccounts },
});
await fetchAndSaveGoogleEvents({
token: accessToken,
email: googleMail,
});
}
} catch (error) {
console.error("Error during Google sign-in:", error);
}
};
const handleMicrosoftSignIn = async () => {
try {
console.log("Starting Microsoft sign-in...");
const authRequest = new AuthSession.AuthRequest({
clientId: microsoftConfig.clientId,
scopes: microsoftConfig.scopes,
redirectUri: microsoftConfig.redirectUri,
responseType: AuthSession.ResponseType.Code,
usePKCE: true, // Enable PKCE
});
console.log("Auth request created:", authRequest);
const authResult = await authRequest.promptAsync({
authorizationEndpoint: microsoftConfig.authorizationEndpoint,
});
console.log("Auth result:", authResult);
if (authResult.type === "success" && authResult.params?.code) {
const code = authResult.params.code;
console.log("Authorization code received:", code);
// Exchange authorization code for tokens
const tokenResponse = await fetch(microsoftConfig.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `client_id=${
microsoftConfig.clientId
}&redirect_uri=${encodeURIComponent(
microsoftConfig.redirectUri
)}&grant_type=authorization_code&code=${code}&code_verifier=${
authRequest.codeVerifier
}&scope=${encodeURIComponent(
"https://graph.microsoft.com/Calendars.ReadWrite offline_access User.Read"
)}`,
});
console.log("Token response status:", tokenResponse.status);
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error("Token exchange failed:", errorText);
return;
}
const tokenData = await tokenResponse.json();
console.log("Token data received:", tokenData);
if (tokenData?.access_token) {
console.log("Access token received, fetching user info...");
// Fetch user info from Microsoft Graph API to get the email
const userInfoResponse = await fetch(
"https://graph.microsoft.com/v1.0/me",
{
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
}
);
const userInfo = await userInfoResponse.json();
console.log("User info received:", userInfo);
if (userInfo.error) {
console.error("Error fetching user info:", userInfo.error);
} else {
const outlookMail = userInfo.mail || userInfo.userPrincipalName;
let microsoftAccounts = profileData?.microsoftAccounts;
const updatedMicrosoftAccounts = microsoftAccounts
? {...microsoftAccounts, [outlookMail]: tokenData.access_token}
: {[outlookMail]: tokenData.access_token};
await updateUserData({
newUserData: {microsoftAccounts: updatedMicrosoftAccounts},
});
await fetchAndSaveOutlookEvents(
tokenData.access_token,
outlookMail
);
console.log("User data updated successfully.");
}
}
} else {
console.warn("Authentication was not successful:", authResult);
}
} catch (error) {
console.error("Error during Microsoft sign-in:", error);
}
};
const handleAppleSignIn = async () => {
try {
console.log("Starting Apple Sign-in...");
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.EMAIL,
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
],
});
console.log("Apple sign-in result:", credential);
alert(JSON.stringify(credential))
const appleToken = credential.identityToken;
const appleMail = credential.email!;
if (appleToken) {
console.log("Apple ID token received. Fetch user info if needed...");
let appleAcounts = profileData?.appleAccounts;
const updatedAppleAccounts = appleAcounts
? {...appleAcounts, [appleMail]: appleToken}
: {[appleMail]: appleToken};
await updateUserData({
newUserData: {appleAccounts: updatedAppleAccounts},
});
console.log("User data updated with Apple ID token.");
await fetchAndSaveAppleEvents({token: appleToken, email: appleMail!});
} else {
console.warn(
"Apple authentication was not successful or email was hidden."
);
}
} catch (error) {
console.error("Error during Apple Sign-in:", error);
}
};
const debouncedUpdateUserData = useCallback( const debouncedUpdateUserData = useCallback(
debounce(async (color: string) => { debounce(async (color: string) => {
@ -331,37 +119,6 @@ const CalendarSettingsPage = () => {
debouncedUpdateUserData(color); debouncedUpdateUserData(color);
}; };
let isConnectedToGoogle = false;
if (profileData?.googleAccounts) {
Object.values(profileData?.googleAccounts).forEach((item) => {
if (item !== null) {
isConnectedToGoogle = true;
return;
}
});
}
let isConnectedToMicrosoft = false;
const microsoftAccounts = profileData?.microsoftAccounts;
if (microsoftAccounts) {
Object.values(profileData?.microsoftAccounts).forEach((item) => {
if (item !== null) {
isConnectedToMicrosoft = true;
return;
}
});
}
let isConnectedToApple = false;
if (profileData?.appleAccounts) {
Object.values(profileData?.appleAccounts).forEach((item) => {
if (item !== null) {
isConnectedToApple = true;
return;
}
});
}
return ( return (
<ScrollView> <ScrollView>
<TouchableOpacity onPress={() => setPageIndex(0)}> <TouchableOpacity onPress={() => setPageIndex(0)}>
@ -462,7 +219,7 @@ const CalendarSettingsPage = () => {
</Text> </Text>
<Button <Button
onPress={() => promptAsync()} onPress={() => handleStartGoogleSignIn()}
label={profileData?.googleAccounts ? "Connect another Google account" : "Connect Google account"} label={profileData?.googleAccounts ? "Connect another Google account" : "Connect Google account"}
labelStyle={styles.addCalLbl} labelStyle={styles.addCalLbl}
labelProps={{ labelProps={{

View File

@ -8,7 +8,7 @@ import {UserProfile} from "@/hooks/firebase/types/profileTypes";
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device'; import * as Device from 'expo-device';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { Platform } from 'react-native'; import {Platform} from 'react-native';
import {useQueryClient} from "react-query"; import {useQueryClient} from "react-query";
@ -24,6 +24,7 @@ interface IAuthContext {
profileType?: ProfileType, profileType?: ProfileType,
profileData?: UserProfile, profileData?: UserProfile,
setProfileData: (profileData: UserProfile) => void, setProfileData: (profileData: UserProfile) => void,
setRedirectOverride: (val: boolean) => void,
refreshProfileData: () => Promise<void> refreshProfileData: () => Promise<void>
} }
@ -50,11 +51,11 @@ async function registerForPushNotificationsAsync() {
} }
if (Device.isDevice) { if (Device.isDevice) {
const { status: existingStatus } = await Notifications.getPermissionsAsync(); const {status: existingStatus} = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus; let finalStatus = existingStatus;
if (existingStatus !== 'granted') { if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync(); const {status} = await Notifications.requestPermissionsAsync();
finalStatus = status; finalStatus = status;
} }
@ -72,7 +73,7 @@ async function registerForPushNotificationsAsync() {
} }
try { try {
const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data; const token = (await Notifications.getExpoPushTokenAsync({projectId})).data;
console.log('Push Token:', token); console.log('Push Token:', token);
return token; return token;
} catch (error) { } catch (error) {
@ -91,24 +92,28 @@ export const AuthContextProvider: FC<{ children: ReactNode }> = ({children}) =>
const [initializing, setInitializing] = useState(true); const [initializing, setInitializing] = useState(true);
const [profileType, setProfileType] = useState<ProfileType | undefined>(undefined); const [profileType, setProfileType] = useState<ProfileType | undefined>(undefined);
const [profileData, setProfileData] = useState<UserProfile | undefined>(undefined); const [profileData, setProfileData] = useState<UserProfile | undefined>(undefined);
const [redirectOverride, setRedirectOverride] = useState(false);
const {replace} = useRouter(); const {replace} = useRouter();
const ready = !initializing; const ready = !initializing;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const onAuthStateChangedHandler = async (authUser: FirebaseAuthTypes.User | null) => { const onAuthStateChangedHandler = async (authUser: FirebaseAuthTypes.User | null) => {
setUser(authUser); if (!redirectOverride) {
setUser(authUser);
if (authUser) { if (authUser) {
await refreshProfileData(authUser); await refreshProfileData(authUser);
const pushToken = await registerForPushNotificationsAsync(); const pushToken = await registerForPushNotificationsAsync();
if (pushToken) { if (pushToken) {
await savePushTokenToFirestore(authUser.uid, pushToken); await savePushTokenToFirestore(authUser.uid, pushToken);
}
} }
}
if (initializing) setInitializing(false); if (initializing) setInitializing(false);
}
}; };
const refreshProfileData = async (user?: FirebaseAuthTypes.User) => { const refreshProfileData = async (user?: FirebaseAuthTypes.User) => {
@ -152,12 +157,12 @@ export const AuthContextProvider: FC<{ children: ReactNode }> = ({children}) =>
}, [initializing]); }, [initializing]);
useEffect(() => { useEffect(() => {
if (ready && user) { if (ready && user && !redirectOverride) {
replace({pathname: "/(auth)/calendar"}); replace({pathname: "/(auth)/calendar"});
} else if (ready && !user) { } else if (ready && !user && !redirectOverride) {
replace({pathname: "/(unauth)"}); replace({pathname: "/(unauth)"});
} }
}, [user, ready]); }, [user, ready, redirectOverride]);
useEffect(() => { useEffect(() => {
const sub = Notifications.addNotificationReceivedListener(notification => { const sub = Notifications.addNotificationReceivedListener(notification => {
@ -175,7 +180,8 @@ export const AuthContextProvider: FC<{ children: ReactNode }> = ({children}) =>
} }
return ( return (
<AuthContext.Provider value={{user, profileType, profileData, setProfileData, refreshProfileData}}> <AuthContext.Provider
value={{user, profileType, profileData, setProfileData, refreshProfileData, setRedirectOverride}}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@ -1,12 +1,16 @@
import {useMutation} from "react-query"; import {useMutation} from "react-query";
import functions, {FirebaseFunctionsTypes} from '@react-native-firebase/functions'; import functions, {FirebaseFunctionsTypes} from '@react-native-firebase/functions';
import auth from "@react-native-firebase/auth"; import auth from "@react-native-firebase/auth";
import {useAuthContext} from "@/contexts/AuthContext";
export const useLoginWithQrCode = () => { export const useLoginWithQrCode = () => {
const {setRedirectOverride} = useAuthContext()
return useMutation({ return useMutation({
mutationKey: ["loginWithQrCode"], mutationKey: ["loginWithQrCode"],
mutationFn: async ({userId}: { userId: string }) => { mutationFn: async ({userId}: { userId: string }) => {
try { try {
setRedirectOverride(true)
const res = await functions().httpsCallable("generateCustomToken")({userId}) as FirebaseFunctionsTypes.HttpsCallableResult<{ const res = await functions().httpsCallable("generateCustomToken")({userId}) as FirebaseFunctionsTypes.HttpsCallableResult<{
token: string token: string
}> }>

View File

@ -1,44 +1,47 @@
import { useMutation } from "react-query"; import {useMutation} from "react-query";
import auth from "@react-native-firebase/auth"; import auth from "@react-native-firebase/auth";
import { ProfileType } from "@/contexts/AuthContext"; import {ProfileType, useAuthContext} from "@/contexts/AuthContext";
import { useSetUserData } from "./useSetUserData"; import {useSetUserData} from "./useSetUserData";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
import * as Localization from "expo-localization"; import * as Localization from "expo-localization";
export const useSignUp = () => { export const useSignUp = () => {
const { mutateAsync: setUserData } = useSetUserData(); const {setRedirectOverride} = useAuthContext()
const {mutateAsync: setUserData} = useSetUserData();
return useMutation({ return useMutation({
mutationKey: ["signUp"], mutationKey: ["signUp"],
mutationFn: async ({ mutationFn: async ({
email, email,
password, password,
firstName, firstName,
lastName, lastName,
}: { }: {
email: string; email: string;
password: string; password: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
}) => { }) => {
await auth() setRedirectOverride(true)
.createUserWithEmailAndPassword(email, password)
.then(async (res) => { await auth()
try { .createUserWithEmailAndPassword(email, password)
await setUserData({ .then(async (res) => {
newUserData: { try {
userType: ProfileType.PARENT, await setUserData({
firstName: firstName, newUserData: {
lastName: lastName, userType: ProfileType.PARENT,
familyId: uuidv4(), firstName: firstName,
timeZone: Localization.getCalendars()[0].timeZone, lastName: lastName,
}, familyId: uuidv4(),
customUser: res.user, timeZone: Localization.getCalendars()[0].timeZone,
}); },
} catch (error) { customUser: res.user,
console.error(error); });
} } catch (error) {
}); console.error(error);
}, }
}); });
},
});
}; };

288
hooks/useCalSync.ts Normal file
View File

@ -0,0 +1,288 @@
import {useAuthContext} from "@/contexts/AuthContext";
import {useEffect} from "react";
import {useUpdateUserData} from "@/hooks/firebase/useUpdateUserData";
import {useFetchAndSaveGoogleEvents} from "@/hooks/useFetchAndSaveGoogleEvents";
import {useFetchAndSaveOutlookEvents} from "@/hooks/useFetchAndSaveOutlookEvents";
import {useFetchAndSaveAppleEvents} from "@/hooks/useFetchAndSaveAppleEvents";
import * as WebBrowser from "expo-web-browser";
import * as Google from "expo-auth-session/providers/google";
import * as AuthSession from "expo-auth-session";
import * as AppleAuthentication from "expo-apple-authentication";
const googleConfig = {
androidClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
iosClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
webClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
scopes: [
"email",
"profile",
"https://www.googleapis.com/auth/calendar.events.owned",
],
extraParams: {
access_type: "offline",
},
};
const microsoftConfig = {
clientId: "13c79071-1066-40a9-9f71-b8c4b138b4af",
redirectUri: AuthSession.makeRedirectUri({path: "settings"}),
scopes: [
"openid",
"profile",
"email",
"offline_access",
"Calendars.ReadWrite",
"User.Read",
],
authorizationEndpoint:
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
tokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
};
export const useCalSync = () => {
const {profileData} = useAuthContext();
const {mutateAsync: updateUserData} = useUpdateUserData();
const {mutateAsync: fetchAndSaveGoogleEvents, isLoading: isSyncingGoogle} =
useFetchAndSaveGoogleEvents();
const {
mutateAsync: fetchAndSaveOutlookEvents,
isLoading: isSyncingOutlook,
} = useFetchAndSaveOutlookEvents();
const {mutateAsync: fetchAndSaveAppleEvents, isLoading: isSyncingApple} =
useFetchAndSaveAppleEvents();
WebBrowser.maybeCompleteAuthSession();
const [_, response, promptAsync] = Google.useAuthRequest(googleConfig);
useEffect(() => {
signInWithGoogle();
}, [response]);
const signInWithGoogle = async () => {
try {
if (response?.type === "success") {
const {accessToken, refreshToken} = response?.authentication!;
const userInfoResponse = await fetch(
"https://www.googleapis.com/oauth2/v3/userinfo",
{
headers: {Authorization: `Bearer ${accessToken}`},
}
);
const userInfo = await userInfoResponse.json();
const googleMail = userInfo.email;
let googleAccounts = profileData?.googleAccounts || {};
const updatedGoogleAccounts = {
...googleAccounts,
[googleMail]: {accessToken, refreshToken},
};
await updateUserData({
newUserData: {googleAccounts: updatedGoogleAccounts},
});
await fetchAndSaveGoogleEvents({
token: accessToken,
email: googleMail,
});
}
} catch (error) {
console.error("Error during Google sign-in:", error);
}
};
const handleMicrosoftSignIn = async () => {
try {
console.log("Starting Microsoft sign-in...");
const authRequest = new AuthSession.AuthRequest({
clientId: microsoftConfig.clientId,
scopes: microsoftConfig.scopes,
redirectUri: microsoftConfig.redirectUri,
responseType: AuthSession.ResponseType.Code,
usePKCE: true, // Enable PKCE
});
console.log("Auth request created:", authRequest);
const authResult = await authRequest.promptAsync({
authorizationEndpoint: microsoftConfig.authorizationEndpoint,
});
console.log("Auth result:", authResult);
if (authResult.type === "success" && authResult.params?.code) {
const code = authResult.params.code;
console.log("Authorization code received:", code);
// Exchange authorization code for tokens
const tokenResponse = await fetch(microsoftConfig.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `client_id=${
microsoftConfig.clientId
}&redirect_uri=${encodeURIComponent(
microsoftConfig.redirectUri
)}&grant_type=authorization_code&code=${code}&code_verifier=${
authRequest.codeVerifier
}&scope=${encodeURIComponent(
"https://graph.microsoft.com/Calendars.ReadWrite offline_access User.Read"
)}`,
});
console.log("Token response status:", tokenResponse.status);
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error("Token exchange failed:", errorText);
return;
}
const tokenData = await tokenResponse.json();
console.log("Token data received:", tokenData);
if (tokenData?.access_token) {
console.log("Access token received, fetching user info...");
// Fetch user info from Microsoft Graph API to get the email
const userInfoResponse = await fetch(
"https://graph.microsoft.com/v1.0/me",
{
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
}
);
const userInfo = await userInfoResponse.json();
console.log("User info received:", userInfo);
if (userInfo.error) {
console.error("Error fetching user info:", userInfo.error);
} else {
const outlookMail = userInfo.mail || userInfo.userPrincipalName;
let microsoftAccounts = profileData?.microsoftAccounts;
const updatedMicrosoftAccounts = microsoftAccounts
? {...microsoftAccounts, [outlookMail]: tokenData.access_token}
: {[outlookMail]: tokenData.access_token};
await updateUserData({
newUserData: {microsoftAccounts: updatedMicrosoftAccounts},
});
await fetchAndSaveOutlookEvents(
tokenData.access_token,
outlookMail
);
console.log("User data updated successfully.");
}
}
} else {
console.warn("Authentication was not successful:", authResult);
}
} catch (error) {
console.error("Error during Microsoft sign-in:", error);
}
};
const handleAppleSignIn = async () => {
try {
console.log("Starting Apple Sign-in...");
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.EMAIL,
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
],
});
console.log("Apple sign-in result:", credential);
alert(JSON.stringify(credential))
const appleToken = credential.identityToken;
const appleMail = credential.email!;
if (appleToken) {
console.log("Apple ID token received. Fetch user info if needed...");
let appleAcounts = profileData?.appleAccounts;
const updatedAppleAccounts = appleAcounts
? {...appleAcounts, [appleMail]: appleToken}
: {[appleMail]: appleToken};
await updateUserData({
newUserData: {appleAccounts: updatedAppleAccounts},
});
console.log("User data updated with Apple ID token.");
await fetchAndSaveAppleEvents({token: appleToken, email: appleMail!});
} else {
console.warn(
"Apple authentication was not successful or email was hidden."
);
}
} catch (error) {
console.error("Error during Apple Sign-in:", error);
}
};
let isConnectedToGoogle = false;
if (profileData?.googleAccounts) {
Object.values(profileData?.googleAccounts).forEach((item) => {
if (item !== null) {
isConnectedToGoogle = true;
return;
}
});
}
let isConnectedToMicrosoft = false;
const microsoftAccounts = profileData?.microsoftAccounts;
if (microsoftAccounts) {
Object.values(profileData?.microsoftAccounts).forEach((item) => {
if (item !== null) {
isConnectedToMicrosoft = true;
return;
}
});
}
let isConnectedToApple = false;
if (profileData?.appleAccounts) {
Object.values(profileData?.appleAccounts).forEach((item) => {
if (item !== null) {
isConnectedToApple = true;
return;
}
});
}
return {
handleAppleSignIn,
handleMicrosoftSignIn,
handleGoogleSignIn: signInWithGoogle,
handleStartGoogleSignIn: promptAsync,
fetchAndSaveOutlookEvents,
fetchAndSaveAppleEvents,
fetchAndSaveGoogleEvents,
isConnectedToApple,
isConnectedToMicrosoft,
isConnectedToGoogle,
isSyncingOutlook,
isSyncingGoogle,
isSyncingApple
}
}