Notification changes

This commit is contained in:
Milan Paunovic
2024-11-22 03:25:16 +01:00
parent f74a6390a2
commit 06a3a2dc8f
33 changed files with 1961 additions and 1447 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

@ -17,7 +17,10 @@
"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, {useMemo} 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,96 @@ 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 = useMemo(
<Drawer () =>
initialRouteName={"index"} ({
detachInactiveScreens navigation,
screenOptions={({ navigation, route }) => ({ 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 +131,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 +143,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 +156,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 +169,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 +197,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 +210,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 +248,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 +261,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 +302,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 +352,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,28 @@
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 { 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";
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 isTablet: boolean = Device.deviceType === DeviceType.TABLET;
const { const {
isConnectedToGoogle,
isConnectedToMicrosoft,
isConnectedToApple,
resyncAllCalendars, resyncAllCalendars,
isSyncing, isSyncing,
} = useCalSync(); } = 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 +32,23 @@ 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={{
position: "absolute",
left: "50%", // Position at screen center
transform: [
// Offset by half its own width
{ translateX: -20 }, // Assuming the refresh control is ~40px wide
],
}}
/> />
} );
return (
<ScrollView
style={{flex: 1, height: "100%",}}
contentContainerStyle={{flex: 1, height: "100%"}}
refreshControl={refreshControl}
bounces={true} bounces={true}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
pointerEvents={refreshing || isSyncing ? "auto" : "none"} >
/> <View style={{flex: 1}}>
{isTablet ? <TabletCalendarPage/> : <CalendarPage/>}
</View> </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,17 +1,14 @@
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";
import { colorMap } from "@/constants/colorMap"; import {ScrollView} from "react-native-gesture-handler";
import { ScrollView } from "react-native-gesture-handler";
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(
@ -37,7 +34,7 @@ const TabletChoresPage = () => {
<View row centerV> <View row centerV>
{user.pfp ? ( {user.pfp ? (
<ImageBackground <ImageBackground
source={{ uri: user.pfp }} source={{uri: user.pfp}}
style={[ style={[
styles.pfp, styles.pfp,
(user.eventColor && { (user.eventColor && {
@ -63,11 +60,11 @@ const TabletChoresPage = () => {
<Text style={styles.name} marginL-15> <Text style={styles.name} marginL-15>
{user.firstName} {user.firstName}
</Text> </Text>
<Text style={[styles.name, { color: "#9b9b9b" }]} marginL-5> <Text style={[styles.name, {color: "#9b9b9b"}]} marginL-5>
({user.userType}) ({user.userType})
</Text> </Text>
</View> </View>
<SingleUserChoreList user={user} /> <SingleUserChoreList user={user}/>
</View> </View>
))} ))}
</View> </View>

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,25 +1,15 @@
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 {modeMap, 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 { MaterialIcons } from "@expo/vector-icons";
import { modeMap, months } from "./constants";
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 handleSegmentChange = (index: number) => { const handleSegmentChange = (index: number) => {
const selectedMode = modeMap.get(index); const selectedMode = modeMap.get(index);
@ -57,23 +47,23 @@ 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>
@ -106,7 +96,7 @@ export const CalendarHeader = memo(() => {
<View> <View>
<SegmentedControl <SegmentedControl
segments={[{ label: "D" }, { label: "W" }, { label: "M" }]} segments={[{label: "D"}, {label: "W"}, {label: "M"}]}
backgroundColor="#ececec" backgroundColor="#ececec"
inactiveColor="#919191" inactiveColor="#919191"
activeBackgroundColor="#ea156c" activeBackgroundColor="#ea156c"

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,21 +1,21 @@
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, 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, 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";
@ -32,9 +32,9 @@ const getTotalMinutes = () => {
}; };
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, refetch} = 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 +42,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()
@ -84,8 +83,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
setSelectedNewEndDate(date); setSelectedNewEndDate(date);
setEditVisible(true); setEditVisible(true);
} }
if (mode === 'week') if (mode === 'week') {
{
setSelectedDate(date) setSelectedDate(date)
setMode("day") setMode("day")
@ -108,7 +106,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
eventColor = profileData?.eventColor ?? colorMap.teal; eventColor = profileData?.eventColor ?? colorMap.teal;
} }
return { backgroundColor: eventColor , fontSize: 14} return {backgroundColor: eventColor, fontSize: 14}
}, },
[] []
); );
@ -118,8 +116,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() &&
@ -151,7 +147,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
} }
}, [mode]); }, [mode]);
const { enrichedEvents, filteredEvents } = useMemo(() => { const {enrichedEvents, filteredEvents} = useMemo(() => {
const startTime = Date.now(); // Start timer const startTime = Date.now(); // Start timer
const startOffset = mode === "month" ? 40 : mode === "week" ? 10 : 1; const startOffset = mode === "month" ? 40 : mode === "week" ? 10 : 1;
@ -189,7 +185,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,9 +221,9 @@ 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>
@ -244,17 +240,6 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
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}>
@ -317,14 +302,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%'}}/>
</> </>
); );

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

@ -1,14 +1,25 @@
import {FlatList, StyleSheet} from "react-native"; import {FlatList, StyleSheet} from "react-native";
import React from "react"; import React, {useCallback} 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";
const NotificationsPage = () => { const NotificationsPage = () => {
const {data: notifications} = useGetNotifications() const setSelectedDate = useSetAtom(selectedDateAtom);
const setMode = useSetAtom(modeAtom);
console.log(notifications?.[0]) const {data: notifications} = useGetNotifications();
const {push} = useRouter();
const goToEventDay = useCallback((notification: Notification) => () => {
if (notification?.date) {
setSelectedDate(notification.date);
setMode("day")
}
push({pathname: "/calendar"});
}, [push, setSelectedDate]);
return ( return (
@ -18,32 +29,56 @@ 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={({item}) => (
<Card
padding-20
marginB-10
key={item.content}
onPress={goToEventDay(item)}
activeOpacity={0.6}
enableShadow={false}
style={styles.card}
>
<Text text70>{item.content}</Text> <Text text70>{item.content}</Text>
<View row spread> <View row spread marginT-10>
<Text <Text text90>
text90>{formatDistanceToNow(new Date(item.timestamp), {addSuffix: true})}</Text> {formatDistanceToNow(new Date(item.timestamp), {addSuffix: true})}
<Text text90>{item.timestamp.toLocaleDateString()}</Text> </Text>
<Text text90>
{item.timestamp.toLocaleDateString()}
</Text>
</View> </View>
</Card>}/> </Card>
)}
/>
</View> </View>
</View> </View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
listContainer: {
paddingBottom: 10,
paddingHorizontal: 25,
},
card: {
width: '100%',
backgroundColor: 'white',
},
subtitle: {
fontFamily: "Manrope_400Regular",
fontSize: 14,
},
searchField: { searchField: {
borderWidth: 0.7, borderWidth: 0.7,
borderColor: "#9b9b9b", borderColor: "#9b9b9b",

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";
type MyGroupProps = { type MyGroupProps = {

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 } from "@/contexts/AuthContext"; import { ProfileType } 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

@ -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

@ -18,23 +18,190 @@ const GOOGLE_CALENDAR_ID = "primary";
const CHANNEL_ID = "cally-family-calendar"; const CHANNEL_ID = "cally-family-calendar";
const WEBHOOK_URL = "https://us-central1-cally-family-calendar.cloudfunctions.net/sendSyncNotification"; const WEBHOOK_URL = "https://us-central1-cally-family-calendar.cloudfunctions.net/sendSyncNotification";
async function getPushTokensForFamily(familyId, excludeUserId = null) {
const usersRef = db.collection('Profiles');
const snapshot = await usersRef.where('familyId', '==', familyId).get();
let pushTokens = [];
snapshot.forEach(doc => {
const data = doc.data();
if ((!excludeUserId || data.uid !== excludeUserId) && data.pushToken) {
pushTokens.push(data.pushToken);
}
});
return pushTokens;
}
exports.sendOverviews = functions.pubsub
.schedule('0 20 * * *')
.onRun(async (context) => {
const familiesSnapshot = await admin.firestore().collection('Families').get();
for (const familyDoc of familiesSnapshot.docs) {
const familyId = familyDoc.id;
const familySettings = familyDoc.data()?.settings || {};
const overviewTime = familySettings.overviewTime || '20:00';
const [hours, minutes] = overviewTime.split(':');
const now = new Date();
if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) {
continue;
}
try {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const tomorrowEnd = new Date(tomorrow);
tomorrowEnd.setHours(23, 59, 59, 999);
const weekEnd = new Date(tomorrow);
weekEnd.setDate(weekEnd.getDate() + 7);
weekEnd.setHours(23, 59, 59, 999);
const [tomorrowEvents, weekEvents] = await Promise.all([
admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>=', tomorrow)
.where('startDate', '<=', tomorrowEnd)
.orderBy('startDate')
.limit(3)
.get(),
admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>', tomorrowEnd)
.where('startDate', '<=', weekEnd)
.orderBy('startDate')
.limit(3)
.get()
]);
if (tomorrowEvents.empty && weekEvents.empty) {
continue;
}
let notificationBody = '';
if (!tomorrowEvents.empty) {
notificationBody += 'Tomorrow: ';
const tomorrowTitles = tomorrowEvents.docs.map(doc => doc.data().title);
notificationBody += tomorrowTitles.join(', ');
if (tomorrowEvents.size === 3) notificationBody += ' and more...';
}
if (!weekEvents.empty) {
if (notificationBody) notificationBody += '\n\n';
notificationBody += 'This week: ';
const weekTitles = weekEvents.docs.map(doc => doc.data().title);
notificationBody += weekTitles.join(', ');
if (weekEvents.size === 3) notificationBody += ' and more...';
}
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Family Calendar Overview",
body: notificationBody,
data: {
type: 'calendar_overview',
date: tomorrow.toISOString()
}
});
await storeNotification({
type: 'calendar_overview',
familyId,
content: notificationBody,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
});
} catch (error) {
console.error(`Error sending overview for family ${familyId}:`, error);
}
}
});
exports.sendWeeklyOverview = functions.pubsub
.schedule('0 20 * * 0')
.onRun(async (context) => {
const familiesSnapshot = await admin.firestore().collection('Families').get();
for (const familyDoc of familiesSnapshot.docs) {
const familyId = familyDoc.id;
const familySettings = familyDoc.data()?.settings || {};
const overviewTime = familySettings.weeklyOverviewTime || '20:00';
const [hours, minutes] = overviewTime.split(':');
const now = new Date();
if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) {
continue;
}
try {
const weekStart = new Date();
weekStart.setDate(weekStart.getDate() + 1);
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7);
const weekEvents = await admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>=', weekStart)
.where('startDate', '<=', weekEnd)
.orderBy('startDate')
.limit(3)
.get();
if (weekEvents.empty) continue;
const eventTitles = weekEvents.docs.map(doc => doc.data().title);
const notificationBody = `Next week: ${eventTitles.join(', ')}${weekEvents.size === 3 ? ' and more...' : ''}`;
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Weekly Calendar Overview",
body: notificationBody,
data: {
type: 'weekly_overview',
weekStart: weekStart.toISOString()
}
});
await storeNotification({
type: 'weekly_overview',
familyId,
content: notificationBody,
timestamp: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
console.error(`Error sending weekly overview for family ${familyId}:`, error);
}
}
});
exports.sendNotificationOnEventCreation = functions.firestore exports.sendNotificationOnEventCreation = functions.firestore
.document('Events/{eventId}') .document('Events/{eventId}')
.onCreate(async (snapshot, context) => { .onCreate(async (snapshot, context) => {
const eventData = snapshot.data(); const eventData = snapshot.data();
const { familyId, creatorId, email, title } = eventData; const {familyId, creatorId, email, title, externalOrigin} = eventData;
if (!!eventData?.externalOrigin) {
console.log('Externally synced event, ignoring.')
return;
}
if (!familyId || !creatorId) { if (!familyId || !creatorId) {
console.error('Missing familyId or creatorId in event data'); console.error('Missing familyId or creatorId in event data');
return; return;
} }
let pushTokens = await getPushTokensForFamilyExcludingCreator(familyId, creatorId); // Get push tokens - exclude creator for manual events, include everyone for synced events
let pushTokens = await getPushTokensForFamily(
familyId,
externalOrigin ? null : creatorId // Only exclude creator for manual events
);
if (!pushTokens.length) { if (!pushTokens.length) {
console.log('No push tokens available for the event.'); console.log('No push tokens available for the event.');
@ -48,9 +215,16 @@ exports.sendNotificationOnEventCreation = functions.firestore
notificationInProgress = true; notificationInProgress = true;
notificationTimeout = setTimeout(async () => { notificationTimeout = setTimeout(async () => {
const eventMessage = eventCount === 1 let eventMessage;
? `An event "${title}" has been added. Check it out!` if (externalOrigin) {
: `${eventCount} new events have been added.`; eventMessage = eventCount === 1
? `Calendar sync completed: "${title}" has been added.`
: `Calendar sync completed: ${eventCount} new events have been added.`;
} else {
eventMessage = eventCount === 1
? `New event "${title}" has been added to the family calendar.`
: `${eventCount} new events have been added to the family calendar.`;
}
let messages = pushTokens.map(pushToken => { let messages = pushTokens.map(pushToken => {
if (!Expo.isExpoPushToken(pushToken)) { if (!Expo.isExpoPushToken(pushToken)) {
@ -61,9 +235,12 @@ exports.sendNotificationOnEventCreation = functions.firestore
return { return {
to: pushToken, to: pushToken,
sound: 'default', sound: 'default',
title: 'New Events Added!', title: externalOrigin ? 'Calendar Sync Complete' : 'New Family Calendar Events',
body: eventMessage, body: eventMessage,
data: { eventId: context.params.eventId }, data: {
eventId: context.params.eventId,
type: externalOrigin ? 'sync' : 'manual'
},
}; };
}).filter(Boolean); }).filter(Boolean);
@ -90,13 +267,15 @@ exports.sendNotificationOnEventCreation = functions.firestore
} }
} }
// Save the notification in Firestore for record-keeping // Save the notification in Firestore
const notificationData = { const notificationData = {
creatorId, creatorId,
familyId, familyId,
content: eventMessage, content: eventMessage,
eventId: context.params.eventId, eventId: context.params.eventId,
type: externalOrigin ? 'sync' : 'manual',
timestamp: Timestamp.now(), timestamp: Timestamp.now(),
date: eventData.startDate
}; };
try { try {
@ -106,15 +285,159 @@ exports.sendNotificationOnEventCreation = functions.firestore
console.error("Error saving notification to Firestore:", error); console.error("Error saving notification to Firestore:", error);
} }
// Reset state variables after notifications are sent // Reset state variables
eventCount = 0; eventCount = 0;
pushTokens = []; pushTokens = [];
notificationInProgress = false; notificationInProgress = false;
}, 5000); }, 5000);
} }
}); });
exports.onEventUpdate = functions.firestore
.document('Events/{eventId}')
.onUpdate(async (change, context) => {
const beforeData = change.before.data();
const afterData = change.after.data();
const {familyId, title, lastModifiedBy} = afterData;
// Skip if no meaningful changes
if (JSON.stringify(beforeData) === JSON.stringify(afterData)) {
return null;
}
try {
// Get push tokens excluding the user who made the change
const pushTokens = await getPushTokensForFamily(familyId, lastModifiedBy);
const message = `Event "${title}" has been updated`;
await sendNotifications(pushTokens, {
title: "Event Updated",
body: message,
data: {
type: 'event_update',
eventId: context.params.eventId
}
});
// Store notification in Firestore
await storeNotification({
type: 'event_update',
familyId,
content: message,
eventId: context.params.eventId,
excludedUser: lastModifiedBy,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
date: eventData.startDate
});
} catch (error) {
console.error('Error sending event update notification:', error);
}
});
// Upcoming Event Reminders
exports.checkUpcomingEvents = functions.pubsub
.schedule('every 5 minutes')
.onRun(async (context) => {
const now = admin.firestore.Timestamp.now();
const eventsSnapshot = await admin.firestore().collection('Events').get();
for (const doc of eventsSnapshot.docs) {
const event = doc.data();
const {startDate, familyId, title, allDay, creatorId} = event;
if (startDate.toDate() < now.toDate()) continue;
try {
const familyDoc = await admin.firestore().collection('Families').doc(familyId).get();
const familySettings = familyDoc.data()?.settings || {};
const reminderTime = familySettings.defaultReminderTime || 15; // minutes
const eventTime = startDate.toDate();
const reminderThreshold = new Date(now.toDate().getTime() + (reminderTime * 60 * 1000));
// For all-day events, send reminder the evening before
if (allDay) {
const eveningBefore = new Date(eventTime);
eveningBefore.setDate(eveningBefore.getDate() - 1);
eveningBefore.setHours(20, 0, 0, 0);
if (now.toDate() >= eveningBefore && !event.eveningReminderSent) {
// Get all family members' tokens (including creator for reminders)
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Tomorrow's All-Day Event",
body: `Tomorrow: ${title}`,
data: {
type: 'event_reminder',
eventId: doc.id
}
});
await doc.ref.update({eveningReminderSent: true});
}
}
// For regular events, check if within reminder threshold
else if (eventTime <= reminderThreshold && !event.reminderSent) {
// Include creator for reminders
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Upcoming Event",
body: `In ${reminderTime} minutes: ${title}`,
data: {
type: 'event_reminder',
eventId: doc.id
}
});
await doc.ref.update({reminderSent: true});
}
} catch (error) {
console.error(`Error processing reminder for event ${doc.id}:`, error);
}
}
});
async function storeNotification(notificationData) {
try {
await admin.firestore()
.collection('Notifications')
.add(notificationData);
} catch (error) {
console.error('Error storing notification:', error);
}
}
async function sendNotifications(pushTokens, notification) {
if (!pushTokens.length) return;
const messages = pushTokens
.filter(token => Expo.isExpoPushToken(token))
.map(pushToken => ({
to: pushToken,
sound: 'default',
priority: 'high',
...notification
}));
const chunks = expo.chunkPushNotifications(messages);
for (let chunk of chunks) {
try {
const tickets = await expo.sendPushNotificationsAsync(chunk);
for (let ticket of tickets) {
if (ticket.status === "error") {
if (ticket.details?.error === "DeviceNotRegistered") {
await removeInvalidPushToken(ticket.to);
}
console.error('Push notification error:', ticket.message);
}
}
} catch (error) {
console.error('Error sending notifications:', error);
}
}
}
exports.createSubUser = onRequest(async (request, response) => { exports.createSubUser = onRequest(async (request, response) => {
const authHeader = request.get('Authorization'); const authHeader = request.get('Authorization');
@ -209,7 +532,7 @@ exports.removeSubUser = onRequest(async (request, response) => {
logger.info("Processing user removal", {requestBody: request.body.data}); logger.info("Processing user removal", {requestBody: request.body.data});
const { userId, familyId } = request.body.data; const {userId, familyId} = request.body.data;
if (!userId || !familyId) { if (!userId || !familyId) {
logger.warn("Missing required fields in request body", {requestBody: request.body.data}); logger.warn("Missing required fields in request body", {requestBody: request.body.data});
@ -397,7 +720,22 @@ async function getPushTokensForFamilyExcludingCreator(familyId, creatorId) {
} }
async function removeInvalidPushToken(pushToken) { async function removeInvalidPushToken(pushToken) {
// TODO try {
const profilesRef = db.collection('Profiles');
const snapshot = await profilesRef.where('pushToken', '==', pushToken).get();
const batch = db.batch();
snapshot.forEach(doc => {
batch.update(doc.ref, {
pushToken: admin.firestore.FieldValue.delete()
});
});
await batch.commit();
console.log(`Removed invalid push token: ${pushToken}`);
} catch (error) {
console.error('Error removing invalid push token:', error);
}
} }
const fetch = require("node-fetch"); const fetch = require("node-fetch");
@ -667,7 +1005,7 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
} }
}); });
async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId }) { async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, creatorId}) {
const baseDate = new Date(); const baseDate = new Date();
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString(); const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString();
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString(); const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString();
@ -700,7 +1038,7 @@ async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId,
token = refreshedToken; token = refreshedToken;
if (token) { if (token) {
return fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId }); return fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, creatorId});
} else { } else {
console.error(`Failed to refresh token for user: ${email}`); console.error(`Failed to refresh token for user: ${email}`);
await clearToken(email); await clearToken(email);
@ -717,8 +1055,12 @@ async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId,
const googleEvent = { const googleEvent = {
id: item.id, id: item.id,
title: item.summary || "", title: item.summary || "",
startDate: item.start?.dateTime ? new Date(item.start.dateTime) : new Date(item.start.date), startDate: item.start?.dateTime
endDate: item.end?.dateTime ? new Date(item.end.dateTime) : new Date(item.end.date), ? new Date(item.start.dateTime)
: new Date(item.start.date + 'T00:00:00'),
endDate: item.end?.dateTime
? new Date(item.end.dateTime)
: new Date(new Date(item.end.date + 'T00:00:00').setDate(new Date(item.end.date).getDate() - 1)),
allDay: !item.start?.dateTime, allDay: !item.start?.dateTime,
familyId, familyId,
email, email,
@ -743,12 +1085,12 @@ async function saveEventsToFirestore(events) {
const batch = db.batch(); const batch = db.batch();
events.forEach((event) => { events.forEach((event) => {
const eventRef = db.collection("Events").doc(event.id); const eventRef = db.collection("Events").doc(event.id);
batch.set(eventRef, event, { merge: true }); batch.set(eventRef, event, {merge: true});
}); });
await batch.commit(); await batch.commit();
} }
async function calendarSync({ userId, email, token, refreshToken, familyId }) { async function calendarSync({userId, email, token, refreshToken, familyId}) {
console.log(`Starting calendar sync for user ${userId} with email ${email}`); console.log(`Starting calendar sync for user ${userId} with email ${email}`);
try { try {
await fetchAndSaveGoogleEvents({ await fetchAndSaveGoogleEvents({
@ -787,7 +1129,7 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
return; return;
} }
const { googleAccounts } = userData; const {googleAccounts} = userData;
const email = Object.keys(googleAccounts || {})[0]; const email = Object.keys(googleAccounts || {})[0];
const accountData = googleAccounts[email] || {}; const accountData = googleAccounts[email] || {};
const token = accountData.accessToken; const token = accountData.accessToken;
@ -795,7 +1137,7 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const familyId = userData.familyId; const familyId = userData.familyId;
console.log("Starting calendar sync..."); console.log("Starting calendar sync...");
await calendarSync({ userId, email, token, refreshToken, familyId }); await calendarSync({userId, email, token, refreshToken, familyId});
console.log("Calendar sync completed."); console.log("Calendar sync completed.");
res.status(200).send("Sync notification sent."); res.status(200).send("Sync notification sent.");
@ -804,3 +1146,201 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
res.status(500).send("Failed to send sync notification."); res.status(500).send("Failed to send sync notification.");
} }
}); });
async function refreshMicrosoftToken(refreshToken) {
try {
console.log("Refreshing Microsoft token...");
const response = 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: "13c79071-1066-40a9-9f71-b8c4b138b4af",
scope: "openid profile email offline_access Calendars.ReadWrite User.Read",
refresh_token: refreshToken,
grant_type: 'refresh_token'
})
});
if (!response.ok) {
const errorData = await response.json();
console.error("Error refreshing Microsoft token:", errorData);
throw new Error(`Failed to refresh Microsoft token: ${errorData.error || response.statusText}`);
}
const data = await response.json();
console.log("Microsoft token refreshed successfully");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken
};
} catch (error) {
console.error("Error refreshing Microsoft token:", error.message);
throw error;
}
}
async function fetchAndSaveMicrosoftEvents({token, refreshToken, email, familyId, creatorId}) {
const baseDate = new Date();
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString();
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString();
try {
console.log(`Fetching Microsoft calendar events for user: ${email}`);
const url = `https://graph.microsoft.com/v1.0/me/calendar/events`;
const queryParams = new URLSearchParams({
$select: 'subject,start,end,id',
$filter: `start/dateTime ge '${timeMin}' and end/dateTime le '${timeMax}'`
});
const response = await fetch(`${url}?${queryParams}`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.status === 401 && refreshToken) {
console.log(`Token expired for user: ${email}, attempting to refresh`);
const {accessToken: newToken} = await refreshMicrosoftToken(refreshToken);
if (newToken) {
return fetchAndSaveMicrosoftEvents({
token: newToken,
refreshToken,
email,
familyId,
creatorId
});
}
}
if (!response.ok) {
throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`);
}
const events = data.value.map(item => ({
id: item.id,
title: item.subject || "",
startDate: new Date(item.start.dateTime + 'Z'),
endDate: new Date(item.end.dateTime + 'Z'),
allDay: false, // Microsoft Graph API handles all-day events differently
familyId,
email,
creatorId,
externalOrigin: "microsoft"
}));
console.log(`Saving Microsoft calendar events to Firestore for user: ${email}`);
await saveEventsToFirestore(events);
} catch (error) {
console.error(`Error fetching Microsoft Calendar events for ${email}:`, error);
throw error;
}
}
async function subscribeMicrosoftCalendar(accessToken, userId) {
try {
console.log(`Setting up Microsoft calendar subscription for user ${userId}`);
const subscription = {
changeType: "created,updated,deleted",
notificationUrl: `https://us-central1-cally-family-calendar.cloudfunctions.net/microsoftCalendarWebhook?userId=${userId}`,
resource: "/me/calendar/events",
expirationDateTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days
clientState: userId
};
const response = await fetch('https://graph.microsoft.com/v1.0/subscriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
});
if (!response.ok) {
throw new Error(`Failed to create subscription: ${response.statusText}`);
}
const subscriptionData = await response.json();
// Store subscription details in Firestore
await admin.firestore().collection('MicrosoftSubscriptions').doc(userId).set({
subscriptionId: subscriptionData.id,
expirationDateTime: subscriptionData.expirationDateTime,
createdAt: admin.firestore.FieldValue.serverTimestamp()
});
console.log(`Microsoft calendar subscription created for user ${userId}`);
return subscriptionData;
} catch (error) {
console.error(`Error creating Microsoft calendar subscription for user ${userId}:`, error);
throw error;
}
}
exports.microsoftCalendarWebhook = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId;
console.log(`Received Microsoft calendar webhook for user ${userId}`, req.body);
try {
const userDoc = await admin.firestore().collection("Profiles").doc(userId).get();
const userData = userDoc.data();
if (!userData?.microsoftAccounts) {
console.log(`No Microsoft account found for user ${userId}`);
return res.status(200).send();
}
const email = Object.keys(userData.microsoftAccounts)[0];
const token = userData.microsoftAccounts[email];
await fetchAndSaveMicrosoftEvents({
token,
email,
familyId: userData.familyId,
creatorId: userId
});
res.status(200).send();
} catch (error) {
console.error(`Error processing Microsoft calendar webhook for user ${userId}:`, error);
res.status(500).send();
}
});
exports.renewMicrosoftSubscriptions = functions.pubsub.schedule('every 24 hours').onRun(async (context) => {
console.log('Starting Microsoft subscription renewal process');
try {
const subscriptionsSnapshot = await admin.firestore()
.collection('MicrosoftSubscriptions')
.get();
for (const doc of subscriptionsSnapshot.docs) {
const userId = doc.id;
const userDoc = await admin.firestore().collection('Profiles').doc(userId).get();
const userData = userDoc.data();
if (userData?.microsoftAccounts) {
const email = Object.keys(userData.microsoftAccounts)[0];
const token = userData.microsoftAccounts[email];
try {
await subscribeMicrosoftCalendar(token, userId);
console.log(`Renewed Microsoft subscription for user ${userId}`);
} catch (error) {
console.error(`Failed to renew Microsoft subscription for user ${userId}:`, error);
}
}
}
} catch (error) {
console.error('Error in Microsoft subscription renewal process:', error);
}
});

View File

@ -1,13 +1,13 @@
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";
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";
export const useGetEvents = () => { export const useGetEvents = () => {
const { user, profileData } = useAuthContext(); const {user, profileData} = useAuthContext();
const isFamilyView = useAtomValue(isFamilyViewAtom); const isFamilyView = useAtomValue(isFamilyViewAtom);
return useQuery({ return useQuery({
@ -19,62 +19,61 @@ export const useGetEvents = () => {
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); // Get public family events
const attendeeQuery = db.collection("Events").where("attendees", "array-contains", userId); const publicFamilyEvents = await db.collection("Events")
.where("familyId", "==", familyId)
.where("private", "==", false)
.get();
const [familySnapshot, attendeeSnapshot] = await Promise.all([ // Get private events where user is creator
familyQuery.get(), const privateCreatorEvents = await db.collection("Events")
attendeeQuery.get(), .where("familyId", "==", familyId)
]); .where("private", "==", true)
.where("creatorId", "==", userId)
.get();
// Collect all events // Get private events where user is attendee
const familyEvents = familySnapshot.docs.map(doc => doc.data()); const privateAttendeeEvents = await db.collection("Events")
const attendeeEvents = attendeeSnapshot.docs.map(doc => doc.data()); .where("private", "==", true)
.where("attendees", "array-contains", userId)
.get();
// 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()),
...privateCreatorEvents.docs.map(doc => doc.data()),
allEvents = [...familyEvents, ...attendeeEvents]; ...privateAttendeeEvents.docs.map(doc => doc.data())
];
} else { } else {
// Only include creator and attendee events when family view is off // Personal view: Only show events where user is creator or attendee
const creatorQuery = db.collection("Events").where("creatorId", "==", userId); const [creatorEvents, attendeeEvents] = await Promise.all([
const attendeeQuery = db.collection("Events").where("attendees", "array-contains", userId); db.collection("Events")
.where("creatorId", "==", userId)
const [creatorSnapshot, attendeeSnapshot] = await Promise.all([ .get(),
creatorQuery.get(), db.collection("Events")
attendeeQuery.get(), .where("attendees", "array-contains", userId)
.get()
]); ]);
const creatorEvents = creatorSnapshot.docs.map(doc => doc.data()); allEvents = [
const attendeeEvents = attendeeSnapshot.docs.map(doc => doc.data()); ...creatorEvents.docs.map(doc => doc.data()),
...attendeeEvents.docs.map(doc => doc.data())
allEvents = [...creatorEvents, ...attendeeEvents]; ];
} }
// Use a Map to ensure uniqueness only for events with IDs // Ensure uniqueness
const uniqueEventsMap = new Map(); const uniqueEventsMap = new Map();
allEvents.forEach(event => { allEvents.forEach(event => {
if (event.id) { if (event.id) {
uniqueEventsMap.set(event.id, event); // Ensure uniqueness for events with IDs uniqueEventsMap.set(event.id, event);
} else { } else {
uniqueEventsMap.set(uuidv4(), event); // Generate a temp key for events without ID uniqueEventsMap.set(uuidv4(), event);
} }
}); });
const uniqueEvents = Array.from(uniqueEventsMap.values());
// Filter out private events unless the user is the creator // Map events with creator colors
const filteredEvents = uniqueEvents.filter(event => {
if (event.private) {
return event.creatorId === userId;
}
return true;
});
// Attach event colors and return the final list of events
return await Promise.all( return await Promise.all(
filteredEvents.map(async (event) => { Array.from(uniqueEventsMap.values()).map(async (event) => {
const profileSnapshot = await db const profileSnapshot = await db
.collection("Profiles") .collection("Profiles")
.doc(event.creatorId) .doc(event.creatorId)
@ -85,10 +84,12 @@ 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,

View File

@ -1,11 +1,34 @@
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 {
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 +37,14 @@ 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, ...data,
familyId: string, timestamp: new Date(data.timestamp.seconds * 1000 + data.timestamp.nanoseconds / 1e6),
content: string, date: data.date ? new Date(data.date.seconds * 1000 + data.date.nanoseconds / 1e6) : undefined
eventId: string,
timestamp: Date,
}; };
}); });
} }
}) });
}; };

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>
@ -155,6 +157,7 @@
<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>