mirror of
https://github.com/urosran/cally.git
synced 2026-03-10 20:51:42 +00:00
Compare commits
8 Commits
718fd562ff
...
7a7b1902a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a7b1902a8 | |||
| c184eb3293 | |||
| d9ee1cd921 | |||
| 1b5024089d | |||
| 59664488e8 | |||
| bb1fb8d8f7 | |||
| b804f37037 | |||
| f649828d80 |
14
.github/workflows/ci-cd.yml
vendored
14
.github/workflows/ci-cd.yml
vendored
@ -6,7 +6,15 @@ env:
|
||||
EXPO_ASC_ISSUER_ID: f7d6175c-75fe-416c-b6d1-0bc9eaf87415
|
||||
EXPO_APPLE_TEAM_ID: MV9C3PHV87
|
||||
EXPO_APPLE_TEAM_TYPE: INDIVIDUAL
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_TOKEN: qt2h_4xhuhFB-ArysIkzgpsBtWOrrZ-c_So_S9ch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -14,7 +22,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
@ -25,10 +32,9 @@ jobs:
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Prebuild, Build and Submit
|
||||
run: yarn prebuild-build-submit-ios-cicd
|
||||
run: yarn prebuild-build-submit-ios-cicd
|
||||
@ -1,15 +1,18 @@
|
||||
import React, {useCallback} from "react";
|
||||
import React, {memo, useCallback, useMemo} from "react";
|
||||
import {Drawer} from "expo-router/drawer";
|
||||
import {DrawerContentScrollView, DrawerContentComponentProps, DrawerNavigationOptions} from "@react-navigation/drawer";
|
||||
import {
|
||||
DrawerContentComponentProps,
|
||||
DrawerContentScrollView,
|
||||
DrawerNavigationOptions,
|
||||
DrawerNavigationProp
|
||||
} from "@react-navigation/drawer";
|
||||
import {ImageBackground, Pressable, StyleSheet} from "react-native";
|
||||
import {Button, ButtonSize, Text, View} from "react-native-ui-lib";
|
||||
import * as Device from "expo-device";
|
||||
import {DeviceType} from "expo-device";
|
||||
import {useSetAtom} from "jotai";
|
||||
import {Ionicons} from "@expo/vector-icons";
|
||||
import {DeviceType} from "expo-device";
|
||||
import {DrawerNavigationProp} from "@react-navigation/drawer";
|
||||
import {ParamListBase, Theme} from '@react-navigation/native';
|
||||
import {RouteProp} from "@react-navigation/native";
|
||||
import {ParamListBase, RouteProp, Theme} from '@react-navigation/native';
|
||||
|
||||
import {useSignOut} from "@/hooks/firebase/useSignOut";
|
||||
import {CalendarHeader} from "@/components/pages/calendar/CalendarHeader";
|
||||
@ -205,7 +208,7 @@ interface HeaderRightProps {
|
||||
navigation: DrawerScreenNavigationProp;
|
||||
}
|
||||
|
||||
const HeaderRight: React.FC<HeaderRightProps> = ({route, navigation}) => {
|
||||
const HeaderRight: React.FC<HeaderRightProps> = memo(({route, navigation}) => {
|
||||
const showViewSwitch = ["calendar", "todos", "index"].includes(route.name);
|
||||
const isCalendarPage = ["calendar", "index"].includes(route.name);
|
||||
|
||||
@ -222,43 +225,7 @@ const HeaderRight: React.FC<HeaderRightProps> = ({route, navigation}) => {
|
||||
)}
|
||||
<ViewSwitch navigation={navigation}/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const screenOptions: (props: {
|
||||
route: RouteProp<ParamListBase, string>;
|
||||
navigation: DrawerNavigationProp<ParamListBase, string>;
|
||||
theme: Theme;
|
||||
}) => DrawerNavigationOptions = ({route, navigation}) => ({
|
||||
lazy: true,
|
||||
headerShown: true,
|
||||
headerTitleAlign: "left",
|
||||
headerTitle: ({children}) => {
|
||||
const isCalendarRoute = ["calendar", "index"].includes(route.name);
|
||||
if (isCalendarRoute) return null;
|
||||
|
||||
return (
|
||||
<View flexG centerV paddingL-10>
|
||||
<Text style={styles.headerTitle}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => navigation.toggleDrawer()}
|
||||
hitSlop={{top: 10, bottom: 10, left: 10, right: 10}}
|
||||
style={({pressed}) => [styles.drawerTrigger, {opacity: pressed ? 0.4 : 1}]}
|
||||
>
|
||||
<DrawerIcon/>
|
||||
</Pressable>
|
||||
),
|
||||
headerRight: () => <HeaderRight
|
||||
route={route as RouteProp<DrawerParamList>}
|
||||
navigation={navigation as DrawerNavigationProp<DrawerParamList>}
|
||||
/>,
|
||||
drawerStyle: styles.drawer,
|
||||
)
|
||||
});
|
||||
|
||||
interface DrawerScreen {
|
||||
@ -280,6 +247,45 @@ const DRAWER_SCREENS: DrawerScreen[] = [
|
||||
];
|
||||
|
||||
const TabLayout: React.FC = () => {
|
||||
const screenOptions = useMemo(() => {
|
||||
return ({route, navigation}: {
|
||||
route: RouteProp<ParamListBase, string>;
|
||||
navigation: DrawerNavigationProp<ParamListBase, string>;
|
||||
theme: Theme;
|
||||
}): DrawerNavigationOptions => ({
|
||||
lazy: true,
|
||||
headerShown: true,
|
||||
headerTitleAlign: "left",
|
||||
headerTitle: ({children}) => {
|
||||
const isCalendarRoute = ["calendar", "index"].includes(route.name);
|
||||
if (isCalendarRoute) return null;
|
||||
|
||||
return (
|
||||
<View flexG centerV paddingL-10>
|
||||
<Text style={styles.headerTitle}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => navigation.toggleDrawer()}
|
||||
hitSlop={{top: 10, bottom: 10, left: 10, right: 10}}
|
||||
style={({pressed}) => [styles.drawerTrigger, {opacity: pressed ? 0.4 : 1}]}
|
||||
>
|
||||
<DrawerIcon/>
|
||||
</Pressable>
|
||||
),
|
||||
headerRight: () => <HeaderRight
|
||||
route={route as RouteProp<DrawerParamList>}
|
||||
navigation={navigation as DrawerNavigationProp<DrawerParamList>}
|
||||
/>,
|
||||
drawerStyle: styles.drawer,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
initialRouteName="index"
|
||||
|
||||
87
app/(unauth)/household_page.tsx
Normal file
87
app/(unauth)/household_page.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Button, Text, View, TextField } from "react-native-ui-lib";
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet } from "react-native";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
import { useUpdateHouseholdName } from "@/hooks/firebase/useUpdateHouseholdName";
|
||||
|
||||
export default function NewHouseholdScreen() {
|
||||
const router = useRouter();
|
||||
const { user, profileData } = useAuthContext();
|
||||
const [householdName, setHouseholdName] = useState("");
|
||||
const { mutateAsync: newHousehold } = useUpdateHouseholdName();
|
||||
|
||||
const handleContinue = async () => {
|
||||
try {
|
||||
if(profileData?.familyId)
|
||||
newHousehold({familyId: profileData?.familyId, name: householdName}).then(() => router.push("/(unauth)/cal_sync"));
|
||||
} catch (error) {
|
||||
console.error("Error saving household name:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 21,
|
||||
paddingBottom: 45,
|
||||
paddingTop: "20%",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View gap-13 width={"100%"} marginB-20>
|
||||
<Text style={{ fontSize: 40, fontFamily: "Manrope_600SemiBold" }}>
|
||||
Name your household
|
||||
</Text>
|
||||
<Text color={"#919191"} style={{ fontSize: 20 }}>
|
||||
Give your family group a unique name!
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View width={"100%"} flexG>
|
||||
<TextField
|
||||
value={householdName}
|
||||
onChangeText={setHouseholdName}
|
||||
placeholder="Enter household name"
|
||||
style={styles.textfield}
|
||||
textAlign="center"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View flexG />
|
||||
|
||||
<View width={"100%"}>
|
||||
<Button
|
||||
label="Continue"
|
||||
onPress={handleContinue}
|
||||
style={{
|
||||
height: 50,
|
||||
}}
|
||||
backgroundColor="#fd1775"
|
||||
labelStyle={{
|
||||
fontFamily: "PlusJakartaSans_600SemiBold",
|
||||
fontSize: 16,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
textfield: {
|
||||
backgroundColor: "white",
|
||||
marginVertical: 100,
|
||||
padding: 30,
|
||||
height: 44,
|
||||
borderRadius: 50,
|
||||
fontFamily: "PlusJakartaSans_300Light",
|
||||
fontSize: 15,
|
||||
color: "#919191",
|
||||
alignContent: "center",
|
||||
},
|
||||
});
|
||||
@ -55,6 +55,9 @@ import {Platform} from 'react-native';
|
||||
import KeyboardManager from 'react-native-keyboard-manager';
|
||||
import {enableScreens, enableFreeze} from 'react-native-screens';
|
||||
import {PersistQueryClientProvider} from "@/contexts/PersistQueryClientProvider";
|
||||
import auth from "@react-native-firebase/auth";
|
||||
import firestore from '@react-native-firebase/firestore';
|
||||
import functions from '@react-native-firebase/functions';
|
||||
|
||||
enableScreens(true)
|
||||
enableFreeze(true)
|
||||
@ -67,9 +70,9 @@ if (Platform.OS === 'ios') {
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
// functions().useEmulator("localhost", 5001);
|
||||
// firestore().useEmulator("localhost", 5471);
|
||||
// auth().useEmulator("http://localhost:9099");
|
||||
// functions().useEmulator("localhost", 5001);
|
||||
// firestore().useEmulator("localhost", 5471);
|
||||
// auth().useEmulator("http://localhost:9099");
|
||||
}
|
||||
|
||||
type TextStyleBase =
|
||||
|
||||
@ -1,30 +1,46 @@
|
||||
import { SegmentedControl, View } from "react-native-ui-lib";
|
||||
import React, { memo, useCallback } from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import { NavigationProp, useNavigationState } from "@react-navigation/native";
|
||||
import {SegmentedControl, View} from "react-native-ui-lib";
|
||||
import React, {memo, useCallback, useMemo, useRef, useEffect} from "react";
|
||||
import {StyleSheet} from "react-native";
|
||||
import {NavigationProp, useNavigationState} from "@react-navigation/native";
|
||||
|
||||
interface ViewSwitchProps {
|
||||
navigation: NavigationProp<any>;
|
||||
}
|
||||
|
||||
const ViewSwitch = memo(function ViewSwitch({ navigation }: ViewSwitchProps) {
|
||||
const ViewSwitch = memo(function ViewSwitch({navigation}: ViewSwitchProps) {
|
||||
const currentIndex = useNavigationState((state) => state.index === 6 ? 1 : 0);
|
||||
const isInitialMount = useRef(true);
|
||||
const navigationPending = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSegmentChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === currentIndex) return;
|
||||
navigation.navigate(index === 0 ? "calendar" : "todos");
|
||||
if (navigationPending.current) return;
|
||||
|
||||
navigationPending.current = true;
|
||||
setTimeout(() => {
|
||||
navigation.navigate(index === 0 ? "calendar" : "todos");
|
||||
navigationPending.current = false;
|
||||
}, 300);
|
||||
},
|
||||
[navigation, currentIndex]
|
||||
[navigation]
|
||||
);
|
||||
|
||||
const segments = useMemo(() => [
|
||||
{label: "Calendar", segmentLabelStyle: styles.labelStyle},
|
||||
{label: "To Dos", segmentLabelStyle: styles.labelStyle},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SegmentedControl
|
||||
segments={[
|
||||
{ label: "Calendar", segmentLabelStyle: styles.labelStyle },
|
||||
{ label: "To Dos", segmentLabelStyle: styles.labelStyle },
|
||||
]}
|
||||
segments={segments}
|
||||
containerStyle={styles.segmentContainer}
|
||||
style={styles.segment}
|
||||
backgroundColor="#ebebeb"
|
||||
@ -45,7 +61,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 30,
|
||||
backgroundColor: "#ebebeb",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOffset: {width: 0, height: 0},
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
@ -65,4 +81,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export default ViewSwitch;
|
||||
export default ViewSwitch;
|
||||
|
||||
@ -13,8 +13,8 @@ interface IAddBrainDumpProps {
|
||||
}
|
||||
|
||||
const AddBrainDump = ({
|
||||
addBrainDumpProps,
|
||||
}: {
|
||||
addBrainDumpProps,
|
||||
}: {
|
||||
addBrainDumpProps: IAddBrainDumpProps;
|
||||
}) => {
|
||||
const {addBrainDump} = useBrainDumpContext();
|
||||
@ -22,11 +22,11 @@ const AddBrainDump = ({
|
||||
const [dumpDesc, setDumpDesc] = useState<string>("");
|
||||
const {width} = Dimensions.get("screen");
|
||||
|
||||
|
||||
// Refs for the two TextFields
|
||||
const descriptionRef = useRef<TextFieldRef>(null);
|
||||
const titleRef = useRef<TextFieldRef>(null);
|
||||
|
||||
const isTitleValid = dumpTitle.trim().length >= 3;
|
||||
|
||||
useEffect(() => {
|
||||
setDumpDesc("");
|
||||
setDumpTitle("");
|
||||
@ -40,9 +40,9 @@ const AddBrainDump = ({
|
||||
}
|
||||
}, [addBrainDumpProps.isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "ios") KeyboardManager.setEnableAutoToolbar(false);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "ios") KeyboardManager.setEnableAutoToolbar(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -69,18 +69,17 @@ const AddBrainDump = ({
|
||||
<Button
|
||||
color="#05a8b6"
|
||||
label="Save"
|
||||
style={styles.topBtn}
|
||||
style={[styles.topBtn, !isTitleValid && styles.disabledBtn]}
|
||||
disabled={!isTitleValid}
|
||||
onPress={() => {
|
||||
addBrainDump({
|
||||
|
||||
id: '99',
|
||||
|
||||
title: dumpTitle.trimEnd().trimStart(),
|
||||
|
||||
description: dumpDesc.trimEnd().trimStart(),
|
||||
|
||||
});
|
||||
addBrainDumpProps.setIsVisible(false);
|
||||
if (isTitleValid) {
|
||||
addBrainDump({
|
||||
id: '99',
|
||||
title: dumpTitle.trim(),
|
||||
description: dumpDesc.trim(),
|
||||
});
|
||||
addBrainDumpProps.setIsVisible(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@ -94,11 +93,10 @@ const AddBrainDump = ({
|
||||
setDumpTitle(text);
|
||||
}}
|
||||
onSubmitEditing={() => {
|
||||
// Move focus to the description field
|
||||
descriptionRef.current?.focus();
|
||||
}}
|
||||
style={styles.title}
|
||||
blurOnSubmit={false} // Keep the keyboard open when moving focus
|
||||
blurOnSubmit={false}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
<View height={2} backgroundColor="#b3b3b3" width={"100%"} marginB-20/>
|
||||
@ -125,28 +123,31 @@ const AddBrainDump = ({
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dialogContainer: {
|
||||
borderTopRightRadius: 15,
|
||||
borderTopLeftRadius: 15,
|
||||
backgroundColor: "white",
|
||||
padding: 0,
|
||||
paddingTop: 3,
|
||||
margin: 0,
|
||||
},
|
||||
topBtns: {},
|
||||
topBtn: {
|
||||
backgroundColor: "white",
|
||||
color: "#05a8b6",
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontFamily: "Manrope_500Medium",
|
||||
},
|
||||
description: {
|
||||
fontFamily: "Manrope_400Regular",
|
||||
fontSize: 14,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
dialogContainer: {
|
||||
borderTopRightRadius: 15,
|
||||
borderTopLeftRadius: 15,
|
||||
backgroundColor: "white",
|
||||
padding: 0,
|
||||
paddingTop: 3,
|
||||
margin: 0,
|
||||
},
|
||||
topBtns: {},
|
||||
topBtn: {
|
||||
backgroundColor: "white",
|
||||
color: "#05a8b6",
|
||||
},
|
||||
disabledBtn: {
|
||||
opacity: 0.2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontFamily: "Manrope_500Medium",
|
||||
},
|
||||
description: {
|
||||
fontFamily: "Manrope_400Regular",
|
||||
fontSize: 14,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
});
|
||||
|
||||
export default AddBrainDump;
|
||||
export default AddBrainDump;
|
||||
@ -20,14 +20,10 @@ const DumpList = (props: { searchText: string }) => {
|
||||
|
||||
return (
|
||||
<View marginB-70>
|
||||
{brainDumps?.length ? <FlatList
|
||||
style={{ zIndex: -1 }}
|
||||
data={sortedDumps}
|
||||
keyExtractor={(item) => item.title}
|
||||
renderItem={({ item }) => (
|
||||
<BrainDumpItem key={item.title} item={item} />
|
||||
)}
|
||||
/> : <Text marginT-20 center style={styles.alert}>You have no notes</Text>}
|
||||
{sortedDumps?.length ? (
|
||||
sortedDumps.map((item) => (
|
||||
<BrainDumpItem key={item.id} item={item} />
|
||||
))) : <Text marginT-20 center style={styles.alert}>You have no notes</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, {memo, useState, useMemo, useCallback} from "react";
|
||||
import React, {memo, useCallback, useMemo, useState} from "react";
|
||||
import {StyleSheet} from "react-native";
|
||||
import {Button, Picker, PickerModes, SegmentedControl, Text, View} from "react-native-ui-lib";
|
||||
import {MaterialIcons} from "@expo/vector-icons";
|
||||
@ -71,29 +71,32 @@ export const CalendarHeader = memo(() => {
|
||||
}, [mode]);
|
||||
|
||||
const renderMonthPicker = () => (
|
||||
<View row centerV gap-1 flexS>
|
||||
{isTablet && (
|
||||
<Text style={styles.yearText}>
|
||||
{selectedDate.getFullYear()}
|
||||
</Text>
|
||||
)}
|
||||
<Picker
|
||||
value={months[selectedDate.getMonth()]}
|
||||
placeholder="Select Month"
|
||||
style={styles.monthPicker}
|
||||
mode={PickerModes.SINGLE}
|
||||
onChange={value => handleMonthChange(value as string)}
|
||||
trailingAccessory={<MaterialIcons name="keyboard-arrow-down"/>}
|
||||
topBarProps={{
|
||||
title: selectedDate.getFullYear().toString(),
|
||||
titleStyle: styles.yearText,
|
||||
}}
|
||||
>
|
||||
{months.map(month => (
|
||||
<Picker.Item key={month} label={month} value={month}/>
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
<>
|
||||
{isTablet && <View flexG/>}
|
||||
<View row centerV gap-1 flexS>
|
||||
{isTablet && (
|
||||
<Text style={styles.yearText}>
|
||||
{selectedDate.getFullYear()}
|
||||
</Text>
|
||||
)}
|
||||
<Picker
|
||||
value={months[selectedDate.getMonth()]}
|
||||
placeholder="Select Month"
|
||||
style={styles.monthPicker}
|
||||
mode={PickerModes.SINGLE}
|
||||
onChange={value => handleMonthChange(value as string)}
|
||||
trailingAccessory={<MaterialIcons name="keyboard-arrow-down"/>}
|
||||
topBarProps={{
|
||||
title: selectedDate.getFullYear().toString(),
|
||||
titleStyle: styles.yearText,
|
||||
}}
|
||||
>
|
||||
{months.map(month => (
|
||||
<Picker.Item key={month} label={month} value={month}/>
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -32,8 +32,11 @@ const AddFeedback = ({
|
||||
const descriptionRef = useRef<TextFieldRef>(null);
|
||||
const titleRef = useRef<TextFieldRef>(null);
|
||||
|
||||
const isTitleValid = feedbackTitle.trim().length >= 3;
|
||||
|
||||
useEffect(() => {
|
||||
setFeedback("");
|
||||
setFeedbackTitle("");
|
||||
}, [addFeedbackProps.isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -46,9 +49,8 @@ const AddFeedback = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "ios") KeyboardManager.setEnableAutoToolbar(false);
|
||||
|
||||
setFeedbackTitle("");
|
||||
setFeedback("");
|
||||
setFeedbackTitle('');
|
||||
setFeedback('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@ -76,16 +78,17 @@ const AddFeedback = ({
|
||||
<Button
|
||||
color="#05a8b6"
|
||||
label="Save"
|
||||
style={styles.topBtn}
|
||||
style={[styles.topBtn, !isTitleValid && styles.disabledBtn]}
|
||||
disabled={!isTitleValid}
|
||||
onPress={() => {
|
||||
addFeedback({
|
||||
id: 99,
|
||||
|
||||
title: feedbackTitle.trimEnd().trimStart(),
|
||||
|
||||
text: feedback.trimEnd().trimStart(),
|
||||
});
|
||||
addFeedbackProps.setIsVisible(false);
|
||||
if (isTitleValid) {
|
||||
addFeedback({
|
||||
id: 99,
|
||||
title: feedbackTitle.trim(),
|
||||
text: feedback.trim(),
|
||||
});
|
||||
addFeedbackProps.setIsVisible(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@ -142,6 +145,9 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: "white",
|
||||
color: "#05a8b6",
|
||||
},
|
||||
disabledBtn: {
|
||||
opacity: 0.2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontFamily: "Manrope_500Medium",
|
||||
@ -153,4 +159,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export default AddFeedback;
|
||||
export default AddFeedback;
|
||||
@ -10,7 +10,7 @@ const Feedback = (props: { item: IFeedback }) => {
|
||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View key={props.item.id}>
|
||||
<TouchableWithoutFeedback onPress={() => setIsVisible(true)}>
|
||||
<View
|
||||
backgroundColor="white"
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { View } from "react-native-ui-lib";
|
||||
import { View, Text } from "react-native-ui-lib";
|
||||
import React from "react";
|
||||
import { FlatList } from "react-native";
|
||||
import { StyleSheet } from "react-native";
|
||||
import { useFeedbackContext } from "@/contexts/FeedbackContext";
|
||||
import Feedback from "./Feedback";
|
||||
|
||||
const FeedbackList = (props: { searchText: string }) => {
|
||||
const { feedbacks } = useFeedbackContext();
|
||||
|
||||
const filteredBrainDumps =
|
||||
const filteredFeedbacks =
|
||||
props.searchText.trim() === ""
|
||||
? feedbacks
|
||||
: feedbacks.filter(
|
||||
@ -20,16 +20,24 @@ const FeedbackList = (props: { searchText: string }) => {
|
||||
|
||||
return (
|
||||
<View marginB-70>
|
||||
<FlatList
|
||||
style={{ zIndex: -1 }}
|
||||
data={filteredBrainDumps}
|
||||
keyExtractor={(item) => item.title}
|
||||
renderItem={({ item }) => (
|
||||
<Feedback key={item.title} item={item} />
|
||||
)}
|
||||
/>
|
||||
{filteredFeedbacks?.length ? (
|
||||
filteredFeedbacks.map((item) => (
|
||||
<Feedback key={item.id} item={item} />
|
||||
))
|
||||
) : (
|
||||
<Text marginT-20 center style={styles.alert}>
|
||||
You have no Feedbacks
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackList;
|
||||
const styles = StyleSheet.create({
|
||||
alert: {
|
||||
fontFamily: "PlusJakartaSans_300Light",
|
||||
fontSize: 20
|
||||
}
|
||||
});
|
||||
|
||||
export default FeedbackList;
|
||||
@ -165,21 +165,18 @@ const GroceryList = ({onInputFocus}: {onInputFocus: (y: number) => void}) => {
|
||||
</View>
|
||||
</View>
|
||||
{pendingGroceries?.length > 0
|
||||
? pendingVisible && (
|
||||
<FlatList
|
||||
data={pendingGroceries}
|
||||
renderItem={({item}) => (
|
||||
<GroceryItem
|
||||
item={item}
|
||||
handleItemApproved={(id, changes) =>
|
||||
updateGroceryItem({...changes, id: id})
|
||||
}
|
||||
onInputFocus={onInputFocus}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
/>
|
||||
)
|
||||
? pendingVisible && (
|
||||
pendingGroceries.map((item) => (
|
||||
<GroceryItem
|
||||
key={item.id.toString()}
|
||||
item={item}
|
||||
handleItemApproved={(id, changes) =>
|
||||
updateGroceryItem({...changes, id: id})
|
||||
}
|
||||
onInputFocus={onInputFocus}
|
||||
/>
|
||||
))
|
||||
)
|
||||
: pendingVisible && (
|
||||
<Text style={styles.noItemTxt}>No items pending approval.</Text>
|
||||
)}
|
||||
@ -230,38 +227,34 @@ const GroceryList = ({onInputFocus}: {onInputFocus: (y: number) => void}) => {
|
||||
{/* Render Approved Groceries Grouped by Category */}
|
||||
{approvedGroceries?.length > 0
|
||||
? approvedVisible && (
|
||||
<FlatList
|
||||
data={Object.keys(approvedGroceriesByCategory).sort((a, b) => {
|
||||
Object.keys(approvedGroceriesByCategory)
|
||||
.sort((a, b) => {
|
||||
if (a !== "Done") return -1;
|
||||
if (b === "Done") return 1;
|
||||
return 0;
|
||||
})}
|
||||
renderItem={({item: category}) => (
|
||||
})
|
||||
.map((category) => (
|
||||
<View key={category}>
|
||||
{/* Render Category Header */}
|
||||
<Text text80M style={{marginTop: 10}} color="#666">
|
||||
{category}
|
||||
</Text>
|
||||
{/* Render Grocery Items for this Category */}
|
||||
{approvedGroceriesByCategory[category].map(
|
||||
(grocery: IGrocery) => (
|
||||
<GroceryItem
|
||||
key={grocery.id}
|
||||
item={grocery}
|
||||
handleItemApproved={(id, changes) =>
|
||||
updateGroceryItem({...changes, id: id})
|
||||
}
|
||||
onInputFocus={onInputFocus}
|
||||
approvedGroceries={approvedGroceries}
|
||||
setApprovedGroceries={setApprovedGroceries}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{/* Render Category Header */}
|
||||
<Text text80M style={{marginTop: 10}} color="#666">
|
||||
{category}
|
||||
</Text>
|
||||
{/* Render Grocery Items for this Category */}
|
||||
{approvedGroceriesByCategory[category].map((grocery: IGrocery) => (
|
||||
<GroceryItem
|
||||
key={grocery.id}
|
||||
item={grocery}
|
||||
handleItemApproved={(id, changes) =>
|
||||
updateGroceryItem({...changes, id: id})
|
||||
}
|
||||
onInputFocus={onInputFocus}
|
||||
approvedGroceries={approvedGroceries}
|
||||
setApprovedGroceries={setApprovedGroceries}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(category) => category}
|
||||
/>
|
||||
)
|
||||
))
|
||||
)
|
||||
: approvedVisible && (
|
||||
<Text style={styles.noItemTxt}>No approved items.</Text>
|
||||
)}
|
||||
|
||||
@ -41,14 +41,9 @@ const GroceryWrapper = () => {
|
||||
|
||||
const handleInputFocus = (y: number) => {
|
||||
if (scrollViewRef.current) {
|
||||
// Get the window height
|
||||
const windowHeight = Dimensions.get('window').height;
|
||||
// Calculate the space we want to leave at the top
|
||||
const topSpacing = 20;
|
||||
|
||||
// Calculate the target scroll position:
|
||||
// y (position of input) - topSpacing (space we want at top)
|
||||
// if keyboard is shown, we need to account for its height
|
||||
const scrollPosition = Math.max(0, y - topSpacing);
|
||||
|
||||
scrollViewRef.current.scrollTo({
|
||||
|
||||
@ -22,14 +22,14 @@ const DeleteProfileDialogs: React.FC<ConfirmationDialogProps> = ({
|
||||
}) => {
|
||||
const [confirmationDialog, setConfirmationDialog] = useState<boolean>(false);
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [isCorrect, setIsCorrect] = useState<boolean>(true);
|
||||
const [isCorrect, setIsCorrect] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInput("");
|
||||
}, [onDismiss, onConfirm])
|
||||
|
||||
useEffect(() => {
|
||||
setIsCorrect(input === householdName);
|
||||
setIsCorrect(input !== "" && input === householdName);
|
||||
}, [input])
|
||||
|
||||
|
||||
|
||||
@ -174,7 +174,8 @@ const AddChoreDialog = (addChoreDialogProps: IAddChoreDialog) => {
|
||||
updateToDo({
|
||||
...todo,
|
||||
points: points,
|
||||
assignees: selectedAssignees
|
||||
assignees: selectedAssignees,
|
||||
currentAssignee: selectedAssignees[0],
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
@ -186,6 +187,7 @@ const AddChoreDialog = (addChoreDialogProps: IAddChoreDialog) => {
|
||||
done: false,
|
||||
points: points,
|
||||
assignees: selectedAssignees,
|
||||
currentAssignee: selectedAssignees[0],
|
||||
repeatDays: todo.repeatDays ?? []
|
||||
});
|
||||
} else {
|
||||
|
||||
@ -231,6 +231,13 @@ const ToDoItem = (props: {
|
||||
</View>
|
||||
<View row style={{ gap: 3 }}>
|
||||
{selectedMembers?.map((member) => {
|
||||
|
||||
let currentAssignee = props?.item?.currentAssignee;
|
||||
let opacity = 1;
|
||||
if (selectedMembers?.length > 1 && currentAssignee !== member?.uid) {
|
||||
opacity = 0.4;
|
||||
}
|
||||
|
||||
return member?.pfp ? (
|
||||
<ImageBackground
|
||||
key={member?.uid}
|
||||
@ -242,10 +249,12 @@ const ToDoItem = (props: {
|
||||
overflow: "hidden",
|
||||
borderWidth: 2,
|
||||
borderColor: member.eventColor || "transparent",
|
||||
opacity: opacity
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
key={member.uid}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 24.64,
|
||||
@ -253,6 +262,7 @@ const ToDoItem = (props: {
|
||||
borderWidth: 2,
|
||||
borderRadius: 100,
|
||||
borderColor: member.eventColor || "#ccc",
|
||||
opacity: opacity
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@ -260,7 +270,7 @@ const ToDoItem = (props: {
|
||||
backgroundColor: member.eventColor || "#ccc",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: 100, // Circular shape
|
||||
borderRadius: 100,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
|
||||
@ -308,59 +308,296 @@ async function fetchAndSaveMicrosoftEvents({ token, refreshToken, email, familyI
|
||||
}
|
||||
}
|
||||
|
||||
// Check Upcoming Events every 5 minutes – send reminders
|
||||
exports.checkUpcomingEvents = functions.pubsub
|
||||
.schedule("every 5 minutes")
|
||||
exports.sendOverviews = functions.pubsub
|
||||
.schedule('0 20 * * *') // Runs at 8 PM daily
|
||||
.onRun(async (context) => {
|
||||
const now = Timestamp.now();
|
||||
const tenMinutesFromNow = new Date(now.toDate().getTime() + 10 * 60 * 1000);
|
||||
const familiesSnapshot = await admin.firestore().collection('Families').get();
|
||||
|
||||
const eventsSnapshot = await db.collection("Events")
|
||||
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') // Runs at 8 PM every Sunday
|
||||
.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.checkUpcomingEvents = functions.pubsub
|
||||
.schedule("every 1 minutes")
|
||||
.onRun(async (context) => {
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
const oneHourFromNow = new Date(now.toDate().getTime() + 60 * 60 * 1000);
|
||||
|
||||
logger.info("Checking upcoming events", {
|
||||
currentTime: now.toDate().toISOString(),
|
||||
lookAheadTime: oneHourFromNow.toISOString()
|
||||
});
|
||||
|
||||
const eventsSnapshot = await admin.firestore()
|
||||
.collection("Events")
|
||||
.where("startDate", ">=", now)
|
||||
.where("startDate", "<=", Timestamp.fromDate(tenMinutesFromNow))
|
||||
.where("startDate", "<=", admin.firestore.Timestamp.fromDate(oneHourFromNow))
|
||||
.get();
|
||||
|
||||
await Promise.all(eventsSnapshot.docs.map(async (doc) => {
|
||||
logger.info(`Found ${eventsSnapshot.size} upcoming events to check`);
|
||||
|
||||
const processPromises = eventsSnapshot.docs.map(async (doc) => {
|
||||
const event = doc.data();
|
||||
if (!event?.startDate) return;
|
||||
const { familyId, title, allDay } = event;
|
||||
const eventId = doc.id;
|
||||
|
||||
// Skip if reminder already sent
|
||||
if (event.reminderSent === true ||
|
||||
event.notifiedAt ||
|
||||
(event.reminderSentAt &&
|
||||
now.toMillis() - event.reminderSentAt.toMillis() < 60000)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const familyDoc = await db.collection("Households").doc(familyId).get();
|
||||
if (!familyDoc.exists) return;
|
||||
const familySettings = familyDoc.data()?.settings || {};
|
||||
const reminderTime = familySettings.defaultReminderTime || 5;
|
||||
const householdSnapshot = await admin.firestore()
|
||||
.collection("Households")
|
||||
.where("familyId", "==", event.familyId)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (householdSnapshot.empty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const householdDoc = householdSnapshot.docs[0];
|
||||
const householdSettings = householdDoc.data()?.settings || {};
|
||||
const reminderTime = householdSettings.defaultReminderTime || 15;
|
||||
const eventTime = event.startDate.toDate();
|
||||
const reminderThreshold = new Date(now.toDate().getTime() + reminderTime * 60 * 1000);
|
||||
if (allDay) {
|
||||
const eveningBefore = new Date(eventTime);
|
||||
eveningBefore.setDate(eveningBefore.getDate() - 1);
|
||||
eveningBefore.setHours(20, 0, 0, 0);
|
||||
if (now.toDate() >= eveningBefore && !event.eveningReminderSent) {
|
||||
const pushTokens = await getPushTokensForFamily(familyId);
|
||||
if (pushTokens.length) {
|
||||
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 });
|
||||
}
|
||||
const currentTime = now.toDate();
|
||||
const minutesUntilEvent = Math.round((eventTime - currentTime) / (60 * 1000));
|
||||
|
||||
// Only send exactly at the reminder time
|
||||
if (minutesUntilEvent === reminderTime) {
|
||||
// Double-check reminder hasn't been sent
|
||||
const freshEventDoc = await doc.ref.get();
|
||||
if (freshEventDoc.data()?.reminderSent === true) {
|
||||
return;
|
||||
}
|
||||
} else if (eventTime <= reminderThreshold && !event.reminderSent) {
|
||||
const pushTokens = await getPushTokensForFamily(familyId);
|
||||
if (pushTokens.length) {
|
||||
|
||||
// Mark as sent FIRST
|
||||
await doc.ref.update({
|
||||
reminderSent: true,
|
||||
reminderSentAt: admin.firestore.FieldValue.serverTimestamp()
|
||||
});
|
||||
|
||||
const pushTokens = await getPushTokensForFamily(event.familyId);
|
||||
if (pushTokens.length > 0) {
|
||||
// Send notification
|
||||
await sendNotifications(pushTokens, {
|
||||
title: "Upcoming Event",
|
||||
body: `In ${reminderTime} minutes: ${title}`,
|
||||
data: { type: "event_reminder", eventId: doc.id },
|
||||
title: "Upcoming Event Reminder",
|
||||
body: `In ${reminderTime} minutes: ${event.title}`,
|
||||
data: {
|
||||
type: "event_reminder",
|
||||
eventId: eventId,
|
||||
familyId: event.familyId
|
||||
}
|
||||
});
|
||||
|
||||
// Store notification record
|
||||
await storeNotification({
|
||||
type: "event_reminder",
|
||||
familyId: event.familyId,
|
||||
content: `Reminder: ${event.title} starts in ${reminderTime} minutes`,
|
||||
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||||
eventId: eventId
|
||||
});
|
||||
|
||||
logger.info(`Sent reminder for event: ${event.title}`, {
|
||||
eventId,
|
||||
minutesUntilEvent,
|
||||
reminderTime,
|
||||
currentTime: currentTime.toISOString(),
|
||||
eventTime: eventTime.toISOString()
|
||||
});
|
||||
await doc.ref.update({ reminderSent: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing reminder for event ${doc.id}`, error);
|
||||
logger.error(`Error processing reminder for event ${eventId}:`, error);
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
await Promise.all(processPromises);
|
||||
});
|
||||
// 3. Add a function to reset reminder flags for testing
|
||||
exports.resetReminderFlags = functions.https.onRequest(async (req, res) => {
|
||||
try {
|
||||
const batch = admin.firestore().batch();
|
||||
const events = await admin.firestore()
|
||||
.collection("Events")
|
||||
.where("reminderSent", "==", true)
|
||||
.get();
|
||||
|
||||
events.docs.forEach(doc => {
|
||||
batch.update(doc.ref, {
|
||||
reminderSent: false,
|
||||
reminderSentAt: null
|
||||
});
|
||||
});
|
||||
|
||||
await batch.commit();
|
||||
res.status(200).send(`Reset ${events.size} events`);
|
||||
} catch (error) {
|
||||
res.status(500).send(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
exports.initializeEventFlags = functions.firestore
|
||||
.document('Events/{eventId}')
|
||||
.onCreate(async (snapshot, context) => {
|
||||
try {
|
||||
const eventData = snapshot.data();
|
||||
if (eventData.reminderSent === undefined) {
|
||||
await snapshot.ref.update({
|
||||
reminderSent: false,
|
||||
reminderSentAt: null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error initializing event ${context.params.eventId}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ─────────────────────────────────
|
||||
@ -465,11 +702,13 @@ exports.syncNewEventToGoogle = functions.firestore
|
||||
const newEvent = snapshot.data();
|
||||
const eventId = context.params.eventId;
|
||||
|
||||
await snapshot.ref.update({
|
||||
reminderSent: false,
|
||||
eveningReminderSent: false,
|
||||
notifiedAt: null
|
||||
});
|
||||
if (newEvent.reminderSent === undefined) {
|
||||
await snapshot.ref.update({
|
||||
reminderSent: false,
|
||||
eveningReminderSent: false,
|
||||
notifiedAt: null
|
||||
});
|
||||
}
|
||||
|
||||
if (newEvent.externalOrigin === "google") {
|
||||
logger.info("[GOOGLE_SYNC] Skipping sync for Google-originated event", eventId);
|
||||
@ -1138,93 +1377,6 @@ exports.notifyOnEventUpdate = functions.firestore
|
||||
}
|
||||
});
|
||||
|
||||
/* ─────────────────────────────────
|
||||
REMINDER FUNCTION (for upcoming events)
|
||||
───────────────────────────────── */
|
||||
// We keep the reminder scheduler mostly as-is but ensure that once a notification is sent, the event is updated
|
||||
exports.checkUpcomingEvents = functions.pubsub
|
||||
.schedule("every 5 minutes")
|
||||
.onRun(async (context) => {
|
||||
const now = Timestamp.now();
|
||||
const thirtyMinutesFromNow = new Date(now.toDate().getTime() + 30 * 60 * 1000);
|
||||
|
||||
logger.info(`Running check at ${now.toDate().toISOString()}`);
|
||||
|
||||
const eventsSnapshot = await db.collection("Events")
|
||||
.where("startDate", ">=", now)
|
||||
.where("startDate", "<=", Timestamp.fromDate(thirtyMinutesFromNow))
|
||||
.get();
|
||||
|
||||
const batch = db.batch();
|
||||
const notificationPromises = [];
|
||||
|
||||
for (const doc of eventsSnapshot.docs) {
|
||||
const event = doc.data();
|
||||
if (!event?.startDate) continue;
|
||||
|
||||
const { familyId, title, allDay } = event;
|
||||
|
||||
// Skip if reminder already sent
|
||||
if (event.reminderSent) {
|
||||
logger.info(`Reminder already sent for event: ${title}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const familyDoc = await db.collection("Households").doc(familyId).get();
|
||||
if (!familyDoc.exists) continue;
|
||||
|
||||
const familySettings = familyDoc.data()?.settings || {};
|
||||
const reminderTime = familySettings.defaultReminderTime || 10;
|
||||
|
||||
const eventTime = event.startDate.toDate();
|
||||
const timeUntilEvent = eventTime.getTime() - now.toDate().getTime();
|
||||
const minutesUntilEvent = Math.floor(timeUntilEvent / (60 * 1000));
|
||||
|
||||
logger.info(`Checking event: "${title}"`, {
|
||||
minutesUntilEvent,
|
||||
reminderTime,
|
||||
eventTime: eventTime.toISOString()
|
||||
});
|
||||
|
||||
// Modified timing logic: Send reminder when we're close to the reminder time
|
||||
// This ensures we don't miss the window between function executions
|
||||
if (minutesUntilEvent <= reminderTime && minutesUntilEvent > 0) {
|
||||
logger.info(`Preparing to send reminder for: ${title}`);
|
||||
const pushTokens = await getPushTokensForFamily(familyId);
|
||||
|
||||
if (pushTokens.length) {
|
||||
await sendNotifications(pushTokens, {
|
||||
title: "Upcoming Event",
|
||||
body: `In ${minutesUntilEvent} minutes: ${title}`,
|
||||
data: { type: 'event_reminder', eventId: doc.id }
|
||||
});
|
||||
|
||||
batch.update(doc.ref, {
|
||||
reminderSent: true,
|
||||
lastReminderSent: Timestamp.now()
|
||||
});
|
||||
|
||||
logger.info(`Reminder sent for: ${title}`);
|
||||
}
|
||||
} else {
|
||||
logger.info(`Not yet time for reminder: ${title}`, {
|
||||
minutesUntilEvent,
|
||||
reminderTime
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing reminder for event ${doc.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit batch if there are any operations
|
||||
if (batch._ops.length > 0) {
|
||||
await batch.commit();
|
||||
logger.info(`Committed ${batch._ops.length} updates`);
|
||||
}
|
||||
});
|
||||
|
||||
/* ─────────────────────────────────
|
||||
MIGRATION UTILITY
|
||||
───────────────────────────────── */
|
||||
@ -1352,7 +1504,7 @@ exports.sendSyncNotification = onRequest(async (req, res) => {
|
||||
createdBy: userId,
|
||||
lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||||
settings: {
|
||||
defaultReminderTime: 15, // Default 15 minutes reminder
|
||||
defaultReminderTime: 30, // Default 30 minutes reminder
|
||||
}
|
||||
});
|
||||
logger.info(`[SYNC] Created new household for family ${familyId}`);
|
||||
@ -1699,4 +1851,88 @@ exports.forceWatchRenewal = onRequest(async (req, res) => {
|
||||
});
|
||||
await batch.commit();
|
||||
res.status(200).send('Forced renewal of all watches');
|
||||
});
|
||||
|
||||
exports.deleteFamily = functions.https.onCall(async (data, context) => {
|
||||
if (!context.auth) {
|
||||
throw new functions.https.HttpsError('unauthenticated', 'User must be authenticated');
|
||||
}
|
||||
|
||||
const { familyId } = data;
|
||||
|
||||
if (!familyId) {
|
||||
throw new functions.https.HttpsError('invalid-argument', 'Family ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const db = admin.firestore();
|
||||
|
||||
const requestingUserProfile = await db.collection('Profiles')
|
||||
.doc(context.auth.uid)
|
||||
.get();
|
||||
|
||||
if (!requestingUserProfile.exists || requestingUserProfile.data().userType !== 'parent') {
|
||||
throw new functions.https.HttpsError('permission-denied', 'Only parents can delete families');
|
||||
}
|
||||
|
||||
if (requestingUserProfile.data().familyId !== familyId) {
|
||||
throw new functions.https.HttpsError('permission-denied', 'You can not delete other families');
|
||||
}
|
||||
|
||||
const profilesSnapshot = await db.collection('Profiles')
|
||||
.where('familyId', '==', familyId)
|
||||
.get();
|
||||
|
||||
const batch = db.batch();
|
||||
const profileIds = [];
|
||||
|
||||
for (const profile of profilesSnapshot.docs) {
|
||||
const userId = profile.id;
|
||||
profileIds.push(userId);
|
||||
|
||||
const collections = [
|
||||
'BrainDumps',
|
||||
'Groceries',
|
||||
'Todos',
|
||||
'Events',
|
||||
//'Feedbacks'
|
||||
];
|
||||
|
||||
for (const collectionName of collections) {
|
||||
const userDocsSnapshot = await db.collection(collectionName)
|
||||
.where('creatorId', '==', userId)
|
||||
.get();
|
||||
|
||||
userDocsSnapshot.docs.forEach(doc => {
|
||||
batch.delete(doc.ref);
|
||||
});
|
||||
}
|
||||
|
||||
batch.delete(profile.ref);
|
||||
}
|
||||
|
||||
const householdSnapshot = await db.collection('Households')
|
||||
.where('familyId', '==', familyId)
|
||||
.get();
|
||||
|
||||
if (!householdSnapshot.empty) {
|
||||
const householdDoc = householdSnapshot.docs[0];
|
||||
batch.delete(householdDoc.ref);
|
||||
} else {
|
||||
console.log('Household not found for familyId:', familyId);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
|
||||
// Delete Firebase Auth accounts
|
||||
await Promise.all(profileIds.map(userId =>
|
||||
admin.auth().deleteUser(userId)
|
||||
));
|
||||
|
||||
return { success: true, message: 'Family deleted successfully' };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting family:', error);
|
||||
throw new functions.https.HttpsError('internal', 'Error deleting family data');
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ export interface IToDo {
|
||||
creatorId?: string;
|
||||
familyId?: string;
|
||||
assignees?: string[]; // Optional list of assignees
|
||||
currentAssignee?: string,
|
||||
connectedTodoId?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -130,7 +130,7 @@ export const useCreateTodo = () => {
|
||||
|
||||
let assignee;
|
||||
if (todoData.assignees && todoData.rotate && todoData?.assignees?.length !== 0) {
|
||||
assignee = todoData.assignees[index % todoData.assignees.length];
|
||||
assignee = todoData.assignees[(index + 1) % todoData.assignees.length];
|
||||
}
|
||||
|
||||
const nextTodo = {
|
||||
@ -140,7 +140,8 @@ export const useCreateTodo = () => {
|
||||
familyId: profileData?.familyId,
|
||||
creatorId: currentUser?.uid,
|
||||
connectedTodoId: ruleDocRef.id,
|
||||
assignees: assignee ? [assignee] : todoData.assignees
|
||||
assignees: todoData.assignees,
|
||||
currentAssignee: assignee
|
||||
}
|
||||
|
||||
batch.set(newDocRef, nextTodo)
|
||||
|
||||
@ -2,9 +2,11 @@ import { useAuthContext } from "@/contexts/AuthContext";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import functions from '@react-native-firebase/functions';
|
||||
import { Alert } from 'react-native';
|
||||
import { useSignOut } from './useSignOut';
|
||||
|
||||
export const useDeleteFamily = () => {
|
||||
const { user } = useAuthContext();
|
||||
const signOut = useSignOut();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["deleteFamily"],
|
||||
@ -16,6 +18,11 @@ export const useDeleteFamily = () => {
|
||||
try {
|
||||
const deleteFamilyFunction = functions().httpsCallable('deleteFamily');
|
||||
const result = await deleteFamilyFunction({ familyId });
|
||||
|
||||
if (result.data.success) {
|
||||
await signOut.mutateAsync();
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'permission-denied') {
|
||||
|
||||
@ -1,30 +1,49 @@
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import auth from "@react-native-firebase/auth";
|
||||
import firestore from "@react-native-firebase/firestore";
|
||||
import {ProfileType, useAuthContext} from "@/contexts/AuthContext";
|
||||
import {useSetUserData} from "./useSetUserData";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import * as Localization from "expo-localization";
|
||||
|
||||
export const useSignUp = () => {
|
||||
const {setRedirectOverride} = useAuthContext()
|
||||
const {setRedirectOverride} = useAuthContext();
|
||||
const {mutateAsync: setUserData} = useSetUserData();
|
||||
|
||||
const createHouseholdIfNeeded = async (familyId: string, lastName: string) => {
|
||||
try {
|
||||
const householdRef = firestore().collection("Households");
|
||||
const snapshot = await householdRef.where("familyId", "==", familyId).get();
|
||||
|
||||
if (snapshot.empty) {
|
||||
await householdRef.add({
|
||||
familyId,
|
||||
name: lastName
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating household:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["signUp"],
|
||||
mutationFn: async ({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
birthday
|
||||
}: {
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
birthday
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthday: Date;
|
||||
}) => {
|
||||
setRedirectOverride(true)
|
||||
setRedirectOverride(true);
|
||||
const familyId = uuidv4();
|
||||
|
||||
await auth()
|
||||
.createUserWithEmailAndPassword(email, password)
|
||||
@ -35,12 +54,14 @@ export const useSignUp = () => {
|
||||
userType: ProfileType.PARENT,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
familyId: uuidv4(),
|
||||
familyId: familyId,
|
||||
timeZone: Localization.getCalendars()[0].timeZone,
|
||||
birthday: birthday
|
||||
},
|
||||
customUser: res.user,
|
||||
});
|
||||
|
||||
await createHouseholdIfNeeded(familyId, lastName);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
@ -59,14 +59,15 @@ export const useUpdateTodo = () => {
|
||||
const newDate = nextDates[index];
|
||||
let assignee;
|
||||
if (todoData.assignees && todoData.rotate && todoData?.assignees?.length !== 0) {
|
||||
assignee = todoData.assignees[index % todoData.assignees.length];
|
||||
assignee = todoData.assignees[(index + 1) % todoData.assignees.length];
|
||||
}
|
||||
|
||||
if (newDate) {
|
||||
const nextTodo = {
|
||||
...todoData,
|
||||
date: newDate,
|
||||
assignees: assignee ? [assignee] : todoData.assignees
|
||||
assignees: todoData.assignees,
|
||||
currentAssignee: assignee
|
||||
}
|
||||
let docRef = todo.ref;
|
||||
batch.update(docRef, nextTodo)
|
||||
@ -83,7 +84,7 @@ export const useUpdateTodo = () => {
|
||||
|
||||
let assignee;
|
||||
if (todoData.assignees && todoData.rotate && todoData?.assignees?.length !== 0) {
|
||||
assignee = todoData.assignees[index % todoData.assignees.length];
|
||||
assignee = todoData.assignees[(index + 1) % todoData.assignees.length];
|
||||
}
|
||||
|
||||
const nextTodo = {
|
||||
@ -93,7 +94,8 @@ export const useUpdateTodo = () => {
|
||||
familyId: profileData?.familyId,
|
||||
creatorId: currentUser?.uid,
|
||||
connectedTodoId: todoData.connectedTodoId,
|
||||
assignees: assignee ? [assignee] : todoData.assignees
|
||||
assignees: todoData.assignees,
|
||||
currentAssignee: assignee
|
||||
}
|
||||
|
||||
batch.set(newDocRef, nextTodo)
|
||||
|
||||
Reference in New Issue
Block a user