diff --git a/package.json b/package.json index d408880..d5c688f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@expo/metro-runtime": "^3.2.3", - "@gorhom/bottom-sheet": "4.6.3", + "@gorhom/bottom-sheet": "5.0.6", "@hookform/resolvers": "^3.9.0", "@react-native-menu/menu": "^1.1.7", "@shopify/flash-list": "1.6.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d761dec..89b3ac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^3.2.3 version: 3.2.3(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0)) '@gorhom/bottom-sheet': - specifier: 4.6.3 - version: 4.6.3(@types/react@18.2.79)(react-native-gesture-handler@2.16.2(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0))(react-native-reanimated@3.10.1(@babel/core@7.25.2)(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0))(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0) + specifier: 5.0.6 + version: 5.0.6(@types/react@18.2.79)(react-native-gesture-handler@2.16.2(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0))(react-native-reanimated@3.10.1(@babel/core@7.25.2)(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0))(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0) '@hookform/resolvers': specifier: ^3.9.0 version: 3.9.0(react-hook-form@7.53.0(react@18.2.0)) @@ -1131,15 +1131,15 @@ packages: '@formatjs/intl-localematcher@0.5.4': resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} - '@gorhom/bottom-sheet@4.6.3': - resolution: {integrity: sha512-fSuSfbtoKsjmSeyz+tG2C0GtcEL7PS63iEXI23c9M+HeCT1IFK6ffmIa2pqyqB43L1jtkR+BWkpZwqXnN4H8xA==} + '@gorhom/bottom-sheet@5.0.6': + resolution: {integrity: sha512-SI/AhPvgRfnCWN6/+wbE6TXwRE4X8F2fLyE4L/0bRwgE34Zenq585qLT139uEcfCIyovC2swC3ICqQpkmWEcFA==} peerDependencies: '@types/react': '*' '@types/react-native': '*' react: '*' react-native: '*' - react-native-gesture-handler: '>=1.10.1' - react-native-reanimated: '>=2.2.0' + react-native-gesture-handler: '>=2.16.1' + react-native-reanimated: '>=3.16.0' peerDependenciesMeta: '@types/react': optional: true @@ -8436,7 +8436,7 @@ snapshots: dependencies: tslib: 2.7.0 - '@gorhom/bottom-sheet@4.6.3(@types/react@18.2.79)(react-native-gesture-handler@2.16.2(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0))(react-native-reanimated@3.10.1(@babel/core@7.25.2)(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0))(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0)': + '@gorhom/bottom-sheet@5.0.6(@types/react@18.2.79)(react-native-gesture-handler@2.16.2(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0))(react-native-reanimated@3.10.1(@babel/core@7.25.2)(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0))(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0)': dependencies: '@gorhom/portal': 1.0.14(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0) invariant: 2.2.4 diff --git a/src/api/habits/index.tsx b/src/api/habits/index.tsx index 993af14..f68be08 100644 --- a/src/api/habits/index.tsx +++ b/src/api/habits/index.tsx @@ -2,6 +2,7 @@ export * from './types'; export * from './use-create-habit'; export * from './use-delete-habit'; export * from './use-edit-habit'; +export * from './use-habit'; export * from './use-habit-completions'; export * from './use-habits'; export * from './use-press-habit-button'; diff --git a/src/api/habits/mock-habits.tsx b/src/api/habits/mock-habits.tsx index 8096eac..729158c 100644 --- a/src/api/habits/mock-habits.tsx +++ b/src/api/habits/mock-habits.tsx @@ -98,6 +98,33 @@ export const mockHabits: { id: HabitIdT; data: DbHabitT }[] = [ }, }, }, + { + id: '4' as HabitIdT, + data: { + colorName: 'blue', + createdAt: new Date('2024-01-04T00:00:00'), + description: 'Meditate for 10 minutes', + title: 'Daily Meditation', + settings: { + allowMultipleCompletions: false, + }, + icon: 'star', + participants: { + ['2' as UserIdT]: { + displayName: 'Jane Doe', + username: 'jane_doe', + lastActivity: new Date('2024-12-15T00:00:00'), + isOwner: true, + }, + ['4' as UserIdT]: { + displayName: 'Bob Johnson', + username: 'bob_johnson', + lastActivity: new Date('2024-12-14T00:00:00'), + isOwner: false, + }, + }, + }, + }, ]; export const setMockHabits = ( newHabits: { id: HabitIdT; data: DbHabitT }[], diff --git a/src/api/habits/use-habit.tsx b/src/api/habits/use-habit.tsx new file mode 100644 index 0000000..6c868e5 --- /dev/null +++ b/src/api/habits/use-habit.tsx @@ -0,0 +1,50 @@ +import { createQuery } from 'react-query-kit'; + +import { habitColors } from '@/ui/colors'; + +import { addTestDelay } from '../common'; +import { type UserIdT } from '../users'; +import { mockHabits } from './mock-habits'; +import { type HabitT } from './types'; + +type Variables = { id: HabitT['id'] }; +type Response = HabitT; + +export const useHabit = createQuery({ + queryKey: ['habit'], + fetcher: async ({ id }) => { + const dbHabit = await addTestDelay( + mockHabits.find((habit) => habit.id === id), + ); + if (!dbHabit) throw new Error('Habit not found'); + + const { data } = dbHabit; + return { + id, + ...data, + color: habitColors[data.colorName], + participants: Object.fromEntries( + Object.entries(data.participants).map( + ([participantId, participant]) => { + if (!participant) + throw new Error('Participant not found for habit ' + id); + + return [ + participantId, + { + id: participantId as UserIdT, + displayName: participant.displayName, + username: participant.username, + lastActivity: new Date(participant.lastActivity), + hasActivityToday: + participant.lastActivity.toLocaleDateString('en-CA') === + new Date().toLocaleDateString('en-CA'), + isOwner: participant?.isOwner ?? false, + }, + ]; + }, + ), + ), + }; + }, +}); diff --git a/src/api/habits/use-habits.tsx b/src/api/habits/use-habits.tsx index 59b826b..fafa300 100644 --- a/src/api/habits/use-habits.tsx +++ b/src/api/habits/use-habits.tsx @@ -15,33 +15,35 @@ export const useHabits = createQuery({ fetcher: async () => { const dbHabits = await addTestDelay(mockHabits); - const habits: HabitT[] = dbHabits.map(({ id: habitId, data }) => ({ - id: habitId, - ...data, - color: habitColors[data.colorName], - participants: Object.fromEntries( - Object.entries(data.participants).map( - ([participantId, participant]) => { - if (!participant) - throw new Error('Participant not found for habit ' + habitId); + const habits: HabitT[] = dbHabits + .filter((habit) => '1' in habit.data.participants) + .map(({ id: habitId, data }) => ({ + id: habitId, + ...data, + color: habitColors[data.colorName], + participants: Object.fromEntries( + Object.entries(data.participants).map( + ([participantId, participant]) => { + if (!participant) + throw new Error('Participant not found for habit ' + habitId); - return [ - participantId, - { - id: participantId as UserIdT, - displayName: participant.displayName, - username: participant.username, - lastActivity: new Date(participant.lastActivity), - hasActivityToday: - participant.lastActivity.toLocaleDateString('en-CA') === - new Date().toLocaleDateString('en-CA'), - isOwner: participant?.isOwner ?? false, - }, - ]; - }, + return [ + participantId, + { + id: participantId as UserIdT, + displayName: participant.displayName, + username: participant.username, + lastActivity: new Date(participant.lastActivity), + hasActivityToday: + participant.lastActivity.toLocaleDateString('en-CA') === + new Date().toLocaleDateString('en-CA'), + isOwner: participant?.isOwner ?? false, + }, + ]; + }, + ), ), - ), - })); + })); return habits; }, diff --git a/src/api/index.tsx b/src/api/index.tsx index 606f5e3..82b9754 100644 --- a/src/api/index.tsx +++ b/src/api/index.tsx @@ -1,4 +1,5 @@ export * from './colors-schemas'; export * from './common'; export * from './habits'; +export * from './notifications'; export * from './users'; diff --git a/src/api/notifications/index.ts b/src/api/notifications/index.ts index fcb073f..ed1bcc4 100644 --- a/src/api/notifications/index.ts +++ b/src/api/notifications/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './use-notifications'; diff --git a/src/api/notifications/mock-notifications.tsx b/src/api/notifications/mock-notifications.tsx new file mode 100644 index 0000000..df5059a --- /dev/null +++ b/src/api/notifications/mock-notifications.tsx @@ -0,0 +1,45 @@ +import { type HabitIdT } from '../habits'; +import { type UserIdT } from '../users'; +import { + type FriendNotificationT, + type HabitNotificationT, + type NotificationT, +} from './types'; + +const now = new Date(); +const getTimeAgoDate = (seconds: number) => + new Date(now.getTime() - seconds * 1000); + +export const mockNotifications: (FriendNotificationT | HabitNotificationT)[] = [ + { + type: 'friendRequest', + senderId: '3' as UserIdT, + receiverId: '1' as UserIdT, + sentAt: getTimeAgoDate(20), // 20 seconds ago + }, + { + type: 'friendRequest', + senderId: '6' as UserIdT, + receiverId: '1' as UserIdT, + sentAt: getTimeAgoDate(3600), // 1 hour ago + }, + { + type: 'habitInvite', + habitId: '4' as HabitIdT, + senderId: '2' as UserIdT, + receiverId: '1' as UserIdT, + sentAt: getTimeAgoDate(1800), // 30 minutes ago + }, + { + type: 'nudge', + habitId: '1' as HabitIdT, + senderId: '4' as UserIdT, + receiverId: '1' as UserIdT, + sentAt: getTimeAgoDate(172800), // 2 days ago + }, +]; + +export const setMockNotifications = (newNotifications: NotificationT[]) => { + mockNotifications.length = 0; + mockNotifications.push(...newNotifications); +}; diff --git a/src/api/notifications/types.ts b/src/api/notifications/types.ts index e321bed..a92664c 100644 --- a/src/api/notifications/types.ts +++ b/src/api/notifications/types.ts @@ -9,21 +9,29 @@ export const NotificationIdSchema = z.coerce .string() .transform((val) => val as NotificationIdT); -export const noticationTypeSchema = z.enum([ - 'habitInvite', - 'nudge', - 'friendRequest', +export const habitNoticationTypeSchema = z.enum(['habitInvite', 'nudge']); +export type HabitNotificationTypeT = z.infer; + +export const friendNoticationTypeSchema = z.enum(['friendRequest']); +export type FriendNotificationTypeT = z.infer< + typeof friendNoticationTypeSchema +>; + +export const notificationTypeSchema = z.union([ + habitNoticationTypeSchema, + friendNoticationTypeSchema, ]); +export type NotificationTypeT = z.infer; export const friendNotificationSchema = z.object({ - type: noticationTypeSchema, + type: friendNoticationTypeSchema, senderId: UserIdSchema, receiverId: UserIdSchema, sentAt: z.date(), }); export const habitNotificationSchema = z.object({ - type: noticationTypeSchema, + type: habitNoticationTypeSchema, habitId: HabitIdSchema, senderId: UserIdSchema, receiverId: UserIdSchema, @@ -35,8 +43,6 @@ export const notificationSchema = z.union([ habitNotificationSchema, ]); -export type NotificationTypeT = z.infer; - export type FriendNotificationT = z.infer; export type HabitNotificationT = z.infer; export type NotificationT = z.infer; diff --git a/src/api/notifications/use-notifications.tsx b/src/api/notifications/use-notifications.tsx new file mode 100644 index 0000000..d573f66 --- /dev/null +++ b/src/api/notifications/use-notifications.tsx @@ -0,0 +1,21 @@ +import { createQuery } from 'react-query-kit'; + +import { addTestDelay } from '../common'; +import { mockNotifications } from './mock-notifications'; +import { type FriendNotificationT, type HabitNotificationT } from './types'; + +type Response = (FriendNotificationT | HabitNotificationT)[]; +type Variables = void; + +export const useNotifications = createQuery({ + queryKey: ['notifications'], + fetcher: async () => { + const myId = '1'; + const notifications = await addTestDelay( + mockNotifications.filter( + (notification) => notification.receiverId === myId, + ), + ); + return notifications; + }, +}); diff --git a/src/api/users/use-user.tsx b/src/api/users/use-user.tsx index ee02387..7b83ac3 100644 --- a/src/api/users/use-user.tsx +++ b/src/api/users/use-user.tsx @@ -10,7 +10,7 @@ type Response = UserWithRelationshipT; export const useUser: ReturnType< typeof createQuery > = createQuery({ - queryKey: ['friend'], + queryKey: ['user'], fetcher: async (variables) => { const myId = '1' as UserIdT; const user = await addTestDelay( diff --git a/src/app/(tabs)/notifications.tsx b/src/app/(tabs)/notifications.tsx index 7a08318..15bf581 100644 --- a/src/app/(tabs)/notifications.tsx +++ b/src/app/(tabs)/notifications.tsx @@ -1,11 +1,101 @@ +/* eslint-disable max-lines-per-function */ +import { useQueries } from '@tanstack/react-query'; import React from 'react'; -import { Header, ScreenContainer } from '@/ui'; +import { type HabitT, useHabit, useNotifications, useUser } from '@/api'; +import { NotificationCard } from '@/components/notification-card'; +import { + Header, + LoadingSpinner, + ScreenContainer, + ScrollView, + Text, + View, +} from '@/ui'; export default function Notifications() { + const { + data: notifications, + isLoading: notificationsLoading, + error: notificationsError, + } = useNotifications(); + + const habitIds = + notifications + ?.map((n) => (n.type !== 'friendRequest' ? n.habitId : undefined)) + .filter((id): id is HabitT['id'] => !!id) ?? []; + + const senderIds = notifications?.map((n) => n.senderId) ?? []; + + const users = useQueries({ + queries: senderIds.map((id) => ({ + queryKey: ['user', { id }], + queryFn: () => useUser.fetcher({ id }), + staleTime: Infinity, + })), + }); + + const habits = useQueries({ + queries: habitIds.map((id) => ({ + queryKey: ['habit', { id }], + queryFn: () => useHabit.fetcher({ id }), + staleTime: Infinity, + })), + }); + + if (notificationsLoading) return ; + if (notificationsError) + return Error: {notificationsError.message}; + if (!notifications) return null; + + const sortedNotifications = [...notifications].sort( + (a, b) => b.sentAt.getTime() - a.sentAt.getTime(), + ); + + const getUserName = (userId: string) => { + const userQuery = users.find((u) => u.data?.id === userId); + if (userQuery?.isLoading || !userQuery?.data?.displayName) { + return null; + } + return userQuery.data.displayName; + }; + + const getHabitData = (habitId: string) => { + const habitQuery = habits.find((h) => h.data?.id === habitId); + if (habitQuery?.isLoading || !habitQuery?.data) { + return null; + } + return habitQuery.data; + }; + return (
+ + + {sortedNotifications.map((notification) => { + const userName = getUserName(notification.senderId); + const habit = + notification.type !== 'friendRequest' + ? getHabitData(notification.habitId) + : undefined; + + const isLoading = + userName === null || + (notification.type !== 'friendRequest' && habit === null); + + return ( + + ); + })} + + ); } diff --git a/src/app/habits/edit-habit.tsx b/src/app/habits/edit-habit.tsx index add10c7..8ad31cb 100644 --- a/src/app/habits/edit-habit.tsx +++ b/src/app/habits/edit-habit.tsx @@ -1,4 +1,5 @@ /* eslint-disable max-lines-per-function */ +import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; import { zodResolver } from '@hookform/resolvers/zod'; import { router } from 'expo-router'; import { useLocalSearchParams } from 'expo-router'; @@ -217,33 +218,32 @@ export default function EditHabit() { - - - - {Object.keys(habitIcons).map((icon) => ( - { - setValue('icon', icon as keyof typeof habitIcons); - iconModal.dismiss(); - }} - > - - - ))} - - - + + + {Object.keys(habitIcons).map((icon) => ( + { + setValue('icon', icon as keyof typeof habitIcons); + iconModal.dismiss(); + }} + > + + + ))} + + ); diff --git a/src/app/habits/view-habit.tsx b/src/app/habits/view-habit.tsx index daae27b..b636560 100644 --- a/src/app/habits/view-habit.tsx +++ b/src/app/habits/view-habit.tsx @@ -1,4 +1,5 @@ /* eslint-disable max-lines-per-function */ +import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; import { Link, useLocalSearchParams } from 'expo-router'; import { BellIcon, @@ -21,6 +22,7 @@ import { } from '@/api'; import { ErrorMessage } from '@/components/error-message'; import { HabitIcon, type habitIcons } from '@/components/habit-icon'; +import HabitInfoCard from '@/components/habit-info-card'; import ModifyHabitEntry from '@/components/modify-habit-entry'; import { DayNavigation } from '@/components/modify-habit-entry/day-navigation'; import UserCard, { UserImageNameAndUsername } from '@/components/user-card'; @@ -224,68 +226,16 @@ const HabitHeader = ({ habit }: HabitHeaderProps) => { - + - - - - {habit.title} - - {habit.description && ( - {habit.description} - )} - - {habit.settings.allowMultipleCompletions - ? 'Multiple completions allowed per day' - : 'One completion per day limit'} - - - Created{' '} - {new Date(habit.createdAt).toLocaleDateString(undefined, { - year: 'numeric', - month: 'long', - day: 'numeric', - })} - - +