Merge remote-tracking branch 'origin/main' into dev

This commit is contained in:
ivic00
2024-11-30 18:02:28 +01:00
53 changed files with 3816 additions and 2115 deletions

6
.idea/git_toolbox_blame.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxBlameSettings">
<option name="version" value="2" />
</component>
</project>

View File

@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="t" enabled="true" level="TEXT ATTRIBUTES" enabled_by_default="true" editorAttributes="CONSIDERATION_ATTRIBUTES" />
</profile>
</component>

6
.idea/jsLinters/eslint.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

View File

@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">"Cally "</string> <string name="app_name">\"Cally \"</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string> <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string> <string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_system_ui_user_interface_style" translatable="false">light</string> <string name="expo_system_ui_user_interface_style" translatable="false">light</string>

View File

@ -13,11 +13,14 @@
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": true,
"bundleIdentifier": "com.cally.app", "bundleIdentifier": "com.cally.app",
"googleServicesFile": "./ios/GoogleService-Info.plist", "googleServicesFile": "./ios/GoogleService-Info.plist",
"buildNumber": "74", "buildNumber": "74",
"usesAppleSignIn": true "usesAppleSignIn": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {

View File

@ -1,15 +1,9 @@
import React, { useEffect } from "react"; import React from "react";
import { Drawer } from "expo-router/drawer"; import {Drawer} from "expo-router/drawer";
import { useSignOut } from "@/hooks/firebase/useSignOut"; import {useSignOut} from "@/hooks/firebase/useSignOut";
import { DrawerContentScrollView } from "@react-navigation/drawer"; import {DrawerContentScrollView, DrawerNavigationOptions, DrawerNavigationProp} from "@react-navigation/drawer";
import { import {Button, ButtonSize, Text, TouchableOpacity, View,} from "react-native-ui-lib";
Button, import {ImageBackground, StyleSheet} from "react-native";
ButtonSize,
Text,
TouchableOpacity,
View,
} from "react-native-ui-lib";
import { ImageBackground, StyleSheet } from "react-native";
import DrawerButton from "@/components/shared/DrawerButton"; import DrawerButton from "@/components/shared/DrawerButton";
import NavGroceryIcon from "@/assets/svgs/NavGroceryIcon"; import NavGroceryIcon from "@/assets/svgs/NavGroceryIcon";
import NavToDosIcon from "@/assets/svgs/NavToDosIcon"; import NavToDosIcon from "@/assets/svgs/NavToDosIcon";
@ -17,7 +11,7 @@ import NavBrainDumpIcon from "@/assets/svgs/NavBrainDumpIcon";
import NavCalendarIcon from "@/assets/svgs/NavCalendarIcon"; import NavCalendarIcon from "@/assets/svgs/NavCalendarIcon";
import NavSettingsIcon from "@/assets/svgs/NavSettingsIcon"; import NavSettingsIcon from "@/assets/svgs/NavSettingsIcon";
import ViewSwitch from "@/components/pages/(tablet_pages)/ViewSwitch"; import ViewSwitch from "@/components/pages/(tablet_pages)/ViewSwitch";
import { useSetAtom } from "jotai"; import {useSetAtom} from "jotai";
import { import {
isFamilyViewAtom, isFamilyViewAtom,
settingsPageIndex, settingsPageIndex,
@ -26,54 +20,92 @@ import {
} from "@/components/pages/calendar/atoms"; } from "@/components/pages/calendar/atoms";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import * as Device from "expo-device"; import * as Device from "expo-device";
import { DeviceType } from "expo-device"; import {DeviceType} from "expo-device";
import FeedbackNavIcon from "@/assets/svgs/FeedbackNavIcon"; import FeedbackNavIcon from "@/assets/svgs/FeedbackNavIcon";
import DrawerIcon from "@/assets/svgs/DrawerIcon"; import DrawerIcon from "@/assets/svgs/DrawerIcon";
import {RouteProp} from "@react-navigation/core";
type DrawerParamList = {
index: undefined;
calendar: undefined;
todos: undefined;
};
type NavigationProp = DrawerNavigationProp<DrawerParamList>;
interface ViewSwitchProps {
navigation: NavigationProp;
}
interface HeaderRightProps {
routeName: keyof DrawerParamList;
navigation: NavigationProp;
}
const MemoizedViewSwitch = React.memo<ViewSwitchProps>(({navigation}) => (
<View marginR-16>
<ViewSwitch navigation={navigation}/>
</View>
));
const HeaderRight = React.memo<HeaderRightProps>(({routeName, navigation}) => {
const showViewSwitch = ["calendar", "todos", "index"].includes(routeName);
if (Device.deviceType !== DeviceType.TABLET || !showViewSwitch) {
return null;
}
return <MemoizedViewSwitch navigation={navigation}/>;
});
export default function TabLayout() { export default function TabLayout() {
const { mutateAsync: signOut } = useSignOut(); const {mutateAsync: signOut} = useSignOut();
const setIsFamilyView = useSetAtom(isFamilyViewAtom); const setIsFamilyView = useSetAtom(isFamilyViewAtom);
const setPageIndex = useSetAtom(settingsPageIndex); const setPageIndex = useSetAtom(settingsPageIndex);
const setUserView = useSetAtom(userSettingsView); const setUserView = useSetAtom(userSettingsView);
const setToDosIndex = useSetAtom(toDosPageIndex); const setToDosIndex = useSetAtom(toDosPageIndex);
return ( const screenOptions = ({
<Drawer navigation,
initialRouteName={"index"} route,
detachInactiveScreens }: {
screenOptions={({ navigation, route }) => ({ navigation: DrawerNavigationProp<DrawerParamList>;
route: RouteProp<DrawerParamList>;
}): DrawerNavigationOptions => ({
headerShown: true, headerShown: true,
headerTitleAlign: headerTitleAlign: Device.deviceType === DeviceType.TABLET ? "left" : "center",
Device.deviceType === DeviceType.TABLET ? "left" : "center",
headerTitleStyle: { headerTitleStyle: {
fontFamily: "Manrope_600SemiBold", fontFamily: "Manrope_600SemiBold",
fontSize: Device.deviceType === DeviceType.TABLET ? 22 : 17, fontSize: Device.deviceType === DeviceType.TABLET ? 22 : 17,
}, },
headerLeft: (props) => ( headerLeft: () => (
<TouchableOpacity <TouchableOpacity
onPress={navigation.toggleDrawer} onPress={navigation.toggleDrawer}
style={{ marginLeft: 16 }} style={{marginLeft: 16}}
> >
<DrawerIcon /> <DrawerIcon/>
</TouchableOpacity> </TouchableOpacity>
), ),
headerRight: () => { headerRight: () => {
// Only show ViewSwitch on calendar and todos pages const showViewSwitch = ["calendar", "todos", "index"].includes(route.name);
const showViewSwitch = ["calendar", "todos", "index"].includes(
route.name if (Device.deviceType !== DeviceType.TABLET || !showViewSwitch) {
); return null;
return Device.deviceType === DeviceType.TABLET && showViewSwitch ? ( }
<View marginR-16>
<ViewSwitch navigation={navigation} /> return <MemoizedViewSwitch navigation={navigation}/>;
</View>
) : null;
}, },
drawerStyle: { drawerStyle: {
width: Device.deviceType === DeviceType.TABLET ? "30%" : "90%", width: Device.deviceType === DeviceType.TABLET ? "30%" : "90%",
backgroundColor: "#f9f8f7", backgroundColor: "#f9f8f7",
height: "100%", height: "100%",
}, },
})} })
return (
<Drawer
initialRouteName={"index"}
detachInactiveScreens
screenOptions={screenOptions}
drawerContent={(props) => { drawerContent={(props) => {
return ( return (
<DrawerContentScrollView {...props} style={{}}> <DrawerContentScrollView {...props} style={{}}>
@ -95,7 +127,7 @@ export default function TabLayout() {
paddingHorizontal: 30, paddingHorizontal: 30,
}} }}
> >
<View style={{ flex: 1, paddingRight: 5 }}> <View style={{flex: 1, paddingRight: 5}}>
<DrawerButton <DrawerButton
title={"Calendar"} title={"Calendar"}
color="rgb(7, 184, 199)" color="rgb(7, 184, 199)"
@ -107,7 +139,7 @@ export default function TabLayout() {
setUserView(true); setUserView(true);
setIsFamilyView(false); setIsFamilyView(false);
}} }}
icon={<NavCalendarIcon />} icon={<NavCalendarIcon/>}
/> />
<DrawerButton <DrawerButton
color="#50be0c" color="#50be0c"
@ -120,7 +152,7 @@ export default function TabLayout() {
setUserView(true); setUserView(true);
setIsFamilyView(false); setIsFamilyView(false);
}} }}
icon={<NavGroceryIcon />} icon={<NavGroceryIcon/>}
/> />
<DrawerButton <DrawerButton
color="#ea156d" color="#ea156d"
@ -133,10 +165,10 @@ export default function TabLayout() {
setUserView(true); setUserView(true);
setIsFamilyView(false); setIsFamilyView(false);
}} }}
icon={<FeedbackNavIcon />} icon={<FeedbackNavIcon/>}
/> />
</View> </View>
<View style={{ flex: 1, paddingRight: 0 }}> <View style={{flex: 1, paddingRight: 0}}>
{/*<DrawerButton {/*<DrawerButton
color="#fd1775" color="#fd1775"
title={"My Reminders"} title={"My Reminders"}
@ -161,7 +193,7 @@ export default function TabLayout() {
setUserView(true); setUserView(true);
setIsFamilyView(false); setIsFamilyView(false);
}} }}
icon={<NavToDosIcon />} icon={<NavToDosIcon/>}
/> />
<DrawerButton <DrawerButton
color="#e0ca03" color="#e0ca03"
@ -174,7 +206,7 @@ export default function TabLayout() {
setUserView(true); setUserView(true);
setIsFamilyView(false); setIsFamilyView(false);
}} }}
icon={<NavBrainDumpIcon />} icon={<NavBrainDumpIcon/>}
/> />
<DrawerButton <DrawerButton
color="#e0ca03" color="#e0ca03"
@ -212,12 +244,12 @@ export default function TabLayout() {
backgroundColor="#ededed" backgroundColor="#ededed"
width={60} width={60}
height={60} height={60}
style={{ borderRadius: 50 }} style={{borderRadius: 50}}
marginR-10 marginR-10
centerV centerV
centerH centerH
> >
<NavSettingsIcon /> <NavSettingsIcon/>
</View> </View>
)} )}
backgroundColor="white" backgroundColor="white"
@ -225,7 +257,7 @@ export default function TabLayout() {
paddingV-30 paddingV-30
marginH-30 marginH-30
borderRadius={18.55} borderRadius={18.55}
style={{ elevation: 0 }} style={{elevation: 0}}
/> />
<Button <Button
@ -266,7 +298,7 @@ export default function TabLayout() {
Device.deviceType === DeviceType.TABLET Device.deviceType === DeviceType.TABLET
? "Family Calendar" ? "Family Calendar"
: "Calendar", : "Calendar",
drawerItemStyle: { display: "none" }, drawerItemStyle: {display: "none"},
}} }}
/> />
<Drawer.Screen <Drawer.Screen
@ -316,15 +348,15 @@ export default function TabLayout() {
/> />
<Drawer.Screen <Drawer.Screen
name="feedback" name="feedback"
options={{ drawerLabel: "Feedback", title: "Feedback" }} options={{drawerLabel: "Feedback", title: "Feedback"}}
/> />
</Drawer> </Drawer>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
signOut: { fontFamily: "Poppins_500Medium", fontSize: 15 }, signOut: {fontFamily: "Poppins_500Medium", fontSize: 15},
label: { fontFamily: "Poppins_400Medium", fontSize: 15 }, label: {fontFamily: "Poppins_400Medium", fontSize: 15},
title: { title: {
fontSize: 26.13, fontSize: 26.13,
fontFamily: "Manrope_600SemiBold", fontFamily: "Manrope_600SemiBold",

View File

@ -1,79 +1,25 @@
import React, { useState } from "react"; import React from "react";
import { ScrollView, RefreshControl, View } from "react-native"; import {RefreshControl, ScrollView, View} from "react-native";
import { useAtom } from "jotai";
import CalendarPage from "@/components/pages/calendar/CalendarPage"; import CalendarPage from "@/components/pages/calendar/CalendarPage";
import { refreshTriggerAtom } from "@/components/pages/calendar/atoms";
import { colorMap } from "@/constants/colorMap";
import TabletCalendarPage from "@/components/pages/(tablet_pages)/calendar/TabletCalendarPage"; import TabletCalendarPage from "@/components/pages/(tablet_pages)/calendar/TabletCalendarPage";
import { DeviceType } from "expo-device";
import * as Device from "expo-device"; import * as Device from "expo-device";
import { useCalSync } from "@/hooks/useCalSync"; import {DeviceType} from "expo-device";
import Toast from "react-native-toast-message"; import {useCalSync} from "@/hooks/useCalSync";
import {colorMap} from "@/constants/colorMap";
export default function Screen() { export default function Screen() {
const [refreshing, setRefreshing] = useState(false); const isTablet = Device.deviceType === DeviceType.TABLET;
const [shouldRefresh, setShouldRefresh] = useAtom(refreshTriggerAtom); const {resyncAllCalendars, isSyncing} = useCalSync();
const isTablet: boolean = Device.deviceType === DeviceType.TABLET;
const {
isConnectedToGoogle,
isConnectedToMicrosoft,
isConnectedToApple,
resyncAllCalendars,
isSyncing,
} = useCalSync();
const onRefresh = React.useCallback(async () => { const onRefresh = React.useCallback(async () => {
setRefreshing(true);
const minimumDelay = new Promise((resolve) => setTimeout(resolve, 1000));
try { try {
if (isConnectedToGoogle || isConnectedToMicrosoft || isConnectedToApple) { await resyncAllCalendars();
await Promise.all([resyncAllCalendars(), minimumDelay]);
} else {
await minimumDelay;
}
} catch (error) { } catch (error) {
console.error("Refresh failed:", error); console.error("Refresh failed:", error);
} finally {
setRefreshing(false);
setShouldRefresh((prev) => !prev);
} }
}, [ }, [resyncAllCalendars]);
resyncAllCalendars,
isConnectedToGoogle,
isConnectedToMicrosoft,
isConnectedToApple,
]);
return ( const refreshControl = (
<View style={{ flex: 1 }}>
<View style={{ flex: 1, zIndex: 0 }}>
{Device.deviceType === DeviceType.TABLET ? (
<TabletCalendarPage />
) : (
<CalendarPage />
)}
</View>
<ScrollView
style={{
position: "absolute",
top: 0,
left: isTablet ? "15%" : 0,
height: isTablet ? "9%" : "4%",
width: isTablet ? "62%" : "100%",
zIndex: 50,
backgroundColor: "transparent",
}}
contentContainerStyle={{
flex: 1,
justifyContent: "center",
paddingRight: 200,
}}
refreshControl={
<RefreshControl <RefreshControl
colors={[ colors={[
colorMap.pink, colorMap.pink,
@ -83,23 +29,58 @@ export default function Screen() {
colorMap.teal, colorMap.teal,
]} ]}
tintColor={colorMap.pink} tintColor={colorMap.pink}
progressBackgroundColor={"white"} progressBackgroundColor="white"
refreshing={refreshing || isSyncing} refreshing={isSyncing}
onRefresh={onRefresh} onRefresh={onRefresh}
style={isTablet ? {
position: "absolute",
left: "50%",
transform: [{translateX: -20}],
} : undefined}
/>
);
if (isTablet) {
return (
<View style={{flex: 1}}>
<View style={{flex: 1, zIndex: 0}}>
<TabletCalendarPage/>
</View>
<ScrollView
style={{ style={{
position: "absolute", position: "absolute",
left: "50%", // Position at screen center top: 0,
transform: [ left: "15%",
// Offset by half its own width height: "9%",
{ translateX: -20 }, // Assuming the refresh control is ~40px wide width: "62%",
], zIndex: 50,
backgroundColor: "transparent",
}} }}
/> contentContainerStyle={{
} flex: 1,
justifyContent: "center",
paddingRight: 200,
}}
refreshControl={refreshControl}
bounces={true} bounces={true}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
pointerEvents={refreshing || isSyncing ? "auto" : "none"} pointerEvents={isSyncing ? "auto" : "none"}
/> />
</View> </View>
); );
}
return (
<ScrollView
style={{flex: 1, height: "100%"}}
contentContainerStyle={{flex: 1, height: "100%"}}
refreshControl={refreshControl}
bounces={true}
showsVerticalScrollIndicator={false}
>
<View style={{flex: 1}}>
<CalendarPage/>
</View>
</ScrollView>
);
} }

View File

@ -1,24 +1,15 @@
import TabletChoresPage from "@/components/pages/(tablet_pages)/chores/TabletChoresPage"; import TabletChoresPage from "@/components/pages/(tablet_pages)/chores/TabletChoresPage";
import AddChore from "@/components/pages/todos/AddChore";
import ProgressCard from "@/components/pages/todos/ProgressCard";
import ToDoItem from "@/components/pages/todos/ToDoItem";
import ToDosList from "@/components/pages/todos/ToDosList";
import ToDosPage from "@/components/pages/todos/ToDosPage"; import ToDosPage from "@/components/pages/todos/ToDosPage";
import HeaderTemplate from "@/components/shared/HeaderTemplate"; import {ToDosContextProvider} from "@/contexts/ToDosContext";
import { useAuthContext } from "@/contexts/AuthContext";
import { ToDosContextProvider, useToDosContext } from "@/contexts/ToDosContext";
import { AntDesign } from "@expo/vector-icons";
import { ScrollView } from "react-native-gesture-handler";
import { Button, ButtonSize, View, Text, Constants } from "react-native-ui-lib";
import * as Device from "expo-device"; import * as Device from "expo-device";
export default function Screen() { export default function Screen() {
return ( return (
<ToDosContextProvider> <ToDosContextProvider>
{Device.deviceType === Device.DeviceType.TABLET ? ( {Device.deviceType === Device.DeviceType.TABLET ? (
<TabletChoresPage /> <TabletChoresPage/>
) : ( ) : (
<ToDosPage /> <ToDosPage/>
)} )}
</ToDosContextProvider> </ToDosContextProvider>
); );

View File

@ -1,43 +1,21 @@
import { SegmentedControl, View } from "react-native-ui-lib"; import { SegmentedControl, View } from "react-native-ui-lib";
import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import React, { memo, useCallback } from "react";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { NavigationProp, useNavigationState } from "@react-navigation/native"; import { NavigationProp, useNavigationState } from "@react-navigation/native";
const ViewSwitch = memo(function ViewSwitch({ interface ViewSwitchProps {
navigation, navigation: NavigationProp<any>;
}: { }
navigation: any;
}) {
const isNavigating = useRef(false);
const navigationState = useNavigationState((state) => state);
const [selectedIndex, setSelectedIndex] = useState(
navigationState.index === 6 ? 1 : 0
);
// Sync the selected index with navigation state const ViewSwitch = memo(function ViewSwitch({ navigation }: ViewSwitchProps) {
useEffect(() => { const currentIndex = useNavigationState((state) => state.index === 6 ? 1 : 0);
const newIndex = navigationState.index === 6 ? 1 : 0;
if (selectedIndex !== newIndex) {
setSelectedIndex(newIndex);
}
isNavigating.current = false;
}, [navigationState.index]);
const handleSegmentChange = useCallback( const handleSegmentChange = useCallback(
(index: number) => { (index: number) => {
if (isNavigating.current) return; if (index === currentIndex) return;
if (index === selectedIndex) return;
isNavigating.current = true;
setSelectedIndex(index);
// Delay navigation slightly to allow the segment control to update
requestAnimationFrame(() => {
navigation.navigate(index === 0 ? "calendar" : "todos"); navigation.navigate(index === 0 ? "calendar" : "todos");
});
console.log(selectedIndex)
}, },
[navigation, selectedIndex] [navigation, currentIndex]
); );
return ( return (
@ -56,12 +34,11 @@ const ViewSwitch = memo(function ViewSwitch({
outlineColor="white" outlineColor="white"
outlineWidth={3} outlineWidth={3}
onChangeIndex={handleSegmentChange} onChangeIndex={handleSegmentChange}
initialIndex={selectedIndex} initialIndex={currentIndex}
/> />
</View> </View>
); );
}); });
export default ViewSwitch;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
@ -87,3 +64,5 @@ const styles = StyleSheet.create({
fontFamily: "Manrope_600SemiBold", fontFamily: "Manrope_600SemiBold",
}, },
}); });
export default ViewSwitch;

View File

@ -1,9 +1,7 @@
import React, { useEffect } from "react"; import React, {useEffect} from "react";
import { View, Text } from "react-native-ui-lib"; import {Text, View} from "react-native-ui-lib";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import TabletContainer from "../tablet_components/TabletContainer"; import TabletContainer from "../tablet_components/TabletContainer";
import ToDosPage from "../../todos/ToDosPage";
import ToDosList from "../../todos/ToDosList";
import SingleUserChoreList from "./SingleUserChoreList"; import SingleUserChoreList from "./SingleUserChoreList";
import { useGetFamilyMembers } from "@/hooks/firebase/useGetFamilyMembers"; import { useGetFamilyMembers } from "@/hooks/firebase/useGetFamilyMembers";
import { ImageBackground, StyleSheet } from "react-native"; import { ImageBackground, StyleSheet } from "react-native";
@ -12,7 +10,7 @@ import { ScrollView } from "react-native-gesture-handler";
import { ProfileType } from "@/contexts/AuthContext"; import { ProfileType } from "@/contexts/AuthContext";
const TabletChoresPage = () => { const TabletChoresPage = () => {
const { data: users } = useGetFamilyMembers(); const {data: users} = useGetFamilyMembers();
// Function to lock the screen orientation to landscape // Function to lock the screen orientation to landscape
const lockScreenOrientation = async () => { const lockScreenOrientation = async () => {
await ScreenOrientation.lockAsync( await ScreenOrientation.lockAsync(
@ -34,7 +32,7 @@ const TabletChoresPage = () => {
<ScrollView horizontal> <ScrollView horizontal>
<View row gap-25 padding-25> <View row gap-25 padding-25>
{users {users
?.filter(member => member.userType !== ProfileType.FAMILY_DEVICE) ?.filter((member) => member.userType !== ProfileType.FAMILY_DEVICE)
.map((user, index) => ( .map((user, index) => (
<View key={index}> <View key={index}>
<View row centerV> <View row centerV>

View File

@ -3,7 +3,6 @@ import React from "react";
import { useBrainDumpContext } from "@/contexts/DumpContext"; import { useBrainDumpContext } from "@/contexts/DumpContext";
import { FlatList } from "react-native"; import { FlatList } from "react-native";
import BrainDumpItem from "./DumpItem"; import BrainDumpItem from "./DumpItem";
import LinearGradient from "react-native-linear-gradient";
const DumpList = (props: { searchText: string }) => { const DumpList = (props: { searchText: string }) => {
const { brainDumps } = useBrainDumpContext(); const { brainDumps } = useBrainDumpContext();

View File

@ -1,31 +1,35 @@
import React, { memo } from "react"; import React, {memo} from "react";
import { import {Button, Picker, PickerModes, SegmentedControl, Text, View} from "react-native-ui-lib";
Button, import {MaterialIcons} from "@expo/vector-icons";
Picker, import {months} from "./constants";
PickerModes, import {StyleSheet} from "react-native";
SegmentedControl, import {useAtom} from "jotai";
Text, import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms";
View, import {format, isSameDay} from "date-fns";
} from "react-native-ui-lib"; import * as Device from "expo-device";
import { MaterialIcons } from "@expo/vector-icons"; import {Mode} from "react-native-big-calendar";
import { modeMap, months } from "./constants"; import { FontAwesome5 } from '@expo/vector-icons';
import { StyleSheet } from "react-native";
import { useAtom } from "jotai";
import { modeAtom, selectedDateAtom } from "@/components/pages/calendar/atoms";
import { format, isSameDay } from "date-fns";
import { useAuthContext } from "@/contexts/AuthContext";
import {useIsMutating} from "react-query";
export const CalendarHeader = memo(() => { export const CalendarHeader = memo(() => {
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom); const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const [mode, setMode] = useAtom(modeAtom); const [mode, setMode] = useAtom(modeAtom);
const { profileData } = useAuthContext(); const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const segments = isTablet
? [{label: "D"}, {label: "W"}, {label: "M"}]
: [{label: "D"}, {label: "3D"}, {label: "M"}];
const handleSegmentChange = (index: number) => { const handleSegmentChange = (index: number) => {
const selectedMode = modeMap.get(index); let selectedMode: Mode;
if (isTablet) {
selectedMode = ["day", "week", "month"][index] as Mode;
} else {
selectedMode = ["day", "3days", "month"][index] as Mode;
}
if (selectedMode) { if (selectedMode) {
setTimeout(() => { setTimeout(() => {
setMode(selectedMode as "day" | "week" | "month"); setMode(selectedMode as "day" | "week" | "month" | "3days");
}, 150); }, 150);
} }
}; };
@ -41,6 +45,24 @@ export const CalendarHeader = memo(() => {
const isSelectedDateToday = isSameDay(selectedDate, new Date()); const isSelectedDateToday = isSameDay(selectedDate, new Date());
const getInitialIndex = () => {
if (isTablet) {
switch (mode) {
case "day": return 0;
case "week": return 1;
case "month": return 2;
default: return 1;
}
} else {
switch (mode) {
case "day": return 0;
case "3days": return 1;
case "month": return 2;
default: return 1;
}
}
};
return ( return (
<View <View
style={{ style={{
@ -57,56 +79,41 @@ export const CalendarHeader = memo(() => {
}} }}
> >
<View row centerV gap-3> <View row centerV gap-3>
<Text style={{ fontFamily: "Manrope_500Medium", fontSize: 17 }}> <Text style={{fontFamily: "Manrope_500Medium", fontSize: 17}}>
{selectedDate.getFullYear()} {selectedDate.getFullYear()}
</Text> </Text>
<Picker <Picker
value={months[selectedDate.getMonth()]} value={months[selectedDate.getMonth()]}
placeholder={"Select Month"} placeholder={"Select Month"}
style={{ fontFamily: "Manrope_500Medium", fontSize: 17, width: 85 }} style={{fontFamily: "Manrope_500Medium", fontSize: 17, width: 85}}
mode={PickerModes.SINGLE} mode={PickerModes.SINGLE}
onChange={(itemValue) => handleMonthChange(itemValue as string)} onChange={(itemValue) => handleMonthChange(itemValue as string)}
trailingAccessory={<MaterialIcons name={"keyboard-arrow-down"} />} trailingAccessory={<MaterialIcons name={"keyboard-arrow-down"}/>}
topBarProps={{ topBarProps={{
title: selectedDate.getFullYear().toString(), title: selectedDate.getFullYear().toString(),
titleStyle: { fontFamily: "Manrope_500Medium", fontSize: 17 }, titleStyle: {fontFamily: "Manrope_500Medium", fontSize: 17},
}} }}
> >
{months.map((month) => ( {months.map((month) => (
<Picker.Item key={month} label={month} value={month} /> <Picker.Item key={month} label={month} value={month}/>
))} ))}
</Picker> </Picker>
</View> </View>
<View row centerV> <View row centerV>
{!isSelectedDateToday && (
<Button <Button
size={"xSmall"} size={"xSmall"}
marginR-0 marginR-1
avoidInnerPadding avoidInnerPadding
style={{ style={styles.todayButton}
borderRadius: 50, onPress={() => setSelectedDate(new Date())}
backgroundColor: "white", >
borderWidth: 0.7, <MaterialIcons name="calendar-today" size={30} color="#5f6368" />
borderColor: "#dadce0", <Text style={styles.todayDate}>{format(new Date(), "d")}</Text>
height: 30, </Button>
paddingHorizontal: 10,
}}
labelStyle={{
fontSize: 12,
color: "black",
fontFamily: "Manrope_500Medium",
}}
label={format(new Date(), "dd/MM/yyyy")}
onPress={() => {
setSelectedDate(new Date());
}}
/>
)}
<View> <View>
<SegmentedControl <SegmentedControl
segments={[{ label: "D" }, { label: "W" }, { label: "M" }]} segments={segments}
backgroundColor="#ececec" backgroundColor="#ececec"
inactiveColor="#919191" inactiveColor="#919191"
activeBackgroundColor="#ea156c" activeBackgroundColor="#ea156c"
@ -115,7 +122,7 @@ export const CalendarHeader = memo(() => {
outlineWidth={3} outlineWidth={3}
segmentLabelStyle={styles.segmentslblStyle} segmentLabelStyle={styles.segmentslblStyle}
onChangeIndex={handleSegmentChange} onChangeIndex={handleSegmentChange}
initialIndex={mode === "day" ? 0 : mode === "week" ? 1 : 2} initialIndex={getInitialIndex()}
/> />
</View> </View>
</View> </View>
@ -128,4 +135,19 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontFamily: "Manrope_600SemiBold", fontFamily: "Manrope_600SemiBold",
}, },
todayButton: {
backgroundColor: "transparent",
borderWidth: 0,
height: 30,
width: 30,
alignItems: 'center',
justifyContent: 'center',
},
todayDate: {
position: 'absolute',
fontSize: 12,
fontFamily: "Manrope_600SemiBold",
color: "#5f6368",
top: '30%',
},
}); });

View File

@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import { View } from "react-native-ui-lib"; import { View } from "react-native-ui-lib";
import HeaderTemplate from "@/components/shared/HeaderTemplate";
import { InnerCalendar } from "@/components/pages/calendar/InnerCalendar"; import { InnerCalendar } from "@/components/pages/calendar/InnerCalendar";
export default function CalendarPage() { export default function CalendarPage() {

View File

@ -1,24 +1,26 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, {useCallback, useEffect, useMemo, useState} from "react";
import { Calendar } from "react-native-big-calendar"; import {Calendar} from "react-native-big-calendar";
import { ActivityIndicator, ScrollView, StyleSheet, View, ViewStyle } from "react-native"; import {ActivityIndicator, StyleSheet, TouchableOpacity, View, ViewStyle} from "react-native";
import { useGetEvents } from "@/hooks/firebase/useGetEvents"; import {useGetEvents} from "@/hooks/firebase/useGetEvents";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import {useAtom, useSetAtom} from "jotai";
import { import {
editVisibleAtom, editVisibleAtom,
eventForEditAtom, eventForEditAtom,
isAllDayAtom, isFamilyViewAtom, isAllDayAtom,
isFamilyViewAtom,
modeAtom, modeAtom,
refreshTriggerAtom,
selectedDateAtom, selectedDateAtom,
selectedNewEventDateAtom, selectedNewEventDateAtom,
} from "@/components/pages/calendar/atoms"; } from "@/components/pages/calendar/atoms";
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 { addDays, compareAsc, isWithinInterval, subDays } from "date-fns"; import {addDays, compareAsc, format, isWithinInterval, subDays} from "date-fns";
import {useCalSync} from "@/hooks/useCalSync"; import {useCalSync} from "@/hooks/useCalSync";
import {useSyncEvents} from "@/hooks/useSyncOnScroll"; import {useSyncEvents} from "@/hooks/useSyncOnScroll";
import {colorMap} from "@/constants/colorMap"; import {colorMap} from "@/constants/colorMap";
import {useGetFamilyMembers} from "@/hooks/firebase/useGetFamilyMembers";
import CachedImage from "expo-cached-image";
interface EventCalendarProps { interface EventCalendarProps {
calendarHeight: number; calendarHeight: number;
@ -31,10 +33,346 @@ const getTotalMinutes = () => {
return Math.abs(date.getUTCHours() * 60 + date.getUTCMinutes() - 200); return Math.abs(date.getUTCHours() * 60 + date.getUTCMinutes() - 200);
}; };
const processEventsForSideBySide = (events: CalendarEvent[]) => {
if (!events) return [];
// Group events by day and time slot
const timeSlots: { [key: string]: CalendarEvent[] } = {};
events.forEach(event => {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
// If it's an all-day event, mark it and add it directly
if (event.allDay) {
const key = `${startDate.toISOString().split('T')[0]}-allday`;
if (!timeSlots[key]) timeSlots[key] = [];
timeSlots[key].push({
...event,
isAllDayEvent: true,
width: 1,
xPos: 0
});
return;
}
// Handle multi-day events
if (startDate.toDateString() !== endDate.toDateString()) {
// Create array of dates between start and end
const dates = [];
let currentDate = new Date(startDate);
while (currentDate <= endDate) {
dates.push(new Date(currentDate));
currentDate.setDate(currentDate.getDate() + 1);
}
// Create segments for each day
dates.forEach((date, index) => {
const isFirstDay = index === 0;
const isLastDay = index === dates.length - 1;
let segmentStart, segmentEnd;
if (isFirstDay) {
// First day: use original start time to end of day
segmentStart = new Date(startDate);
segmentEnd = new Date(date);
segmentEnd.setHours(23, 59, 59);
} else if (isLastDay) {
// Last day: use start of day to original end time
segmentStart = new Date(date);
segmentStart.setHours(0, 0, 0);
segmentEnd = new Date(endDate);
} else {
// Middle days: full day
segmentStart = new Date(date);
segmentStart.setHours(0, 0, 0);
segmentEnd = new Date(date);
segmentEnd.setHours(23, 59, 59);
}
const key = `${segmentStart.toISOString().split('T')[0]}-${segmentStart.getHours()}`;
if (!timeSlots[key]) timeSlots[key] = [];
timeSlots[key].push({
...event,
start: segmentStart,
end: segmentEnd,
isMultiDaySegment: true,
isFirstDay,
isLastDay,
originalStart: startDate,
originalEnd: endDate,
allDay: true // Mark multi-day events as all-day events
});
});
} else {
// Regular event
const key = `${startDate.toISOString().split('T')[0]}-${startDate.getHours()}`;
if (!timeSlots[key]) timeSlots[key] = [];
timeSlots[key].push(event);
}
});
// Process all time slots
return Object.values(timeSlots).flatMap(slotEvents => {
// Sort events by start time
slotEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
// Find overlapping events (only for non-all-day events)
return slotEvents.map((event, index) => {
// If it's an all-day or multi-day event, return as is
if (event.allDay || event.isMultiDaySegment) {
return {
...event,
width: 1,
xPos: 0
};
}
// Handle regular events
const overlappingEvents = slotEvents.filter((otherEvent, otherIndex) => {
if (index === otherIndex || otherEvent.allDay || otherEvent.isMultiDaySegment) return false;
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
const otherStart = new Date(otherEvent.start);
const otherEnd = new Date(otherEvent.end);
return (eventStart < otherEnd && eventEnd > otherStart);
});
const total = overlappingEvents.length + 1;
const position = index % total;
return {
...event,
width: 1 / total,
xPos: position / total
};
});
});
};
const renderEvent = (event: CalendarEvent & {
width: number;
xPos: number;
isMultiDaySegment?: boolean;
isFirstDay?: boolean;
isLastDay?: boolean;
originalStart?: Date;
originalEnd?: Date;
isAllDayEvent?: boolean;
allDay?: boolean;
eventColor?: string;
attendees?: string[];
creatorId?: string;
pfp?: string;
firstName?: string;
lastName?: string;
notes?: string;
hideHours?: boolean;
}, props: any) => {
const {data: familyMembers} = useGetFamilyMembers();
const attendees = useMemo(() => {
if (!familyMembers || !event.attendees) return event?.creatorId ? [event?.creatorId] : [];
return familyMembers.filter(member => event?.attendees?.includes(member?.uid!));
}, [familyMembers, event.attendees]);
if (event.allDay && !!event.isMultiDaySegment) {
return (
<TouchableOpacity
{...props}
style={[
props.style,
{
width: '100%',
flexDirection: 'row',
alignItems: 'center'
}
]}
>
<Text style={styles.allDayEventText} numberOfLines={1}>
{event.title}
{event.isMultiDaySegment &&
` (${format(new Date(event.start), 'MMM d')} - ${format(new Date(event.end), 'MMM d')})`
}
</Text>
</TouchableOpacity>
);
}
const originalStyle = Array.isArray(props.style) ? props.style[0] : props.style;
console.log('Rendering event:', {
title: event.title,
start: event.start,
end: event.end,
width: event.width,
xPos: event.xPos,
isMultiDaySegment: event.isMultiDaySegment
});
// Ensure we have Date objects
const startDate = event.start instanceof Date ? event.start : new Date(event.start);
const endDate = event.end instanceof Date ? event.end : new Date(event.end);
const hourHeight = props.hourHeight || 60;
const startHour = startDate.getHours();
const startMinutes = startDate.getMinutes();
const topPosition = (startHour + startMinutes / 60) * hourHeight;
const endHour = endDate.getHours();
const endMinutes = endDate.getMinutes();
const duration = (endHour + endMinutes / 60) - (startHour + startMinutes / 60);
const height = duration * hourHeight;
const formatTime = (date: Date) => {
const hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? 'pm' : 'am';
const formattedHours = hours % 12 || 12;
const formattedMinutes = minutes.toString().padStart(2, '0');
return `${formattedHours}:${formattedMinutes}${ampm}`;
};
const timeString = event.isMultiDaySegment
? event.isFirstDay
? `${formatTime(startDate)} → 12:00PM`
: event.isLastDay
? `12:00am → ${formatTime(endDate)}`
: 'All day'
: `${formatTime(startDate)} - ${formatTime(endDate)}`;
return (
<TouchableOpacity
{...props}
style={[
originalStyle,
{
position: 'absolute',
width: `${event.width * 100}%`,
left: `${event.xPos * 100}%`,
top: topPosition,
height: height,
zIndex: event.isMultiDaySegment ? 1 : 2,
shadowRadius: 2,
overflow: "hidden"
}
]}
>
<View
style={{
flex: 1,
backgroundColor: event.eventColor,
borderRadius: 4,
padding: 8,
justifyContent: 'space-between'
}}
>
<View>
<Text
style={{
color: 'white',
fontSize: 12,
fontFamily: "PlusJakartaSans_500Medium",
fontWeight: '600',
marginBottom: 4
}}
numberOfLines={1}
>
{event.title}
</Text>
<Text
style={{
color: 'white',
fontSize: 10,
fontFamily: "PlusJakartaSans_500Medium",
opacity: 0.8
}}
>
{timeString}
</Text>
</View>
{/* Attendees Section */}
{attendees?.length > 0 && (
<View style={{flexDirection: 'row', marginTop: 8, height: 27.32}}>
{attendees?.filter(x=>x?.firstName).slice(0, 5).map((attendee, index) => (
<View
key={attendee?.uid}
style={{
position: 'absolute',
left: index * 19,
width: 20,
height: 20,
borderRadius: 50,
borderWidth: 2,
borderColor: '#f2f2f2',
overflow: 'hidden',
backgroundColor: attendee.eventColor || colorMap.pink,
}}
>
{attendee.pfp ? (
<CachedImage
source={{uri: attendee.pfp}}
style={{width: '100%', height: '100%'}}
cacheKey={attendee.pfp}
/>
) : (
<View style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center'
}}>
<Text style={{
color: 'white',
fontSize: 12,
fontFamily: "Manrope_600SemiBold",
}}>
{attendee?.firstName?.at(0)}
{attendee?.lastName?.at(0)}
</Text>
</View>
)}
</View>
))}
{attendees.length > 3 && (
<View style={{
position: 'absolute',
left: 3 * 19,
width: 27.32,
height: 27.32,
borderRadius: 50,
borderWidth: 2,
borderColor: '#f2f2f2',
backgroundColor: colorMap.pink,
justifyContent: 'center',
alignItems: 'center'
}}>
<Text style={{
color: 'white',
fontFamily: "Manrope_600SemiBold",
fontSize: 12,
}}>
+{attendees.length - 3}
</Text>
</View>
)}
</View>
)}
</View>
</TouchableOpacity>
);
};
export const EventCalendar: React.FC<EventCalendarProps> = React.memo( export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
({ calendarHeight }) => { ({calendarHeight}) => {
const { data: events, isLoading, refetch } = useGetEvents(); const {data: events, isLoading} = useGetEvents();
const { profileData, user } = useAuthContext(); const {profileData, user} = useAuthContext();
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom); const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const [mode, setMode] = useAtom(modeAtom); const [mode, setMode] = useAtom(modeAtom);
const [isFamilyView] = useAtom(isFamilyViewAtom); const [isFamilyView] = useAtom(isFamilyViewAtom);
@ -42,7 +380,6 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const setEditVisible = useSetAtom(editVisibleAtom); const setEditVisible = useSetAtom(editVisibleAtom);
const [isAllDay, setIsAllDay] = useAtom(isAllDayAtom); const [isAllDay, setIsAllDay] = useAtom(isAllDayAtom);
const setEventForEdit = useSetAtom(eventForEditAtom); const setEventForEdit = useSetAtom(eventForEditAtom);
const shouldRefresh = useAtomValue(refreshTriggerAtom);
const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom); const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom);
const {isSyncing} = useSyncEvents() const {isSyncing} = useSyncEvents()
@ -53,9 +390,8 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const handlePressEvent = useCallback( const handlePressEvent = useCallback(
(event: CalendarEvent) => { (event: CalendarEvent) => {
if (mode === "day" || mode === "week") { if (mode === "day" || mode === "week" || mode === "3days") {
setEditVisible(true); setEditVisible(true);
// console.log({event});
setEventForEdit(event); setEventForEdit(event);
} else { } else {
setMode("day"); setMode("day");
@ -67,7 +403,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const handlePressCell = useCallback( const handlePressCell = useCallback(
(date: Date) => { (date: Date) => {
if (mode === "day" || mode === "week") { if (mode === "day" || mode === "week" || mode === "3days") {
setSelectedNewEndDate(date); setSelectedNewEndDate(date);
} else { } else {
setMode("day"); setMode("day");
@ -84,10 +420,8 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
setSelectedNewEndDate(date); setSelectedNewEndDate(date);
setEditVisible(true); setEditVisible(true);
} }
if (mode === 'week') if (mode === 'week' || mode === '3days') {
{
setSelectedDate(date) setSelectedDate(date)
setMode("day") setMode("day")
} }
}, },
@ -104,11 +438,11 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const memoizedEventCellStyle = useCallback( const memoizedEventCellStyle = useCallback(
(event: CalendarEvent) => { (event: CalendarEvent) => {
let eventColor = event.eventColor; let eventColor = event.eventColor;
if (!isFamilyView && (event.attendees?.includes(user?.uid) || event.creatorId === user?.uid)) { if (!isFamilyView && (event.attendees?.includes(user?.uid!) || event.creatorId! === user?.uid)) {
eventColor = profileData?.eventColor ?? colorMap.teal; eventColor = profileData?.eventColor ?? colorMap.teal;
} }
return { backgroundColor: eventColor , fontSize: 14} return {backgroundColor: eventColor, fontSize: 14}
}, },
[] []
); );
@ -118,8 +452,6 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
[profileData] [profileData]
); );
// console.log({memoizedWeekStartsOn, profileData: profileData?.firstDayOfWeek,
const isSameDate = useCallback((date1: Date, date2: Date) => { const isSameDate = useCallback((date1: Date, date2: Date) => {
return ( return (
date1.getDate() === date2.getDate() && date1.getDate() === date2.getDate() &&
@ -133,7 +465,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
}, [selectedDate, mode]); }, [selectedDate, mode]);
const dateStyle = useMemo(() => { const dateStyle = useMemo(() => {
if (mode === "week") return undefined; if (mode === "week" || mode === "3days") return undefined;
return isSameDate(todaysDate, selectedDate) && mode === "day" return isSameDate(todaysDate, selectedDate) && mode === "day"
? styles.dayHeader ? styles.dayHeader
: styles.otherDayHeader; : styles.otherDayHeader;
@ -142,7 +474,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const memoizedHeaderContentStyle = useMemo(() => { const memoizedHeaderContentStyle = useMemo(() => {
if (mode === "day") { if (mode === "day") {
return styles.dayModeHeader; return styles.dayModeHeader;
} else if (mode === "week") { } else if (mode === "week" || mode === "3days") {
return styles.weekModeHeader; return styles.weekModeHeader;
} else if (mode === "month") { } else if (mode === "month") {
return styles.monthModeHeader; return styles.monthModeHeader;
@ -150,12 +482,11 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
return {}; return {};
} }
}, [mode]); }, [mode]);
const {enrichedEvents, filteredEvents} = useMemo(() => {
const startTime = Date.now();
const { enrichedEvents, filteredEvents } = useMemo(() => { const startOffset = mode === "month" ? 40 : (mode === "week" || mode === "3days") ? 10 : 1;
const startTime = Date.now(); // Start timer const endOffset = mode === "month" ? 40 : (mode === "week" || mode === "3days") ? 10 : 1;
const startOffset = mode === "month" ? 40 : mode === "week" ? 10 : 1;
const endOffset = mode === "month" ? 40 : mode === "week" ? 10 : 1;
const filteredEvents = const filteredEvents =
events?.filter( events?.filter(
@ -181,7 +512,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
overlapCount: 0, overlapCount: 0,
}); });
acc[dateKey].sort((a, b) => compareAsc(a.start, b.start)); acc[dateKey].sort((a: { start: any; }, b: { start: any; }) => compareAsc(a.start, b.start));
return acc; return acc;
}, {} as Record<string, CalendarEvent[]>); }, {} as Record<string, CalendarEvent[]>);
@ -189,7 +520,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const endTime = Date.now(); 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) => {
@ -225,36 +556,29 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const appliedStyle = isCurrentDate ? currentDateStyle : defaultStyle; const appliedStyle = isCurrentDate ? currentDateStyle : defaultStyle;
return ( return (
<View style={{ alignItems: "center" }}> <View style={{alignItems: "center"}}>
<View style={appliedStyle}> <View style={appliedStyle}>
<Text style={{ color: isCurrentDate ? "white" : "black" }}> <Text style={{color: isCurrentDate ? "white" : "black"}}>
{date.getDate()} {date.getDate()}
</Text> </Text>
</View> </View>
</View> </View>
); );
}, },
[todaysDate, currentDateStyle, defaultStyle] // dependencies [todaysDate, currentDateStyle, defaultStyle]
); );
return renderDate(date); return renderDate(date);
}; };
const processedEvents = useMemo(() => {
return processEventsForSideBySide(filteredEvents);
}, [filteredEvents]);
useEffect(() => { useEffect(() => {
setOffsetMinutes(getTotalMinutes()); setOffsetMinutes(getTotalMinutes());
}, [events, mode]); }, [events, mode]);
useEffect(() => {
refetch()
.then(() => {
console.log('✅ Events refreshed successfully');
})
.catch((error) => {
console.error('❌ Events refresh failed:', error);
});
}, [shouldRefresh, refetch])
if (isLoading) { if (isLoading) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
@ -264,8 +588,6 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
); );
} }
// console.log(enrichedEvents, filteredEvents)
return ( return (
<> <>
{isSyncing && ( {isSyncing && (
@ -278,12 +600,13 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
bodyContainerStyle={styles.calHeader} bodyContainerStyle={styles.calHeader}
swipeEnabled swipeEnabled
mode={mode} mode={mode}
// enableEnrichedEvents={true}
sortedMonthView sortedMonthView
// enrichedEventsByDate={enrichedEvents}
events={filteredEvents} events={filteredEvents}
// renderEvent={renderEvent}
eventCellStyle={memoizedEventCellStyle} eventCellStyle={memoizedEventCellStyle}
allDayEventCellStyle={memoizedEventCellStyle} allDayEventCellStyle={memoizedEventCellStyle}
// enableEnrichedEvents={true}
// enrichedEventsByDate={enrichedEvents}
onPressEvent={handlePressEvent} onPressEvent={handlePressEvent}
weekStartsOn={memoizedWeekStartsOn} weekStartsOn={memoizedWeekStartsOn}
height={calendarHeight} height={calendarHeight}
@ -318,14 +641,14 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
dayHeaderHighlightColor={"white"} dayHeaderHighlightColor={"white"}
showAdjacentMonths showAdjacentMonths
headerContainerStyle={mode !== "month" ? { headerContainerStyle={mode !== "month" ? {
overflow:"hidden", overflow: "hidden",
} : {}} } : {}}
hourStyle={styles.hourStyle} hourStyle={styles.hourStyle}
onPressDateHeader={handlePressDayHeader} onPressDateHeader={handlePressDayHeader}
ampm ampm
// renderCustomDateForMonth={renderCustomDateForMonth} // renderCustomDateForMonth={renderCustomDateForMonth}
/> />
<View style={{backgroundColor: 'white', height: 50, width: '100%'}} /> <View style={{backgroundColor: 'white', height: 50, width: '100%'}}/>
</> </>
); );
@ -381,4 +704,16 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontFamily: "Manrope_500Medium", fontFamily: "Manrope_500Medium",
}, },
eventCell: {
flex: 1,
borderRadius: 4,
padding: 4,
height: '100%',
justifyContent: 'center',
},
eventTitle: {
color: 'white',
fontSize: 12,
fontFamily: "PlusJakartaSans_500Medium",
},
}); });

View File

@ -39,6 +39,7 @@ import BinIcon from "@/assets/svgs/BinIcon";
import DeleteEventDialog from "./DeleteEventDialog"; import DeleteEventDialog from "./DeleteEventDialog";
import { useDeleteEvent } from "@/hooks/firebase/useDeleteEvent"; import { useDeleteEvent } from "@/hooks/firebase/useDeleteEvent";
import AddPersonIcon from "@/assets/svgs/AddPersonIcon"; import AddPersonIcon from "@/assets/svgs/AddPersonIcon";
import {addHours, startOfHour, startOfMinute} from "date-fns";
const daysOfWeek = [ const daysOfWeek = [
{ label: "Monday", value: "monday" }, { label: "Monday", value: "monday" },
@ -86,7 +87,6 @@ export const ManuallyAddEventModal = () => {
if(allDayAtom === true) setIsAllDay(true); if(allDayAtom === true) setIsAllDay(true);
}, [allDayAtom]) }, [allDayAtom])
const [startTime, setStartTime] = useState(() => { const [startTime, setStartTime] = useState(() => {
const date = initialDate ?? new Date(); const date = initialDate ?? new Date();
if ( if (
@ -104,27 +104,11 @@ export const ManuallyAddEventModal = () => {
const [endTime, setEndTime] = useState(() => { const [endTime, setEndTime] = useState(() => {
if (editEvent?.end) { if (editEvent?.end) {
const date = new Date(editEvent.end); return new Date(editEvent.end);
date.setSeconds(0, 0);
return date;
} }
const baseDate = editEvent?.end ?? initialDate ?? new Date(); const baseDate = editEvent?.end ?? initialDate ?? new Date();
const date = new Date(baseDate); return addHours(startOfHour(baseDate), 1);
if (
date.getMinutes() > 0 ||
date.getSeconds() > 0 ||
date.getMilliseconds() > 0
) {
date.setHours(date.getHours() + 1);
}
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setHours(date.getHours() + 1);
return date;
}); });
const [startDate, setStartDate] = useState(initialDate ?? new Date()); const [startDate, setStartDate] = useState(initialDate ?? new Date());
const [endDate, setEndDate] = useState( const [endDate, setEndDate] = useState(
@ -161,38 +145,17 @@ export const ManuallyAddEventModal = () => {
setIsPrivate(editEvent?.private || false); setIsPrivate(editEvent?.private || false);
setStartTime(() => { setStartTime(() => {
const date = new Date(initialDate ?? new Date()); const date = initialDate ?? new Date();
const minutes = date.getMinutes();
date.setMinutes(0, 0, 0);
if (minutes >= 30) {
date.setHours(date.getHours() + 1);
}
return date;
});
setEndTime(() => {
if (editEvent?.end) {
const date = new Date(editEvent.end);
date.setSeconds(0, 0); date.setSeconds(0, 0);
return date; return date;
});
setEndTime(() => {
if (editEvent?.end) {
return startOfMinute(new Date(editEvent.end));
} }
const baseDate = editEvent?.end ?? initialDate ?? new Date(); const baseDate = editEvent?.end ?? initialDate ?? new Date();
const date = new Date(baseDate); return addHours(startOfHour(baseDate), 1);
if (
date.getMinutes() > 0 ||
date.getSeconds() > 0 ||
date.getMilliseconds() > 0
) {
date.setHours(date.getHours() + 1);
}
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
date.setHours(date.getHours() + 1);
return date;
}); });
setStartDate(initialDate ?? new Date()); setStartDate(initialDate ?? new Date());
@ -282,10 +245,10 @@ export const ManuallyAddEventModal = () => {
Alert.alert('Alert', 'Title field cannot be empty'); Alert.alert('Alert', 'Title field cannot be empty');
return false; return false;
} }
if (!selectedAttendees || selectedAttendees?.length === 0) { // if (!selectedAttendees || selectedAttendees?.length === 0) {
Alert.alert('Alert', 'Cannot have an event without any attendees'); // Alert.alert('Alert', 'Cannot have an event without any attendees');
return false; // return false;
} // }
return true; return true;
} }

View File

@ -1,11 +1,17 @@
import { atom } from "jotai"; import { atom } from "jotai";
import * as Device from "expo-device";
import { CalendarEvent } from "@/components/pages/calendar/interfaces"; import { CalendarEvent } from "@/components/pages/calendar/interfaces";
const getDefaultMode = () => {
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
return isTablet ? "week" : "3days";
};
export const editVisibleAtom = atom<boolean>(false); export const editVisibleAtom = atom<boolean>(false);
export const isAllDayAtom = atom<boolean>(false); export const isAllDayAtom = atom<boolean>(false);
export const eventForEditAtom = atom<CalendarEvent | undefined>(undefined); export const eventForEditAtom = atom<CalendarEvent | undefined>(undefined);
export const isFamilyViewAtom = atom<boolean>(false); export const isFamilyViewAtom = atom<boolean>(false);
export const modeAtom = atom<"week" | "month" | "day">("week"); export const modeAtom = atom<"week" | "month" | "day" | "3days">(getDefaultMode());
export const selectedDateAtom = atom<Date>(new Date()); export const selectedDateAtom = atom<Date>(new Date());
export const selectedNewEventDateAtom = atom<Date | undefined>(undefined); export const selectedNewEventDateAtom = atom<Date | undefined>(undefined);
export const settingsPageIndex = atom<number>(0); export const settingsPageIndex = atom<number>(0);

View File

@ -1,7 +1,8 @@
export const modeMap = new Map([ export const modeMap = new Map([
[0, "day"], [0, "day"],
[1, "week"], [1, "3days"],
[2, "month"], [2, "week"],
[3, "month"]
]); ]);
export const months = [ export const months = [

View File

@ -1,8 +1,9 @@
export interface CalendarEvent { export interface CalendarEvent {
id?: number | string; // Unique identifier for the event id?: number | string; // Unique identifier for the event
user?: string; user?: string;
creatorId?: string;
title: string; // Event title or name title: string; // Event title or name
description?: string; // Optional description for the event description?: string; // Optiional description for the event
start: Date; // Start date and time of the event start: Date; // Start date and time of the event
end: Date; // End date and time of the event end: Date; // End date and time of the event
location?: string; // Optional event location location?: string; // Optional event location

View File

@ -11,7 +11,6 @@ import { PanningDirectionsEnum } from "react-native-ui-lib/src/incubator/panView
import { Dimensions, Platform, StyleSheet } from "react-native"; import { Dimensions, Platform, StyleSheet } from "react-native";
import DropModalIcon from "@/assets/svgs/DropModalIcon"; import DropModalIcon from "@/assets/svgs/DropModalIcon";
import { useBrainDumpContext } from "@/contexts/DumpContext";
import KeyboardManager from "react-native-keyboard-manager"; import KeyboardManager from "react-native-keyboard-manager";
import { useFeedbackContext } from "@/contexts/FeedbackContext"; import { useFeedbackContext } from "@/contexts/FeedbackContext";

View File

@ -14,9 +14,6 @@ import PenIcon from "@/assets/svgs/PenIcon";
import BinIcon from "@/assets/svgs/BinIcon"; import BinIcon from "@/assets/svgs/BinIcon";
import DropModalIcon from "@/assets/svgs/DropModalIcon"; import DropModalIcon from "@/assets/svgs/DropModalIcon";
import CloseXIcon from "@/assets/svgs/CloseXIcon"; import CloseXIcon from "@/assets/svgs/CloseXIcon";
import NavCalendarIcon from "@/assets/svgs/NavCalendarIcon";
import NavToDosIcon from "@/assets/svgs/NavToDosIcon";
import RemindersIcon from "@/assets/svgs/RemindersIcon";
import MenuIcon from "@/assets/svgs/MenuIcon"; import MenuIcon from "@/assets/svgs/MenuIcon";
import { IFeedback, useFeedbackContext } from "@/contexts/FeedbackContext"; import { IFeedback, useFeedbackContext } from "@/contexts/FeedbackContext";
import FeedbackDialog from "./FeedbackDialog"; import FeedbackDialog from "./FeedbackDialog";

View File

@ -2,7 +2,6 @@ import {Dimensions, StyleSheet} from "react-native";
import React from "react"; import React from "react";
import {Button, View,} from "react-native-ui-lib"; import {Button, View,} from "react-native-ui-lib";
import {useGroceryContext} from "@/contexts/GroceryContext"; import {useGroceryContext} from "@/contexts/GroceryContext";
import {FontAwesome6} from "@expo/vector-icons";
import PlusIcon from "@/assets/svgs/PlusIcon"; import PlusIcon from "@/assets/svgs/PlusIcon";
const { width } = Dimensions.get("screen"); const { width } = Dimensions.get("screen");

View File

@ -26,6 +26,20 @@ import { DeviceType } from "expo-device";
if (Platform.OS === "ios") KeyboardManager.setEnableAutoToolbar(true); if (Platform.OS === "ios") KeyboardManager.setEnableAutoToolbar(true);
const firebaseAuthErrors: { [key: string]: string } = {
'auth/invalid-email': 'Please enter a valid email address',
'auth/user-disabled': 'This account has been disabled. Please contact support',
'auth/user-not-found': 'No account found with this email address',
'auth/wrong-password': 'Incorrect password. Please try again',
'auth/email-already-in-use': 'An account with this email already exists',
'auth/operation-not-allowed': 'This login method is not enabled. Please contact support',
'auth/weak-password': 'Password should be at least 6 characters',
'auth/invalid-credential': 'Invalid login credentials. Please try again',
'auth/network-request-failed': 'Network error. Please check your internet connection',
'auth/too-many-requests': 'Too many failed login attempts. Please try again later',
'auth/invalid-login-credentials': 'Invalid email or password. Please try again',
};
const SignInPage = () => { const SignInPage = () => {
const [email, setEmail] = useState<string>(""); const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>("");
@ -57,17 +71,21 @@ const SignInPage = () => {
const router = useRouter(); const router = useRouter();
const handleSignIn = async () => { const handleSignIn = async () => {
try {
await signIn({ email, password }); await signIn({ email, password });
if (!isError) {
Toast.show({ Toast.show({
type: "success", type: "success",
text1: "Login successful!", text1: "Login successful!",
}); });
} else { } catch (error: any) {
const errorCode = error?.code || 'unknown-error';
const errorMessage = firebaseAuthErrors[errorCode] || 'An unexpected error occurred. Please try again';
Toast.show({ Toast.show({
type: "error", type: "error",
text1: "Error logging in", text1: "Error logging in",
text2: `${error}`, text2: errorMessage,
}); });
} }
}; };
@ -147,9 +165,9 @@ const SignInPage = () => {
/> />
{isError && ( {isError && (
<Text center style={{ marginBottom: 20 }}>{`${ <Text center style={{ marginBottom: 20 }}>
error?.toString()?.split("]")?.[1] {firebaseAuthErrors[error?.code] || 'An unexpected error occurred. Please try again'}
}`}</Text> </Text>
)} )}
<View row centerH marginB-5 gap-5> <View row centerH marginB-5 gap-5>

View File

@ -1,15 +1,123 @@
import {FlatList, StyleSheet} from "react-native"; import {ActivityIndicator, Animated, FlatList, StyleSheet} from "react-native";
import React from "react"; import React, {useCallback, useState} from "react";
import {Card, Text, View} from "react-native-ui-lib"; import {Card, Text, View} from "react-native-ui-lib";
import HeaderTemplate from "@/components/shared/HeaderTemplate"; import HeaderTemplate from "@/components/shared/HeaderTemplate";
import {useGetNotifications} from "@/hooks/firebase/useGetNotifications"; import {Notification, useGetNotifications} from "@/hooks/firebase/useGetNotifications";
import {formatDistanceToNow} from "date-fns"; import {formatDistanceToNow} from "date-fns";
import {useRouter} from "expo-router";
import {useSetAtom} from "jotai";
import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms";
import {Swipeable} from 'react-native-gesture-handler';
import {useDeleteNotification} from "@/hooks/firebase/useDeleteNotification";
const NotificationsPage = () => { interface NotificationItemProps {
const {data: notifications} = useGetNotifications() item: Notification;
onDelete: (id: string) => void;
onPress: () => void;
isDeleting: boolean;
}
console.log(notifications?.[0]) const NotificationItem: React.FC<NotificationItemProps> = React.memo(({
item,
onDelete,
onPress,
isDeleting
}) => {
const renderRightActions = useCallback((
progress: Animated.AnimatedInterpolation<number>,
dragX: Animated.AnimatedInterpolation<number>
) => {
const trans = dragX.interpolate({
inputRange: [-100, 0],
outputRange: [0, 100],
extrapolate: 'clamp'
});
return (
<Animated.View
style={[
styles.deleteAction,
{
transform: [{translateX: trans}],
},
]}
>
<Text style={styles.deleteActionText}>Delete</Text>
</Animated.View>
);
}, []);
return (
<Swipeable
renderRightActions={renderRightActions}
onSwipeableRightOpen={() => onDelete(item.id)}
overshootRight={false}
enabled={!isDeleting}
>
<Card
padding-20
marginB-10
onPress={onPress}
activeOpacity={0.6}
enableShadow={false}
style={styles.card}
>
{isDeleting && (
<View style={styles.loadingOverlay}>
<ActivityIndicator color="#000" size="large"/>
</View>
)}
<Text text70>{item.content}</Text>
<View row spread marginT-10>
<Text text90>
{formatDistanceToNow(new Date(item.timestamp), {addSuffix: true})}
</Text>
<Text text90>
{item.timestamp.toLocaleDateString()}
</Text>
</View>
</Card>
</Swipeable>
);
});
const NotificationsPage: React.FC = () => {
const setSelectedDate = useSetAtom(selectedDateAtom);
const setMode = useSetAtom(modeAtom);
const {data: notifications} = useGetNotifications();
const deleteNotification = useDeleteNotification();
const {push} = useRouter();
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const goToEventDay = useCallback((notification: Notification) => () => {
if (notification?.date) {
setSelectedDate(notification.date);
setMode("day")
}
push({pathname: "/calendar"});
}, [push, setSelectedDate]);
const handleDelete = useCallback((notificationId: string) => {
setDeletingIds(prev => new Set(prev).add(notificationId));
deleteNotification.mutate(notificationId, {
onSettled: () => {
setDeletingIds(prev => {
const newSet = new Set(prev);
newSet.delete(notificationId);
return newSet;
});
}
});
}, [deleteNotification]);
const renderNotificationItem = useCallback(({item}: { item: Notification }) => (
<NotificationItem
item={item}
onDelete={handleDelete}
onPress={goToEventDay(item)}
isDeleting={deletingIds.has(item.id)}
/>
), [handleDelete, goToEventDay, deletingIds]);
return ( return (
<View flexG height={"100%"}> <View flexG height={"100%"}>
@ -18,40 +126,57 @@ const NotificationsPage = () => {
<HeaderTemplate <HeaderTemplate
message={"Welcome to your notifications!"} message={"Welcome to your notifications!"}
isWelcome={false} isWelcome={false}
children={
<Text
style={{fontFamily: "Manrope_400Regular", fontSize: 14}}
> >
<Text style={styles.subtitle}>
See your notifications here. See your notifications here.
</Text> </Text>
} </HeaderTemplate>
/>
</View> </View>
<FlatList contentContainerStyle={{paddingBottom: 10, paddingHorizontal: 25}} <FlatList
contentContainerStyle={styles.listContainer}
data={notifications ?? []} data={notifications ?? []}
renderItem={({item}) => <Card padding-20 gap-10 marginB-10> renderItem={renderNotificationItem}
<Text text70>{item.content}</Text> keyExtractor={(item) => item.id}
<View row spread> />
<Text
text90>{formatDistanceToNow(new Date(item.timestamp), {addSuffix: true})}</Text>
<Text text90>{item.timestamp.toLocaleDateString()}</Text>
</View>
</Card>}/>
</View> </View>
</View> </View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
searchField: { listContainer: {
borderWidth: 0.7, paddingBottom: 10,
borderColor: "#9b9b9b", paddingHorizontal: 25,
borderRadius: 15, },
height: 42, card: {
paddingLeft: 10, width: '100%',
marginVertical: 20, backgroundColor: 'white',
},
subtitle: {
fontFamily: "Manrope_400Regular",
fontSize: 14,
},
deleteAction: {
backgroundColor: '#FF3B30',
justifyContent: 'center',
alignItems: 'flex-end',
paddingRight: 30,
marginBottom: 10,
width: 100,
borderRadius: 10,
},
deleteActionText: {
color: 'white',
fontWeight: '600',
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1,
}, },
}); });
export default NotificationsPage; export default NotificationsPage

View File

@ -1,19 +1,16 @@
import { Image } from "react-native"; import {Image, StyleSheet} from "react-native";
import React, { useRef } from "react"; import React, {useRef} from "react";
import { View, Text, Button, TextField } from "react-native-ui-lib"; import {Button, Text, TextField, View} from "react-native-ui-lib";
import Onboarding from "react-native-onboarding-swiper"; import Onboarding from "react-native-onboarding-swiper";
import { StyleSheet } from "react-native";
import { useAuthContext } from "@/contexts/AuthContext";
import { useSignUp } from "@/hooks/firebase/useSignUp";
const OnboardingFlow = () => { const OnboardingFlow = () => {
const onboardingRef = useRef(null); const onboardingRef = useRef(null);
const { mutateAsync: signUp } = useSignUp();
return ( return (
<Onboarding <Onboarding
showPagination={false} showPagination={false}
ref={onboardingRef} ref={onboardingRef}
containerStyles={{ backgroundColor: "#f9f8f7" }} containerStyles={{backgroundColor: "#f9f8f7"}}
imageContainerStyles={{ imageContainerStyles={{
paddingBottom: 0, paddingBottom: 0,
paddingTop: 0, paddingTop: 0,
@ -34,7 +31,7 @@ const OnboardingFlow = () => {
<Text text50R>Lightening Mental Loads, One Family at a Time</Text> <Text text50R>Lightening Mental Loads, One Family at a Time</Text>
<Button <Button
label="Continue" label="Continue"
style={{ backgroundColor: "#fd1775" }} style={{backgroundColor: "#fd1775"}}
onPress={() => onboardingRef?.current?.goToPage(1, true)} onPress={() => onboardingRef?.current?.goToPage(1, true)}
/> />
</View> </View>
@ -60,8 +57,8 @@ const OnboardingFlow = () => {
<View marginH-30> <View marginH-30>
{/*<TextField style={styles.textfield} placeholder="First name" />*/} {/*<TextField style={styles.textfield} placeholder="First name" />*/}
{/*<TextField style={styles.textfield} placeholder="Last name" />*/} {/*<TextField style={styles.textfield} placeholder="Last name" />*/}
<TextField style={styles.textfield} placeholder="Email" /> <TextField style={styles.textfield} placeholder="Email"/>
<TextField style={styles.textfield} placeholder="Password" /> <TextField style={styles.textfield} placeholder="Password"/>
<Button <Button
label="Login" label="Login"
backgroundColor="#ea156c" backgroundColor="#ea156c"

View File

@ -2,7 +2,6 @@ import React, { useState } from "react";
import { Dialog, Button, Text, View } from "react-native-ui-lib"; import { Dialog, Button, Text, View } from "react-native-ui-lib";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import {useDeleteUser} from "@/hooks/firebase/useDeleteUser";
interface ConfirmationDialogProps { interface ConfirmationDialogProps {
visible: boolean; visible: boolean;

View File

@ -33,8 +33,6 @@ import KeyboardManager, {
} from "react-native-keyboard-manager"; } from "react-native-keyboard-manager";
import { ScrollView } from "react-native-gesture-handler"; import { ScrollView } from "react-native-gesture-handler";
import { useUploadProfilePicture } from "@/hooks/useUploadProfilePicture"; import { useUploadProfilePicture } from "@/hooks/useUploadProfilePicture";
import { ImagePickerAsset } from "expo-image-picker";
import MenuDotsIcon from "@/assets/svgs/MenuDotsIcon";
import UserOptions from "./UserOptions"; import UserOptions from "./UserOptions";
import { colorMap } from "@/constants/colorMap"; import { colorMap } from "@/constants/colorMap";

View File

@ -318,23 +318,23 @@ const MyProfile = () => {
)} )}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => handleChangeColor(colorMap.gray)}> <TouchableOpacity onPress={() => handleChangeColor(colorMap.indigo)}>
<View style={styles.colorBox} backgroundColor={colorMap.gray}> <View style={styles.colorBox} backgroundColor={colorMap.indigo}>
{selectedColor == colorMap.gray && ( {selectedColor == colorMap.indigo && (
<AntDesign name="check" size={30} color="white" /> <AntDesign name="check" size={30} color="white" />
)} )}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => handleChangeColor(colorMap.yellow)}> <TouchableOpacity onPress={() => handleChangeColor(colorMap.emerald)}>
<View style={styles.colorBox} backgroundColor={colorMap.yellow}> <View style={styles.colorBox} backgroundColor={colorMap.emerald}>
{selectedColor == colorMap.yellow && ( {selectedColor == colorMap.emerald && (
<AntDesign name="check" size={30} color="white" /> <AntDesign name="check" size={30} color="white" />
)} )}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => handleChangeColor(colorMap.sky)}> <TouchableOpacity onPress={() => handleChangeColor(colorMap.violet)}>
<View style={styles.colorBox} backgroundColor={colorMap.sky}> <View style={styles.colorBox} backgroundColor={colorMap.violet}>
{selectedColor == colorMap.sky && ( {selectedColor == colorMap.violet && (
<AntDesign name="check" size={30} color="white" /> <AntDesign name="check" size={30} color="white" />
)} )}
</View> </View>

View File

@ -9,7 +9,6 @@ import {
import QRCode from "react-native-qrcode-svg"; import QRCode from "react-native-qrcode-svg";
import { PanningDirectionsEnum } from "react-native-ui-lib/src/components/panningViews/panningProvider"; import { PanningDirectionsEnum } from "react-native-ui-lib/src/components/panningViews/panningProvider";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { useGetFamilyMembers } from "@/hooks/firebase/useGetFamilyMembers";
import { UserProfile } from "@/hooks/firebase/types/profileTypes"; import { UserProfile } from "@/hooks/firebase/types/profileTypes";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { ProfileType, useAuthContext } from "@/contexts/AuthContext"; import { ProfileType, useAuthContext } from "@/contexts/AuthContext";

View File

@ -1,8 +1,6 @@
import { Dimensions, StyleSheet } from "react-native"; import {StyleSheet} from "react-native";
import React, { useState } from "react"; import React, {useState} from "react";
import { Button, ButtonSize, Text, View } from "react-native-ui-lib"; import {Button, ButtonSize, Text, View} from "react-native-ui-lib";
import { AntDesign } from "@expo/vector-icons";
import LinearGradient from "react-native-linear-gradient";
import AddChoreDialog from "./AddChoreDialog"; import AddChoreDialog from "./AddChoreDialog";
import PlusIcon from "@/assets/svgs/PlusIcon"; import PlusIcon from "@/assets/svgs/PlusIcon";
@ -27,10 +25,10 @@ const AddChore = () => {
style={styles.button} style={styles.button}
onPress={() => setIsVisible(!isVisible)} onPress={() => setIsVisible(!isVisible)}
> >
<PlusIcon /> <PlusIcon/>
<Text <Text
white white
style={{ fontFamily: "Manrope_600SemiBold", fontSize: 15 }} style={{fontFamily: "Manrope_600SemiBold", fontSize: 15}}
marginL-5 marginL-5
> >
Create new to do Create new to do
@ -38,7 +36,7 @@ const AddChore = () => {
</Button> </Button>
</View> </View>
{isVisible && ( {isVisible && (
<AddChoreDialog isVisible={isVisible} setIsVisible={setIsVisible} /> <AddChoreDialog isVisible={isVisible} setIsVisible={setIsVisible}/>
)} )}
</View> </View>
); );

View File

@ -1,21 +1,20 @@
import { View, Text, Button } from "react-native-ui-lib"; import {Text, View} from "react-native-ui-lib";
import React from "react"; import React from "react";
import { Fontisto } from "@expo/vector-icons"; import {ProgressBar} from "react-native-ui-lib/src/components/progressBar";
import { ProgressBar } from "react-native-ui-lib/src/components/progressBar"; import {useToDosContext} from "@/contexts/ToDosContext";
import { useToDosContext } from "@/contexts/ToDosContext";
import FireworksOrangeIcon from "@/assets/svgs/FireworksOrangeIcon"; import FireworksOrangeIcon from "@/assets/svgs/FireworksOrangeIcon";
const ProgressCard = ({children}: {children?: React.ReactNode}) => { const ProgressCard = ({children}: { children?: React.ReactNode }) => {
const { maxPoints } = useToDosContext(); const {maxPoints} = useToDosContext();
return ( return (
<View <View
backgroundColor="white" backgroundColor="white"
marginB-5 marginB-5
padding-15 padding-15
style={{ borderRadius: 22 }} style={{borderRadius: 22}}
> >
<View row centerV> <View row centerV>
<FireworksOrangeIcon /> <FireworksOrangeIcon/>
<Text marginL-15 text70 style={{fontFamily: 'Manrope_600SemiBold', fontSize: 14}}> <Text marginL-15 text70 style={{fontFamily: 'Manrope_600SemiBold', fontSize: 14}}>
You have earned XX points this week!{" "} You have earned XX points this week!{" "}
</Text> </Text>

View File

@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { View } from "react-native";
import { BarChart } from "react-native-gifted-charts"; import { BarChart } from "react-native-gifted-charts";
const FamilyChart = () => { const FamilyChart = () => {

View File

@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { View } from "react-native";
import { BarChart } from "react-native-gifted-charts"; import { BarChart } from "react-native-gifted-charts";
const UserChart = () => { const UserChart = () => {

View File

@ -1,25 +1,15 @@
import { import {Button, ButtonSize, Dialog, ProgressBar, Text, TouchableOpacity, View,} from "react-native-ui-lib";
View, import React, {useState} from "react";
Text, import {StyleSheet} from "react-native";
ProgressBar,
Button,
ButtonSize,
Modal,
Dialog,
TouchableOpacity,
} from "react-native-ui-lib";
import React, { useState } from "react";
import { StyleSheet } from "react-native";
import UserChart from "./UserChart"; import UserChart from "./UserChart";
import ProgressCard from "../ProgressCard"; import ProgressCard from "../ProgressCard";
import { AntDesign, Feather, Ionicons } from "@expo/vector-icons"; import {AntDesign, Ionicons} from "@expo/vector-icons";
import { ScrollView } from "react-native-gesture-handler"; import {ScrollView} from "react-native-gesture-handler";
import { PanViewDirectionsEnum } from "react-native-ui-lib/src/incubator/panView";
import FireworksOrangeIcon from "@/assets/svgs/FireworksOrangeIcon"; import FireworksOrangeIcon from "@/assets/svgs/FireworksOrangeIcon";
const UserChoresProgress = ({ const UserChoresProgress = ({
setPageIndex, setPageIndex,
}: { }: {
setPageIndex: (value: number) => void; setPageIndex: (value: number) => void;
}) => { }) => {
const [modalVisible, setModalVisible] = useState<boolean>(false); const [modalVisible, setModalVisible] = useState<boolean>(false);
@ -35,10 +25,10 @@ const UserChoresProgress = ({
name="chevron-back" name="chevron-back"
size={14} size={14}
color="#979797" color="#979797"
style={{ paddingBottom: 3 }} style={{paddingBottom: 3}}
/> />
<Text <Text
style={{ fontFamily: "Poppins_400Regular", fontSize: 14.71 }} style={{fontFamily: "Poppins_400Regular", fontSize: 14.71}}
color="#979797" color="#979797"
> >
Return to To Do's Return to To Do's
@ -46,26 +36,26 @@ const UserChoresProgress = ({
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<View> <View>
<Text style={{ fontFamily: "Manrope_700Bold", fontSize: 20 }}> <Text style={{fontFamily: "Manrope_700Bold", fontSize: 20}}>
Your To Do's Progress Report Your To Do's Progress Report
</Text> </Text>
</View> </View>
<View row spread marginT-25 marginB-5> <View row spread marginT-25 marginB-5>
<Text text70 style={{ fontFamily: "Manrope_600SemiBold" }}> <Text text70 style={{fontFamily: "Manrope_600SemiBold"}}>
Daily Goal Daily Goal
</Text> </Text>
</View> </View>
<ProgressCard /> <ProgressCard/>
<View row spread marginT-15 marginB-8> <View row spread marginT-15 marginB-8>
<Text text70 style={{ fontFamily: "Manrope_600SemiBold" }}> <Text text70 style={{fontFamily: "Manrope_600SemiBold"}}>
Points Earned This Week Points Earned This Week
</Text> </Text>
</View> </View>
<View style={styles.card} paddingL-10> <View style={styles.card} paddingL-10>
<UserChart /> <UserChart/>
</View> </View>
<View row spread marginT-20 marginB-8 centerV> <View row spread marginT-20 marginB-8 centerV>
<Text text70 style={{ fontFamily: "Manrope_600SemiBold" }}> <Text text70 style={{fontFamily: "Manrope_600SemiBold"}}>
Total Reward Points Total Reward Points
</Text> </Text>
<Button <Button
@ -82,7 +72,7 @@ const UserChoresProgress = ({
<AntDesign <AntDesign
name="gift" name="gift"
size={20} size={20}
style={{ marginRight: 5 }} style={{marginRight: 5}}
color="#50be0c" color="#50be0c"
/> />
)} )}
@ -90,11 +80,11 @@ const UserChoresProgress = ({
</View> </View>
<View style={styles.card}> <View style={styles.card}>
<View row centerV> <View row centerV>
<FireworksOrangeIcon color="#8005eb" /> <FireworksOrangeIcon color="#8005eb"/>
<Text <Text
marginL-8 marginL-8
text70 text70
style={{ fontFamily: "Manrope_600SemiBold", fontSize: 14 }} style={{fontFamily: "Manrope_600SemiBold", fontSize: 14}}
> >
You have 1200 points saved! You have 1200 points saved!
</Text> </Text>
@ -110,8 +100,8 @@ const UserChoresProgress = ({
}} }}
/> />
<View row spread> <View row spread>
<Text style={{ fontSize: 13, color: "#858585" }}>0</Text> <Text style={{fontSize: 13, color: "#858585"}}>0</Text>
<Text style={{ fontSize: 13, color: "#858585" }}>5000</Text> <Text style={{fontSize: 13, color: "#858585"}}>5000</Text>
</View> </View>
</View> </View>
</ScrollView> </ScrollView>
@ -156,7 +146,7 @@ const UserChoresProgress = ({
labelStyle={styles.bigButtonText} labelStyle={styles.bigButtonText}
/> />
<TouchableOpacity onPress={() => setModalVisible(false)}> <TouchableOpacity onPress={() => setModalVisible(false)}>
<Text text70 marginT-20 center color="#999999" style={{fontFamily: 'Manrope_500Medium'}} > <Text text70 marginT-20 center color="#999999" style={{fontFamily: 'Manrope_500Medium'}}>
Go back to my to dos Go back to my to dos
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -183,7 +173,7 @@ const styles = StyleSheet.create({
borderRadius: 20, borderRadius: 20,
padding: 20, padding: 20,
}, },
bigButtonText:{ bigButtonText: {
fontFamily: 'Manrope_400Regular' fontFamily: 'Manrope_400Regular'
} }
}); });

View File

@ -8,5 +8,8 @@ export const colorMap = {
red: '#ff1637', red: '#ff1637',
gray: '#607d8b', gray: '#607d8b',
yellow: '#ffc107', yellow: '#ffc107',
sky: '#2196f3' sky: '#2196f3',
indigo: '#4F46E5',
emerald: '#059669',
violet: '#7C3AED',
}; };

View File

@ -164,19 +164,15 @@ export const AuthContextProvider: FC<{ children: ReactNode }> = ({children}) =>
} }
}, [user, ready, redirectOverride]); }, [user, ready, redirectOverride]);
// useEffect(() => { useEffect(() => {
// const handleNotification = async (notification: Notifications.Notification) => { const handleNotification = async (notification: Notifications.Notification) => {
// const eventId = notification?.request?.content?.data?.eventId; queryClient.invalidateQueries(["notifications"]);
// };
// // if (eventId) {
// queryClient.invalidateQueries(['events']); const sub = Notifications.addNotificationReceivedListener(handleNotification);
// // }
// }; return () => sub.remove();
// }, []);
// const sub = Notifications.addNotificationReceivedListener(handleNotification);
//
// return () => sub.remove();
// }, []);
if (!ready) { if (!ready) {
return null; return null;

View File

@ -2,9 +2,7 @@ import { useCreateNote } from "@/hooks/firebase/useCreateNote";
import { useDeleteNote } from "@/hooks/firebase/useDeleteNote"; import { useDeleteNote } from "@/hooks/firebase/useDeleteNote";
import { useGetNotes } from "@/hooks/firebase/useGetNotes"; import { useGetNotes } from "@/hooks/firebase/useGetNotes";
import { useUpdateNote } from "@/hooks/firebase/useUpdateNote"; import { useUpdateNote } from "@/hooks/firebase/useUpdateNote";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { createContext, useContext, useState } from "react"; import { createContext, useContext, useState } from "react";
import { create } from "react-test-renderer";
export interface IBrainDump { export interface IBrainDump {
id: number; id: number;

View File

@ -1,9 +1,8 @@
import { useCreateFeedback } from "@/hooks/firebase/useCreateFeedback"; import {useCreateFeedback} from "@/hooks/firebase/useCreateFeedback";
import { useDeleteFeedback } from "@/hooks/firebase/useDeleteFeedback"; import {useDeleteFeedback} from "@/hooks/firebase/useDeleteFeedback";
import { useGetFeedbacks } from "@/hooks/firebase/useGetFeedbacks"; import {useGetFeedbacks} from "@/hooks/firebase/useGetFeedbacks";
import { useUpdateFeedback } from "@/hooks/firebase/useUpdateFeedback"; import {useUpdateFeedback} from "@/hooks/firebase/useUpdateFeedback";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import {createContext, useContext, useState} from "react";
import { createContext, useContext, useState } from "react";
export interface IFeedback { export interface IFeedback {
id: number; id: number;
@ -24,20 +23,18 @@ const FeedbackContext = createContext<IFeedbackContext | undefined>(undefined);
export const FeedbackProvider: React.FC<{ children: React.ReactNode }> = ({ export const FeedbackProvider: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
const { const {
mutateAsync: createFeedback, mutateAsync: createFeedback,
isLoading: isAdding,
isError,
} = useCreateFeedback(); } = useCreateFeedback();
const { data: feedbacks } = useGetFeedbacks(); const {data: feedbacks} = useGetFeedbacks();
const { mutate: deleteFeedbackMutate } = useDeleteFeedback(); const {mutate: deleteFeedbackMutate} = useDeleteFeedback();
const { mutate: updateFeedbackMutate } = useUpdateFeedback(); const {mutate: updateFeedbackMutate} = useUpdateFeedback();
const [isAddingFeedback, setIsAddingFeedback] = useState<boolean>(false); const [isAddingFeedback, setIsAddingFeedback] = useState<boolean>(false);
const addFeedback = (Feedback: IFeedback) => { const addFeedback = (Feedback: IFeedback) => {
createFeedback({ title: Feedback.title, text: Feedback.text }); createFeedback({title: Feedback.title, text: Feedback.text});
}; };
const updateFeedback = (id: number, changes: Partial<IFeedback>) => { const updateFeedback = (id: number, changes: Partial<IFeedback>) => {

View File

@ -1,4 +1,3 @@
import { View, Text } from "react-native";
import React, { createContext, useContext, useState } from "react"; import React, { createContext, useContext, useState } from "react";
export interface IReminder { export interface IReminder {
id: number; id: number;

View File

@ -11,7 +11,10 @@
}, },
"preview": { "preview": {
"distribution": "internal", "distribution": "internal",
"channel": "preview" "channel": "production",
"android": {
"buildType": "apk"
}
}, },
"production": { "production": {
"channel": "production", "channel": "production",

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,29 @@
import {ProfileType} from "@/contexts/AuthContext"; export type ProfileType = 'parent' | 'child';
export interface User { export interface CalendarAccount {
uid: string; accessToken: string;
email: string | null; refreshToken?: string;
resourceId?: string;
email?: string;
expiresAt?: Date;
} }
export interface GoogleAccount extends CalendarAccount {
scope?: string;
}
export interface MicrosoftAccount extends CalendarAccount {
subscriptionId?: string;
}
export interface AppleAccount extends CalendarAccount {
identityToken?: string;
}
export type CalendarAccounts = {
[email: string]: GoogleAccount | MicrosoftAccount | AppleAccount;
};
export interface UserProfile { export interface UserProfile {
userType: ProfileType; userType: ProfileType;
firstName: string; firstName: string;
@ -21,23 +40,7 @@ export interface UserProfile {
eventColor?: string | null; eventColor?: string | null;
timeZone?: string | null; timeZone?: string | null;
firstDayOfWeek?: string | null; firstDayOfWeek?: string | null;
googleAccounts?: Object; googleAccounts?: { [email: string]: GoogleAccount };
microsoftAccounts?: Object; microsoftAccounts?: { [email: string]: MicrosoftAccount };
appleAccounts?: Object; appleAccounts?: { [email: string]: AppleAccount };
}
export interface ParentProfile extends UserProfile {
userType: ProfileType.PARENT;
childrenIds: string[];
}
export interface ChildProfile extends UserProfile {
userType: ProfileType.CHILD;
birthday: Date;
parentId: string;
}
export interface CaregiverProfile extends UserProfile {
userType: ProfileType.CAREGIVER;
contact: string;
} }

View File

@ -24,6 +24,7 @@ export const useCreateEvent = () => {
.doc(docId) .doc(docId)
.set({ .set({
...eventData, ...eventData,
attendees: (eventData.attendees?.length ?? 0) === 0 ?? [currentUser?.uid],
creatorId: currentUser?.uid, creatorId: currentUser?.uid,
familyId: profileData?.familyId familyId: profileData?.familyId
}, {merge: true}); }, {merge: true});
@ -37,15 +38,12 @@ export const useCreateEvent = () => {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
},
onSuccess: () => {
queryClients.invalidateQueries("events")
} }
}) })
} }
export const useCreateEventsFromProvider = () => { export const useCreateEventsFromProvider = () => {
const { user: currentUser } = useAuthContext(); const {user: currentUser} = useAuthContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@ -66,14 +64,14 @@ export const useCreateEventsFromProvider = () => {
// Event doesn't exist, so add it // Event doesn't exist, so add it
return firestore() return firestore()
.collection("Events") .collection("Events")
.add({ ...eventData, creatorId: currentUser?.uid }); .add({...eventData, creatorId: currentUser?.uid});
} else { } else {
// Event exists, update it // Event exists, update it
const docId = snapshot.docs[0].id; const docId = snapshot.docs[0].id;
return firestore() return firestore()
.collection("Events") .collection("Events")
.doc(docId) .doc(docId)
.set({ ...eventData, creatorId: currentUser?.uid }, { merge: true }); .set({...eventData, creatorId: currentUser?.uid}, {merge: true});
} }
}); });

View File

@ -0,0 +1,37 @@
import {useMutation, useQueryClient} from "react-query";
import {useAuthContext} from "@/contexts/AuthContext";
import firestore from "@react-native-firebase/firestore";
import {Notification} from "@/hooks/firebase/useGetNotifications";
export const useDeleteNotification = () => {
const queryClient = useQueryClient();
const {user} = useAuthContext();
return useMutation({
mutationFn: async (id: string) => {
await firestore()
.collection("Notifications")
.doc(id)
.delete();
},
onMutate: async (deletedId) => {
await queryClient.cancelQueries(["notifications", user?.uid]);
const previousNotifications = queryClient.getQueryData<Notification[]>(["notifications", user?.uid]);
queryClient.setQueryData<Notification[]>(["notifications", user?.uid], (old) =>
old?.filter((notification) => notification?.id! !== deletedId) ?? []
);
return {previousNotifications};
},
onError: (_err, _deletedId, context) => {
if (context?.previousNotifications) {
queryClient.setQueryData(["notifications", user?.uid], context.previousNotifications);
}
},
onSettled: () => {
queryClient.invalidateQueries(["notifications", user?.uid]);
},
});
};

View File

@ -1,80 +1,202 @@
import { useQuery } from "react-query"; import {useQuery, useQueryClient} from "react-query";
import firestore from "@react-native-firebase/firestore"; import firestore from "@react-native-firebase/firestore";
import { useAuthContext } from "@/contexts/AuthContext"; import {useAuthContext} from "@/contexts/AuthContext";
import { useAtomValue } from "jotai"; import {useAtomValue} from "jotai";
import { isFamilyViewAtom } from "@/components/pages/calendar/atoms"; import {isFamilyViewAtom} from "@/components/pages/calendar/atoms";
import { colorMap } from "@/constants/colorMap"; import {colorMap} from "@/constants/colorMap";
import {uuidv4} from "@firebase/util"; import {uuidv4} from "@firebase/util";
import {useEffect} from "react";
const createEventHash = (event: any): string => {
const str = `${event.startDate?.seconds || ''}-${event.endDate?.seconds || ''}-${
event.title || ''
}-${event.location || ''}-${event.allDay ? 'true' : 'false'}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString(36);
};
export const useGetEvents = () => { export const useGetEvents = () => {
const { user, profileData } = useAuthContext(); const {user, profileData} = useAuthContext();
const isFamilyView = useAtomValue(isFamilyViewAtom); const isFamilyView = useAtomValue(isFamilyViewAtom);
const queryClient = useQueryClient();
useEffect(() => {
if (!profileData?.familyId) {
console.log('[SYNC] No family ID available, skipping listener setup');
return;
}
console.log('[SYNC] Setting up sync listener', {
familyId: profileData.familyId,
userId: user?.uid,
isFamilyView
});
const unsubscribe = firestore()
.collection('Households')
.where("familyId", "==", profileData.familyId)
.onSnapshot((snapshot) => {
console.log('[SYNC] Snapshot received', {
empty: snapshot.empty,
size: snapshot.size,
changes: snapshot.docChanges().length
});
snapshot.docChanges().forEach((change) => {
console.log('[SYNC] Processing change', {
type: change.type,
docId: change.doc.id,
newData: change.doc.data()
});
if (change.type === 'modified') {
const data = change.doc.data();
console.log('[SYNC] Modified document data', {
hasLastSyncTimestamp: !!data?.lastSyncTimestamp,
hasLastUpdateTimestamp: !!data?.lastUpdateTimestamp,
allFields: Object.keys(data || {})
});
if (data?.lastSyncTimestamp) {
console.log('[SYNC] Sync timestamp change detected', {
timestamp: data.lastSyncTimestamp.toDate(),
householdId: change.doc.id,
queryKey: ["events", user?.uid, isFamilyView]
});
console.log('[SYNC] Invalidating queries...');
queryClient.invalidateQueries(["events", user?.uid, isFamilyView]);
console.log('[SYNC] Queries invalidated');
} else {
console.log('[SYNC] Modified document without lastSyncTimestamp', {
householdId: change.doc.id
});
}
}
});
}, (error) => {
console.error('[SYNC] Listener error:', {
message: error.message,
code: error.code,
stack: error.stack
});
});
console.log('[SYNC] Listener setup complete');
return () => {
console.log('[SYNC] Cleaning up sync listener', {
familyId: profileData.familyId,
userId: user?.uid
});
unsubscribe();
};
}, [profileData?.familyId, user?.uid, isFamilyView, queryClient]);
return useQuery({ return useQuery({
queryKey: ["events", user?.uid, isFamilyView], queryKey: ["events", user?.uid, isFamilyView],
queryFn: async () => { queryFn: async () => {
console.log(`Fetching events - Family View: ${isFamilyView}, User: ${user?.uid}`);
const db = firestore(); const db = firestore();
const userId = user?.uid; const userId = user?.uid;
const familyId = profileData?.familyId; const familyId = profileData?.familyId;
let allEvents = []; let allEvents = [];
// If family view is active, include family, creator, and attendee events
if (isFamilyView) { if (isFamilyView) {
const familyQuery = db.collection("Events").where("familyId", "==", familyId); const [publicFamilyEvents, privateCreatorEvents, privateAttendeeEvents, userAttendeeEvents, userCreatorEvents] = await Promise.all([
const attendeeQuery = db.collection("Events").where("attendees", "array-contains", userId); // Public family events
db.collection("Events")
.where("familyId", "==", familyId)
.where("private", "==", false)
.get(),
const [familySnapshot, attendeeSnapshot] = await Promise.all([ // Private events user created
familyQuery.get(), db.collection("Events")
attendeeQuery.get(), .where("familyId", "==", familyId)
.where("private", "==", true)
.where("creatorId", "==", userId)
.get(),
// Private events user is attending
db.collection("Events")
.where("private", "==", true)
.where("attendees", "array-contains", userId)
.get(),
// All events where user is attendee
db.collection("Events")
.where("attendees", "array-contains", userId)
.get(),
// ALL events where user is creator (regardless of attendees)
db.collection("Events")
.where("creatorId", "==", userId)
.get()
]); ]);
// Collect all events console.log(`Found ${publicFamilyEvents.size} public events, ${privateCreatorEvents.size} private creator events, ${privateAttendeeEvents.size} private attendee events, ${userAttendeeEvents.size} user attendee events, ${userCreatorEvents.size} user creator events`);
const familyEvents = familySnapshot.docs.map(doc => doc.data());
const attendeeEvents = attendeeSnapshot.docs.map(doc => doc.data());
// console.log("Family events not in creator query: ", familyEvents.filter(event => !creatorEvents.some(creatorEvent => creatorEvent.id === event.id))); allEvents = [
...publicFamilyEvents.docs.map(doc => ({...doc.data(), id: doc.id})),
...privateCreatorEvents.docs.map(doc => ({...doc.data(), id: doc.id})),
allEvents = [...familyEvents, ...attendeeEvents]; ...privateAttendeeEvents.docs.map(doc => ({...doc.data(), id: doc.id})),
...userAttendeeEvents.docs.map(doc => ({...doc.data(), id: doc.id})),
...userCreatorEvents.docs.map(doc => ({...doc.data(), id: doc.id}))
];
} else { } else {
// Only include creator and attendee events when family view is off const [creatorEvents, attendeeEvents] = await Promise.all([
const creatorQuery = db.collection("Events").where("creatorId", "==", userId); db.collection("Events")
const attendeeQuery = db.collection("Events").where("attendees", "array-contains", userId); .where("creatorId", "==", userId)
.get(),
const [creatorSnapshot, attendeeSnapshot] = await Promise.all([ db.collection("Events")
creatorQuery.get(), .where("attendees", "array-contains", userId)
attendeeQuery.get(), .get()
]); ]);
const creatorEvents = creatorSnapshot.docs.map(doc => doc.data()); console.log(`Found ${creatorEvents.size} creator events, ${attendeeEvents.size} attendee events`);
const attendeeEvents = attendeeSnapshot.docs.map(doc => doc.data());
allEvents = [...creatorEvents, ...attendeeEvents]; allEvents = [
...creatorEvents.docs.map(doc => ({...doc.data(), id: doc.id})),
...attendeeEvents.docs.map(doc => ({...doc.data(), id: doc.id}))
];
} }
// Use a Map to ensure uniqueness only for events with IDs
const uniqueEventsMap = new Map(); const uniqueEventsMap = new Map();
const processedHashes = new Set();
allEvents.forEach(event => { allEvents.forEach(event => {
if (event.id) { const eventHash = createEventHash(event);
uniqueEventsMap.set(event.id, event); // Ensure uniqueness for events with IDs
console.log(`Processing ${uniqueEventsMap.size} unique events`);
const processedEvent = {
...event,
id: event.id || uuidv4(),
creatorId: event.creatorId || userId
};
// Only add the event if we haven't seen this hash before
if (!processedHashes.has(eventHash)) {
processedHashes.add(eventHash);
uniqueEventsMap.set(processedEvent.id, processedEvent);
} else { } else {
uniqueEventsMap.set(uuidv4(), event); // Generate a temp key for events without ID console.log(`Duplicate event detected and skipped using hash: ${eventHash}`);
} }
}); });
const uniqueEvents = Array.from(uniqueEventsMap.values());
// Filter out private events unless the user is the creator console.log(`Processing ${uniqueEventsMap.size} unique events after deduplication`);
const filteredEvents = uniqueEvents.filter(event => {
if (event.private) {
return event.creatorId === userId;
}
return true;
});
// Attach event colors and return the final list of events const processedEvents = await Promise.all(
return await Promise.all( Array.from(uniqueEventsMap.values()).map(async (event) => {
filteredEvents.map(async (event) => {
const profileSnapshot = await db const profileSnapshot = await db
.collection("Profiles") .collection("Profiles")
.doc(event.creatorId) .doc(event.creatorId)
@ -85,19 +207,28 @@ export const useGetEvents = () => {
return { return {
...event, ...event,
id: event.id || Math.random().toString(36).slice(2, 9), // Generate temp ID if missing start: event.allDay
title: event.title, ? new Date(new Date(event.startDate.seconds * 1000).setHours(0, 0, 0, 0))
start: new Date(event.startDate.seconds * 1000), : new Date(event.startDate.seconds * 1000),
end: new Date(event.endDate.seconds * 1000), end: event.allDay
? new Date(new Date(event.endDate.seconds * 1000).setHours(0, 0, 0, 0))
: new Date(event.endDate.seconds * 1000),
hideHours: event.allDay, hideHours: event.allDay,
eventColor, eventColor,
notes: event.notes, notes: event.notes,
}; };
}) })
); );
console.log(`Events processing completed, returning ${processedEvents.length} events`);
return processedEvents;
}, },
staleTime: Infinity, staleTime: 5 * 60 * 1000,
cacheTime: Infinity, cacheTime: 30 * 60 * 1000,
keepPreviousData: true, keepPreviousData: true,
onError: (error) => {
console.error('Error fetching events:', error);
}
}); });
}; };

View File

@ -1,11 +1,35 @@
import {useQuery} from "react-query"; import { useQuery } from "react-query";
import firestore from "@react-native-firebase/firestore"; import firestore from "@react-native-firebase/firestore";
import {useAuthContext} from "@/contexts/AuthContext"; import { useAuthContext } from "@/contexts/AuthContext";
interface FirestoreTimestamp {
seconds: number;
nanoseconds: number;
}
interface NotificationFirestore {
creatorId: string;
familyId: string;
content: string;
eventId: string;
timestamp: FirestoreTimestamp;
date?: FirestoreTimestamp;
}
export interface Notification {
id: string;
creatorId: string;
familyId: string;
content: string;
eventId: string;
timestamp: Date;
date?: Date;
}
export const useGetNotifications = () => { export const useGetNotifications = () => {
const { user, profileData } = useAuthContext(); const { user, profileData } = useAuthContext();
return useQuery({ return useQuery<Notification[], Error>({
queryKey: ["notifications", user?.uid], queryKey: ["notifications", user?.uid],
queryFn: async () => { queryFn: async () => {
const snapshot = await firestore() const snapshot = await firestore()
@ -14,16 +38,17 @@ export const useGetNotifications = () => {
.get(); .get();
return snapshot.docs.map((doc) => { return snapshot.docs.map((doc) => {
const data = doc.data(); const data = doc.data() as NotificationFirestore;
return {...data, timestamp: new Date(data.timestamp.seconds * 1000 + data.timestamp.nanoseconds / 1e6)} as { return {
creatorId: string, id: doc.id,
familyId: string, ...data,
content: string, timestamp: new Date(data.timestamp.seconds * 1000 + data.timestamp.nanoseconds / 1e6),
eventId: string, date: data.date ? new Date(data.date.seconds * 1000 + data.date.nanoseconds / 1e6) : undefined
timestamp: Date,
}; };
}); });
} },
}) refetchOnWindowFocus: true,
staleTime: 60000,
});
}; };

View File

@ -1,11 +1,14 @@
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 {useRouter} from "expo-router";
export const useSignOut = () => { export const useSignOut = () => {
const {replace} = useRouter();
return useMutation({ return useMutation({
mutationKey: ["signOut"], mutationKey: ["signOut"],
mutationFn: async () => { mutationFn: async () => {
await auth().signOut() await auth().signOut()
replace("/(unauth)")
} }
}); });
} }

View File

@ -2,7 +2,7 @@ import {useAuthContext} from "@/contexts/AuthContext";
import {useEffect} from "react"; import {useEffect} from "react";
import {useUpdateUserData} from "@/hooks/firebase/useUpdateUserData"; import {useUpdateUserData} from "@/hooks/firebase/useUpdateUserData";
import {useFetchAndSaveGoogleEvents} from "@/hooks/useFetchAndSaveGoogleEvents"; import {useFetchAndSaveGoogleEvents} from "@/hooks/useFetchAndSaveGoogleEvents";
import {useFetchAndSaveOutlookEvents} from "@/hooks/useFetchAndSaveOutlookEvents"; import {useFetchAndSaveMicrosoftEvents} from "@/hooks/useFetchAndSaveOutlookEvents";
import {useFetchAndSaveAppleEvents} from "@/hooks/useFetchAndSaveAppleEvents"; import {useFetchAndSaveAppleEvents} from "@/hooks/useFetchAndSaveAppleEvents";
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from "expo-web-browser";
import * as Google from "expo-auth-session/providers/google"; import * as Google from "expo-auth-session/providers/google";
@ -10,14 +10,12 @@ import * as AuthSession from "expo-auth-session";
import * as AppleAuthentication from "expo-apple-authentication"; import * as AppleAuthentication from "expo-apple-authentication";
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import {useQueryClient} from "react-query"; import {useQueryClient} from "react-query";
import {AppleAccount, GoogleAccount, MicrosoftAccount} from "@/hooks/firebase/types/profileTypes";
const googleConfig = { const googleConfig = {
androidClientId: androidClientId: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com", iosClientId: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
iosClientId: webClientId: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
webClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
scopes: [ scopes: [
"email", "email",
"profile", "profile",
@ -39,18 +37,32 @@ const microsoftConfig = {
"Calendars.ReadWrite", "Calendars.ReadWrite",
"User.Read", "User.Read",
], ],
authorizationEndpoint: authorizationEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
tokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token", tokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
}; };
interface SyncResponse {
success: boolean;
eventCount?: number;
error?: string;
}
interface CalendarSyncResult {
data: {
success: boolean;
eventCount: number;
message?: string;
error?: string;
}
}
export const useCalSync = () => { export const useCalSync = () => {
const {profileData} = useAuthContext(); const {profileData} = useAuthContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const {mutateAsync: updateUserData} = useUpdateUserData(); const {mutateAsync: updateUserData} = useUpdateUserData();
const {mutateAsync: fetchAndSaveGoogleEvents, isLoading: isSyncingGoogle} = useFetchAndSaveGoogleEvents(); const {mutateAsync: fetchAndSaveGoogleEvents, isLoading: isSyncingGoogle} = useFetchAndSaveGoogleEvents();
const {mutateAsync: fetchAndSaveOutlookEvents, isLoading: isSyncingOutlook} = useFetchAndSaveOutlookEvents(); const {mutateAsync: fetchAndSaveOutlookEvents, isLoading: isSyncingOutlook} = useFetchAndSaveMicrosoftEvents();
const {mutateAsync: fetchAndSaveAppleEvents, isLoading: isSyncingApple} = useFetchAndSaveAppleEvents(); const {mutateAsync: fetchAndSaveAppleEvents, isLoading: isSyncingApple} = useFetchAndSaveAppleEvents();
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
@ -72,89 +84,68 @@ export const useCalSync = () => {
} }
); );
console.log(response)
const userInfo = await userInfoResponse.json(); const userInfo = await userInfoResponse.json();
const googleMail = userInfo.email; const googleMail = userInfo.email;
let googleAccounts = profileData?.googleAccounts || {}; const googleAccount: GoogleAccount = {
const updatedGoogleAccounts = { accessToken,
...googleAccounts, refreshToken,
[googleMail]: {accessToken, refreshToken}, email: googleMail,
expiresAt: new Date(Date.now() + 3600 * 1000),
scope: googleConfig.scopes.join(' ')
}; };
console.log({refreshToken})
await updateUserData({ await updateUserData({
newUserData: {googleAccounts: updatedGoogleAccounts}, newUserData: {
googleAccounts: {
...profileData?.googleAccounts,
[googleMail]: googleAccount
}
},
}); });
await fetchAndSaveGoogleEvents({ await fetchAndSaveGoogleEvents({email: googleMail});
token: accessToken,
refreshToken: refreshToken,
email: googleMail,
});
} }
} catch (error) { } catch (error) {
console.error("Error during Google sign-in:", error); console.error("Error during Google sign-in:", error);
throw error;
} }
}; };
const handleMicrosoftSignIn = async () => { const handleMicrosoftSignIn = async () => {
try { try {
console.log("Starting Microsoft sign-in...");
const authRequest = new AuthSession.AuthRequest({ const authRequest = new AuthSession.AuthRequest({
clientId: microsoftConfig.clientId, clientId: microsoftConfig.clientId,
scopes: microsoftConfig.scopes, scopes: microsoftConfig.scopes,
redirectUri: microsoftConfig.redirectUri, redirectUri: microsoftConfig.redirectUri,
responseType: AuthSession.ResponseType.Code, responseType: AuthSession.ResponseType.Code,
usePKCE: true, // Enable PKCE usePKCE: true,
}); });
console.log("Auth request created:", authRequest);
const authResult = await authRequest.promptAsync({ const authResult = await authRequest.promptAsync({
authorizationEndpoint: microsoftConfig.authorizationEndpoint, authorizationEndpoint: microsoftConfig.authorizationEndpoint,
}); });
console.log("Auth result:", authResult);
if (authResult.type === "success" && authResult.params?.code) { if (authResult.type === "success" && authResult.params?.code) {
const code = 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, { const tokenResponse = await fetch(microsoftConfig.tokenEndpoint, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
body: `client_id=${ body: `client_id=${microsoftConfig.clientId}&redirect_uri=${encodeURIComponent(
microsoftConfig.clientId
}&redirect_uri=${encodeURIComponent(
microsoftConfig.redirectUri microsoftConfig.redirectUri
)}&grant_type=authorization_code&code=${code}&code_verifier=${ )}&grant_type=authorization_code&code=${code}&code_verifier=${
authRequest.codeVerifier authRequest.codeVerifier
}&scope=${encodeURIComponent( }&scope=${encodeURIComponent(microsoftConfig.scopes.join(' '))}`,
"https://graph.microsoft.com/Calendars.ReadWrite offline_access User.Read"
)}`,
}); });
console.log("Token response status:", tokenResponse.status);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
const errorText = await tokenResponse.text(); throw new Error(await tokenResponse.text());
console.error("Token exchange failed:", errorText);
return;
} }
const tokenData = await tokenResponse.json(); 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( const userInfoResponse = await fetch(
"https://graph.microsoft.com/v1.0/me", "https://graph.microsoft.com/v1.0/me",
{ {
@ -165,41 +156,34 @@ export const useCalSync = () => {
); );
const userInfo = await userInfoResponse.json(); 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; const outlookMail = userInfo.mail || userInfo.userPrincipalName;
let microsoftAccounts = profileData?.microsoftAccounts; const microsoftAccount: MicrosoftAccount = {
const updatedMicrosoftAccounts = microsoftAccounts accessToken: tokenData.access_token,
? {...microsoftAccounts, [outlookMail]: tokenData.access_token} refreshToken: tokenData.refresh_token,
: {[outlookMail]: tokenData.access_token}; email: outlookMail,
expiresAt: new Date(Date.now() + tokenData.expires_in * 1000),
};
await updateUserData({ await updateUserData({
newUserData: {microsoftAccounts: updatedMicrosoftAccounts}, newUserData: {
microsoftAccounts: {
...profileData?.microsoftAccounts,
[outlookMail]: microsoftAccount
}
},
}); });
await fetchAndSaveOutlookEvents( await fetchAndSaveOutlookEvents({email: outlookMail});
tokenData.access_token,
outlookMail
);
console.log("User data updated successfully.");
}
}
} else {
console.warn("Authentication was not successful:", authResult);
} }
} catch (error) { } catch (error) {
console.error("Error during Microsoft sign-in:", error); console.error("Error during Microsoft sign-in:", error);
throw error;
} }
}; };
const handleAppleSignIn = async () => { const handleAppleSignIn = async () => {
try { try {
console.log("Starting Apple Sign-in...");
const credential = await AppleAuthentication.signInAsync({ const credential = await AppleAuthentication.signInAsync({
requestedScopes: [ requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.EMAIL, AppleAuthentication.AppleAuthenticationScope.EMAIL,
@ -207,117 +191,124 @@ export const useCalSync = () => {
], ],
}); });
console.log("Apple sign-in result:", credential);
alert(JSON.stringify(credential))
const appleToken = credential.identityToken; const appleToken = credential.identityToken;
const appleMail = credential.email!; const appleMail = credential.email!;
if (appleToken) { if (appleToken) {
console.log("Apple ID token received. Fetch user info if needed..."); const appleAccount: AppleAccount = {
accessToken: appleToken,
email: appleMail,
identityToken: credential.identityToken!,
expiresAt: new Date(Date.now() + 3600 * 1000)
};
let appleAcounts = profileData?.appleAccounts; const updatedAppleAccounts = {
const updatedAppleAccounts = appleAcounts ...profileData?.appleAccounts,
? {...appleAcounts, [appleMail]: appleToken} [appleMail]: appleAccount
: {[appleMail]: appleToken}; };
await updateUserData({ await updateUserData({
newUserData: {appleAccounts: updatedAppleAccounts}, newUserData: {appleAccounts: updatedAppleAccounts},
}); });
console.log("User data updated with Apple ID token."); await fetchAndSaveAppleEvents({email: appleMail});
await fetchAndSaveAppleEvents({token: appleToken, email: appleMail!});
} else {
console.warn(
"Apple authentication was not successful or email was hidden."
);
} }
} catch (error) { } catch (error) {
console.error("Error during Apple Sign-in:", error); console.error("Error during Apple Sign-in:", error);
} }
}; };
const resyncAllCalendars = async (): Promise<void> => { const resyncAllCalendars = async (): Promise<void> => {
try { try {
const syncPromises: Promise<void>[] = []; const results: SyncResponse[] = [];
if (profileData?.googleAccounts) { if (profileData?.googleAccounts) {
console.log(profileData.googleAccounts) for (const email of Object.keys(profileData.googleAccounts)) {
for (const [email, emailAcc] of Object.entries(profileData.googleAccounts)) { try {
if(emailAcc?.accessToken) { const result = await fetchAndSaveGoogleEvents({email});
syncPromises.push(fetchAndSaveGoogleEvents({ token: emailAcc?.accessToken, refreshToken: emailAcc?.refreshToken, email })); results.push({
success: result.success,
eventCount: result.eventCount
});
} catch (error: any) {
console.error(`Failed to sync Google calendar ${email}:`, error);
results.push({
success: false,
error: error.message
});
} }
} }
} }
if (profileData?.microsoftAccounts) { if (profileData?.microsoftAccounts) {
for (const [email, accessToken] of Object.entries(profileData.microsoftAccounts)) { for (const email of Object.keys(profileData.microsoftAccounts)) {
syncPromises.push(fetchAndSaveOutlookEvents(accessToken, email)); try {
const result = await fetchAndSaveOutlookEvents({email});
results.push({
success: result.success,
eventCount: result.eventCount
});
} catch (error: any) {
console.error(`Failed to sync Microsoft calendar ${email}:`, error);
results.push({
success: false,
error: error.message
});
}
} }
} }
if (profileData?.appleAccounts) { if (profileData?.appleAccounts) {
for (const [email, token] of Object.entries(profileData.appleAccounts)) { for (const email of Object.keys(profileData.appleAccounts)) {
syncPromises.push(fetchAndSaveAppleEvents({ token, email })); try {
const result = await fetchAndSaveAppleEvents({email});
results.push({
success: true,
});
} catch (error: any) {
console.error(`Failed to sync Apple calendar ${email}:`, error);
results.push({
success: false,
error: error.message
});
}
} }
} }
await Promise.all(syncPromises); const successCount = results.filter(r => r.success).length;
console.log("All calendars have been resynced."); const failCount = results.filter(r => !r.success).length;
const totalEvents = results.reduce((sum, r) => sum + (r.eventCount || 0), 0);
if (failCount > 0) {
console.error(`${failCount} calendar syncs failed, ${successCount} succeeded`);
results.filter(r => !r.success).forEach(r => {
console.error('Sync failed:', r.error);
});
} else if (successCount > 0) {
console.log(`Successfully synced ${successCount} calendars with ${totalEvents} total events`);
} else {
console.log("No calendars to sync");
}
} catch (error) { } catch (error) {
console.error("Error resyncing calendars:", error); console.error("Error in resyncAllCalendars:", error);
throw 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;
}
});
}
const isConnectedToGoogle = Object.values(profileData?.googleAccounts || {}).some(account => !!account);
const isConnectedToMicrosoft = Object.values(profileData?.microsoftAccounts || {}).some(account => !!account);
const isConnectedToApple = Object.values(profileData?.appleAccounts || {}).some(account => !!account);
useEffect(() => { useEffect(() => {
const handleNotification = async (notification: Notifications.Notification) => { const handleNotification = async (notification: Notifications.Notification) => {
const eventId = notification?.request?.content?.data?.eventId;
// await resyncAllCalendars();
queryClient.invalidateQueries(["events"]); queryClient.invalidateQueries(["events"]);
}; };
const sub = Notifications.addNotificationReceivedListener(handleNotification); const sub = Notifications.addNotificationReceivedListener(handleNotification);
return () => sub.remove(); return () => sub.remove();
}, []); }, []);
return { return {
handleAppleSignIn, handleAppleSignIn,
handleMicrosoftSignIn, handleMicrosoftSignIn,
@ -334,5 +325,5 @@ export const useCalSync = () => {
isSyncingApple, isSyncingApple,
resyncAllCalendars, resyncAllCalendars,
isSyncing: isSyncingApple || isSyncingOutlook || isSyncingGoogle isSyncing: isSyncingApple || isSyncingOutlook || isSyncingGoogle
} };
} };

View File

@ -1,107 +1,37 @@
import { useMutation, useQueryClient } from "react-query"; import {useMutation, useQueryClient} from "react-query";
import { fetchGoogleCalendarEvents } from "@/calendar-integration/google-calendar-utils"; import {useAuthContext} from "@/contexts/AuthContext";
import { useAuthContext } from "@/contexts/AuthContext"; import functions from "@react-native-firebase/functions";
import { useCreateEventsFromProvider } from "@/hooks/firebase/useCreateEvent";
import { useClearTokens } from "@/hooks/firebase/useClearTokens"; interface SyncResponse {
import { useUpdateUserData } from "@/hooks/firebase/useUpdateUserData"; success: boolean;
eventCount: number;
message?: string;
}
export const useFetchAndSaveGoogleEvents = () => { export const useFetchAndSaveGoogleEvents = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { profileData } = useAuthContext(); const { profileData } = useAuthContext();
const { mutateAsync: createEventsFromProvider } = useCreateEventsFromProvider();
const { mutateAsync: clearToken } = useClearTokens();
const { mutateAsync: updateUserData } = useUpdateUserData();
return useMutation({ return useMutation({
mutationKey: ["fetchAndSaveGoogleEvents", "sync"], mutationKey: ["fetchAndSaveGoogleEvents", "sync"],
mutationFn: async ({ token, email, date, refreshToken }: { token?: string; refreshToken?: string; email?: string; date?: Date }) => { mutationFn: async ({ email }: { email?: string }) => {
const baseDate = date || new Date(); if (!email || !profileData?.googleAccounts?.[email]) {
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString().slice(0, -5) + "Z"; throw new Error("No valid Google account found");
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString().slice(0, -5) + "Z"; }
console.log("Token: ", token);
const tryFetchEvents = async (isRetry = false) => {
try { try {
const response = await fetchGoogleCalendarEvents( const response = await functions()
token, .httpsCallable('triggerGoogleSync')({ email });
email,
profileData?.familyId,
timeMin,
timeMax
);
if (!response.success) { return response.data as SyncResponse;
await clearToken({ email: email!, provider: "google" }); } catch (error: any) {
return; // Stop refetching if clearing the token console.error("Error initiating Google Calendar sync:", error);
throw new Error(error.details?.message || error.message || "Failed to sync calendar");
} }
console.log("Google Calendar events fetched:", response);
const items = response?.googleEvents?.map((item) => {
if (item.allDay) {
item.startDate = new Date(item.startDate.setHours(0, 0, 0, 0));
item.endDate = item.startDate;
}
return item;
}) || [];
await createEventsFromProvider(items);
} catch (error) {
console.error("Error fetching Google Calendar events:", error);
if (!isRetry) {
const refreshedToken = await handleRefreshToken(email, refreshToken);
if (refreshedToken) {
await updateUserData({
newUserData: {
googleAccounts: {
...profileData.googleAccounts,
[email!]: { ...profileData.googleAccounts[email!], accessToken: refreshedToken },
}, },
}, onSuccess: (data) => {
});
return tryFetchEvents(true); // Retry once after refreshing
} else {
await clearToken({ email: email!, provider: "google" });
console.error(`Token refresh failed; token cleared for ${email}`);
throw error;
}
} else {
console.error(`Retry failed after refreshing token for user ${profileData?.email}:`, error.message);
throw error;
}
}
};
return tryFetchEvents();
},
onSuccess: () => {
queryClient.invalidateQueries(["events"]); queryClient.invalidateQueries(["events"]);
}, console.log(`Successfully synced ${data.eventCount} events`);
}
}); });
}; };
async function handleRefreshToken(email: string, refreshToken: string) {
if (!refreshToken) return null;
try {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
}),
});
const data = await response.json();
return data.access_token;
} catch (error) {
console.error("Error refreshing Google token:", error);
return null;
}
}

View File

@ -1,41 +1,144 @@
import {useMutation, useQueryClient} from "react-query"; import { useMutation, useQueryClient } from "react-query";
import {useAuthContext} from "@/contexts/AuthContext"; import { useAuthContext } from "@/contexts/AuthContext";
import {useCreateEventsFromProvider} from "@/hooks/firebase/useCreateEvent"; import { useSetUserData } from "@/hooks/firebase/useSetUserData";
import {fetchMicrosoftCalendarEvents} from "@/calendar-integration/microsoft-calendar-utils"; import functions from '@react-native-firebase/functions';
import * as AuthSession from 'expo-auth-session';
export const useFetchAndSaveOutlookEvents = () => { interface SyncResponse {
const queryClient = useQueryClient() success: boolean;
const {profileData} = useAuthContext(); eventCount: number;
const {mutateAsync: createEventsFromProvider} = useCreateEventsFromProvider(); message?: string;
}
return useMutation({ interface SyncError extends Error {
code?: string;
details?: {
requiresReauth?: boolean;
message?: string;
};
}
const microsoftConfig = {
clientId: "13c79071-1066-40a9-9f71-b8c4b138b4af",
scopes: [
"openid",
"profile",
"email",
"offline_access",
"Calendars.ReadWrite",
"User.Read",
],
redirectUri: AuthSession.makeRedirectUri({path: "settings"})
};
export const useFetchAndSaveMicrosoftEvents = () => {
const queryClient = useQueryClient();
const { profileData } = useAuthContext();
const { mutateAsync: setUserData } = useSetUserData();
const handleReauth = async (email: string) => {
try {
const authRequest = new AuthSession.AuthRequest({
clientId: microsoftConfig.clientId,
scopes: microsoftConfig.scopes,
redirectUri: microsoftConfig.redirectUri,
responseType: AuthSession.ResponseType.Code,
usePKCE: true,
});
const result = await authRequest.promptAsync({
authorizationEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
});
if (result.type === 'success' && result.params?.code) {
const tokenResponse = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: microsoftConfig.clientId,
scope: microsoftConfig.scopes.join(' '),
code: result.params.code,
redirect_uri: microsoftConfig.redirectUri,
grant_type: 'authorization_code',
code_verifier: authRequest.codeVerifier || '',
}),
});
const tokens = await tokenResponse.json();
await setUserData({
newUserData: {
microsoftAccounts: {
...profileData?.microsoftAccounts,
[email]: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
email,
}
}
}
});
return true;
}
return false;
} catch (error) {
console.error('Microsoft reauth error:', error);
return false;
}
};
return useMutation<SyncResponse, SyncError, { email?: string }>({
mutationKey: ["fetchAndSaveOutlookEvents", "sync"], mutationKey: ["fetchAndSaveOutlookEvents", "sync"],
mutationFn: async ({token, email, date}: { token?: string; email?: string, date?: Date }) => { mutationFn: async ({ email }: { email?: string }) => {
const baseDate = date || new Date(); if (!email) {
const timeMin = new Date(new Date(baseDate).setFullYear(new Date(baseDate).getMonth() - 1)); throw new Error("Email is required");
const timeMax = new Date(new Date(baseDate).setFullYear(new Date(baseDate).getMonth() + 1)); }
console.log("Token: ", token ?? profileData?.microsoftToken); if (!profileData?.microsoftAccounts?.[email]) {
throw new Error("No valid Microsoft account found");
}
try { try {
const response = await fetchMicrosoftCalendarEvents( const response = await functions()
token ?? profileData?.microsoftToken, .httpsCallable('triggerMicrosoftSync')({ email });
email ?? profileData?.outlookMail,
profileData?.familyId, return response.data as SyncResponse;
timeMin.toISOString().slice(0, -5) + "Z", } catch (error: any) {
timeMax.toISOString().slice(0, -5) + "Z" console.error("Microsoft sync error:", error);
);
// Check if we need to reauthenticate
if (error.details?.requiresReauth ||
error.code === 'functions/failed-precondition' ||
error.code === 'functions/unauthenticated') {
console.log('Attempting Microsoft reauth...');
const reauthSuccessful = await handleReauth(email);
if (reauthSuccessful) {
// Retry the sync with new tokens
console.log('Retrying sync after reauth...');
const retryResponse = await functions()
.httpsCallable('triggerMicrosoftSync')({ email });
return retryResponse.data as SyncResponse;
}
}
console.log(response);
const items = response ?? [];
await createEventsFromProvider(items);
} catch (error) {
console.error("Error fetching and saving Outlook events: ", error);
throw error; throw error;
} }
}, },
onSuccess: () => { onSuccess: (data) => {
queryClient.invalidateQueries(["events"]) queryClient.invalidateQueries(["events"]);
console.log(`Successfully synced ${data.eventCount} Microsoft events`);
}, },
onError: (error) => {
console.error('Microsoft sync failed:', {
message: error.message,
code: error.code,
details: error.details
});
}
}); });
}; };

View File

@ -3,10 +3,16 @@ import { useAuthContext } from "@/contexts/AuthContext";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useFetchAndSaveGoogleEvents } from "./useFetchAndSaveGoogleEvents"; import { useFetchAndSaveGoogleEvents } from "./useFetchAndSaveGoogleEvents";
import { useFetchAndSaveAppleEvents } from "./useFetchAndSaveAppleEvents"; import { useFetchAndSaveAppleEvents } from "./useFetchAndSaveAppleEvents";
import { useFetchAndSaveOutlookEvents } from "./useFetchAndSaveOutlookEvents"; import { useFetchAndSaveMicrosoftEvents } from "./useFetchAndSaveOutlookEvents";
import { selectedDateAtom } from "@/components/pages/calendar/atoms"; import { selectedDateAtom } from "@/components/pages/calendar/atoms";
import { addDays, subDays, isBefore, isAfter, format } from "date-fns"; import { addDays, subDays, isBefore, isAfter, format } from "date-fns";
interface SyncResponse {
success: boolean;
eventCount?: number;
error?: string;
}
export const useSyncEvents = () => { export const useSyncEvents = () => {
const { profileData } = useAuthContext(); const { profileData } = useAuthContext();
const selectedDate = useAtomValue(selectedDateAtom); const selectedDate = useAtomValue(selectedDateAtom);
@ -15,12 +21,18 @@ export const useSyncEvents = () => {
const [lowerBoundDate, setLowerBoundDate] = useState<Date>(subDays(selectedDate, 6 * 30)); const [lowerBoundDate, setLowerBoundDate] = useState<Date>(subDays(selectedDate, 6 * 30));
const [upperBoundDate, setUpperBoundDate] = useState<Date>(addDays(selectedDate, 6 * 30)); const [upperBoundDate, setUpperBoundDate] = useState<Date>(addDays(selectedDate, 6 * 30));
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState<Error | null>(null);
const [syncStats, setSyncStats] = useState<{
total: number;
success: number;
failed: number;
events: number;
}>({ total: 0, success: 0, failed: 0, events: 0 });
const syncedRanges = useState<Set<string>>(new Set())[0]; const syncedRanges = useState<Set<string>>(new Set())[0];
const { mutateAsync: fetchAndSaveGoogleEvents } = useFetchAndSaveGoogleEvents(); const { mutateAsync: fetchAndSaveGoogleEvents } = useFetchAndSaveGoogleEvents();
const { mutateAsync: fetchAndSaveOutlookEvents } = useFetchAndSaveOutlookEvents(); const { mutateAsync: fetchAndSaveOutlookEvents } = useFetchAndSaveMicrosoftEvents();
const { mutateAsync: fetchAndSaveAppleEvents } = useFetchAndSaveAppleEvents(); const { mutateAsync: fetchAndSaveAppleEvents } = useFetchAndSaveAppleEvents();
const generateRangeKey = (startDate: Date, endDate: Date) => { const generateRangeKey = (startDate: Date, endDate: Date) => {
@ -41,26 +53,71 @@ export const useSyncEvents = () => {
} }
if (isBefore(selectedDate, lowerBoundDate) || isAfter(selectedDate, upperBoundDate)) { if (isBefore(selectedDate, lowerBoundDate) || isAfter(selectedDate, upperBoundDate)) {
const results: SyncResponse[] = [];
const stats = { total: 0, success: 0, failed: 0, events: 0 };
try { try {
const googleEvents = Object.entries(profileData?.googleAccounts || {}).map(([email, { accessToken }]) => if (profileData?.googleAccounts) {
fetchAndSaveGoogleEvents({ token: accessToken, email, date: selectedDate }) for (const [email] of Object.entries(profileData.googleAccounts)) {
); try {
stats.total++;
const result = await fetchAndSaveGoogleEvents({ email }) as SyncResponse;
if (result.success) {
stats.success++;
stats.events += result.eventCount || 0;
} else {
stats.failed++;
}
results.push(result);
} catch (err) {
stats.failed++;
console.error(`Failed to sync Google calendar for ${email}:`, err);
}
}
}
const outlookEvents = Object.entries(profileData?.microsoftAccounts || {}).map(([email, token]) => if (profileData?.microsoftAccounts) {
fetchAndSaveOutlookEvents({ token, email, date: selectedDate }) for (const [email] of Object.entries(profileData.microsoftAccounts)) {
); try {
stats.total++;
const result = await fetchAndSaveOutlookEvents({ email });
if (result.success) {
stats.success++;
stats.events += result.eventCount || 0;
} else {
stats.failed++;
}
results.push(result);
} catch (err) {
stats.failed++;
console.error(`Failed to sync Microsoft calendar for ${email}:`, err);
}
}
}
const appleEvents = Object.entries(profileData?.appleAccounts || {}).map(([email, token]) => if (profileData?.appleAccounts) {
fetchAndSaveAppleEvents({ token, email, date: selectedDate }) for (const [email] of Object.entries(profileData.appleAccounts)) {
); try {
stats.total++;
await Promise.all([...googleEvents, ...outlookEvents, ...appleEvents]); const result = await fetchAndSaveAppleEvents({ email });
} catch (err) {
stats.failed++;
console.error(`Failed to sync Apple calendar for ${email}:`, err);
}
}
}
setSyncStats(stats);
setLastSyncDate(selectedDate); setLastSyncDate(selectedDate);
setLowerBoundDate(newLowerBound); setLowerBoundDate(newLowerBound);
setUpperBoundDate(newUpperBound); setUpperBoundDate(newUpperBound);
syncedRanges.add(rangeKey); syncedRanges.add(rangeKey);
} catch (err) {
if (stats.failed > 0) {
throw new Error(`Failed to sync ${stats.failed} calendars`);
}
} catch (err: any) {
console.error("Error syncing events:", err); console.error("Error syncing events:", err);
setError(err); setError(err);
} finally { } finally {
@ -69,7 +126,16 @@ export const useSyncEvents = () => {
} else { } else {
setIsSyncing(false); setIsSyncing(false);
} }
}, [selectedDate, lowerBoundDate, upperBoundDate, profileData, fetchAndSaveGoogleEvents, fetchAndSaveOutlookEvents, fetchAndSaveAppleEvents, syncedRanges]); }, [
selectedDate,
lowerBoundDate,
upperBoundDate,
profileData,
fetchAndSaveGoogleEvents,
fetchAndSaveOutlookEvents,
fetchAndSaveAppleEvents,
syncedRanges
]);
useEffect(() => { useEffect(() => {
syncEvents(); syncEvents();
@ -81,5 +147,6 @@ export const useSyncEvents = () => {
lastSyncDate, lastSyncDate,
lowerBoundDate, lowerBoundDate,
upperBoundDate, upperBoundDate,
syncStats,
}; };
}; };

View File

@ -450,11 +450,11 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.cally.app; PRODUCT_BUNDLE_IDENTIFIER = com.cally.app;
PRODUCT_NAME = Cally; PRODUCT_NAME = "Cally";
SWIFT_OBJC_BRIDGING_HEADER = "cally/cally-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "cally/cally-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Debug; name = Debug;
@ -484,10 +484,10 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.cally.app; PRODUCT_BUNDLE_IDENTIFIER = com.cally.app;
PRODUCT_NAME = Cally; PRODUCT_NAME = "Cally";
SWIFT_OBJC_BRIDGING_HEADER = "cally/cally-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "cally/cally-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Release; name = Release;

View File

@ -48,6 +48,8 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>74</string> <string>74</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
@ -154,6 +156,8 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>SplashScreen</string> <string>SplashScreen</string>

View File

@ -9,8 +9,10 @@
"ios": "TAMAGUI_TARGET=native npx expo run:ios", "ios": "TAMAGUI_TARGET=native npx expo run:ios",
"ios-native": "TAMAGUI_TARGET=native npx expo run:ios --device", "ios-native": "TAMAGUI_TARGET=native npx expo run:ios --device",
"dev-ios": "TAMAGUI_TARGET=native npx eas-cli build --profile development --platform ios", "dev-ios": "TAMAGUI_TARGET=native npx eas-cli build --profile development --platform ios",
"dev-android": "TAMAGUI_TARGET=native npx eas-cli build --profile development --platform android",
"build-ios": "TAMAGUI_TARGET=native npx eas-cli build --profile production --platform ios", "build-ios": "TAMAGUI_TARGET=native npx eas-cli build --profile production --platform ios",
"build-android": "TAMAGUI_TARGET=native npx eas-cli build --profile production --platform android", "build-android": "TAMAGUI_TARGET=native npx eas-cli build --profile production --platform android",
"build-apk": "TAMAGUI_TARGET=native eas build -p android --profile preview",
"build-cicd": "TAMAGUI_TARGET=native npx eas-cli build --profile production --platform all --non-interactive --no-wait --auto-submit ", "build-cicd": "TAMAGUI_TARGET=native npx eas-cli build --profile production --platform all --non-interactive --no-wait --auto-submit ",
"build-ios-cicd": "TAMAGUI_TARGET=native npx eas-cli build --profile production --platform ios --non-interactive --no-wait --auto-submit ", "build-ios-cicd": "TAMAGUI_TARGET=native npx eas-cli build --profile production --platform ios --non-interactive --no-wait --auto-submit ",
"build-dev-ios": "TAMAGUI_TARGET=native npx eas-cli build --profile development --platform ios", "build-dev-ios": "TAMAGUI_TARGET=native npx eas-cli build --profile development --platform ios",