From 98428124356a0ceb19f60bd28bd2aa19c20e6b48 Mon Sep 17 00:00:00 2001 From: Eero Salla <28145295+eerosal@users.noreply.github.com> Date: Thu, 18 Jul 2024 17:41:22 +0300 Subject: [PATCH 1/4] Fix lag when switching between days in the calendar --- app/_layout.tsx | 9 ++- components/timetable/Event.tsx | 22 +++++--- elements/timetable/EventsBox.tsx | 65 +++++++++++++++------- hooks/useFavorite.ts | 95 ++++++++++++++++---------------- package-lock.json | 47 +++++++++++++++- package.json | 4 +- 6 files changed, 165 insertions(+), 77 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 6ac37a5..82083fc 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,5 +1,6 @@ import { TabBarIcon } from '@/components'; import { GlobalStateProvider } from '@/hooks/providers/GlobalStateProvider'; +import { useFavoriteStoreStatus } from '@/hooks/useFavorite'; import Locales from '@/locales'; import { Themes } from '@/styles'; import { useFonts } from 'expo-font'; @@ -34,13 +35,15 @@ export default function RootLayout() { Roboto: require('../assets/fonts/Roboto-Regular.ttf'), }); + const { hydrated: favoriteStoreReady } = useFavoriteStoreStatus(); + const { t } = useTranslation(); useEffect(() => { - if (loaded) { - SplashScreen.hideAsync(); + if (loaded && favoriteStoreReady) { + SplashScreen.hideAsync().catch(); } - }, [loaded]); + }, [loaded, favoriteStoreReady]); if (!loaded) { return null; diff --git a/components/timetable/Event.tsx b/components/timetable/Event.tsx index 67eeebb..5050082 100644 --- a/components/timetable/Event.tsx +++ b/components/timetable/Event.tsx @@ -1,7 +1,9 @@ import { useFavorite } from '@/hooks/useFavorite'; import dayjs from 'dayjs'; +import { Image } from 'expo-image'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Image, View } from 'react-native'; +import { View } from 'react-native'; import { IconButton, Surface, Text } from 'react-native-paper'; interface EventProps { @@ -26,10 +28,15 @@ const getEventTimeString = (start: Date, end: Date) => { }; const Event = ({ id, title, location, start, end, color, thumbnail }: EventProps) => { - const timeString = getEventTimeString(start, end); - const { favorite, toggle: toggleFavorite } = useFavorite(id); + const timeString = useMemo(() => getEventTimeString(start, end), [start, end]); + const { isFavorite, setIsFavorite } = useFavorite(id); const { t, i18n } = useTranslation(); - dayjs.locale(i18n.language); + + const [isAfterEnd, isBeforeStart] = useMemo(() => { + dayjs.locale(i18n.language); + + return [dayjs().isAfter(end), dayjs().isBefore(start)]; + }, [end, i18n.language, start]); return ( - {dayjs().isAfter(end) && + {isAfterEnd && process.env.EXPO_PUBLIC_ENVIRONMENT !== 'development' && process.env.EXPO_PUBLIC_ENVIRONMENT !== 'preview' && ( { + const [loadedEvents, setLoadedEvents] = React.useState([]); + + const isLoading = loadedEvents !== events; + + useEffect(() => { + const timeout = setTimeout(() => { + setLoadedEvents(events); + }, 250); + + return () => clearTimeout(timeout); + }, [events]); + return ( - - {events.map((event) => ( - - ))} - + <> + {isLoading ? : null} + + removeClippedSubviews={true} + pointerEvents={isLoading ? 'none' : 'auto'} + style={{ + paddingHorizontal: 30, + paddingBottom: 8, + opacity: isLoading ? 0 : 1, + }} + renderItem={({ item: event }) => ( + + )} + contentContainerStyle={{ gap: 8 }} + keyExtractor={(event) => event.id.toString()} + getItem={(data, index) => data[index]} + getItemCount={(_) => loadedEvents.length} + data={loadedEvents} + initialNumToRender={8} + /> + ); }; -export default EventsBox; +export default React.memo(EventsBox); diff --git a/hooks/useFavorite.ts b/hooks/useFavorite.ts index c514584..745a3ed 100644 --- a/hooks/useFavorite.ts +++ b/hooks/useFavorite.ts @@ -1,63 +1,66 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useEffect, useState } from 'react'; +import { useCallback, useMemo } from 'react'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { useShallow } from 'zustand/react/shallow'; -/** - * Retrieves the favorite events from async storage. - * @returns A promise that resolves to an array of numbers representing the favorite event ids . - */ -const getFavorites = async (): Promise => { - try { - const data = await AsyncStorage.getItem('favorite_events'); - if (data) { - return data.split(';').map((n) => parseInt(n)); - } - } catch (ex) { - console.error('Failed to get favorites from async storage:', ex); - } - return []; +type FavoriteStore = { + hydrated: boolean; + setHydrated: (value: boolean) => void; + favorites: Record; + setFavorite: (id: number, value: boolean) => void; }; -/** - * Saves the favorite events to async storage. - * - * @param {number[]} favorites - The array of favorite event IDs. - */ -const saveFavorites = async (favorites: number[]) => { - try { - await AsyncStorage.setItem('favorite_events', favorites.join(';')); - } catch (ex) { - console.error('Failed to save favorites to async storage:', ex); - } -}; +const useFavoriteStore = create()( + persist( + (set) => ({ + hydrated: false, + setHydrated: (value) => set({ hydrated: value }), + favorites: {}, + setFavorite: (id, value) => + set((state) => ({ + favorites: { ...state.favorites, [id.toString(10)]: value || undefined }, + })), + }), + { + name: 'favoriteEvents', + onRehydrateStorage: () => (state) => state?.setHydrated(true), + storage: createJSONStorage(() => AsyncStorage), + } + ) +); /** * Hook to manage favorite state for a given eventId. * * @param id - The ID of the event to track favorite state for. - * @returns An object containing the current favorite state and a function to toggle the favorite state. + * @returns An object containing the current favorite state and a function to set the favorite state. */ export const useFavorite = (id: number) => { - const [favorite, setFavorite] = useState(false); + const idStr = id.toString(10); - useEffect(() => { - getFavorites().then((favorites) => { - setFavorite(favorites.includes(id)); - }); - }, [id]); + const [isFavorite, setFavorite] = useFavoriteStore( + useShallow((state) => { + return [state.favorites[idStr] ?? false, state.setFavorite]; + }) + ); - const toggle = async () => { - let favorites = await getFavorites(); + const setIsFavorite = useCallback( + (value: boolean) => { + setFavorite(id, value); + }, + [id, setFavorite] + ); - if (favorite) { - favorites.push(id); - } else { - favorites.filter((f) => f !== id); - } + return useMemo(() => ({ isFavorite, setIsFavorite }), [isFavorite, setIsFavorite]); +}; - saveFavorites(favorites); - // Trigger redraw - setFavorite(!favorite); - }; +/** + * Hook to get the current favorite store status. + * @returns An object containing the current hydration status. + */ +export const useFavoriteStoreStatus = () => { + const hydrated = useFavoriteStore((state) => state.hydrated); - return { favorite, toggle }; + return useMemo(() => ({ hydrated }), [hydrated]); }; diff --git a/package-lock.json b/package-lock.json index c114a51..701f348 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "expo-build-properties": "~0.12.3", "expo-constants": "~16.0.2", "expo-font": "~12.0.7", + "expo-image": "~1.12.13", "expo-linking": "~6.3.1", "expo-router": "~3.5.16", "expo-splash-screen": "~0.27.5", @@ -34,7 +35,8 @@ "react-native-safe-area-context": "^4.10.1", "react-native-screens": "3.31.1", "react-native-web": "~0.19.10", - "react-native-webview": "13.8.6" + "react-native-webview": "13.8.6", + "zustand": "^4.5.4" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -9574,6 +9576,14 @@ "expo": "*" } }, + "node_modules/expo-image": { + "version": "1.12.13", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-1.12.13.tgz", + "integrity": "sha512-Dmuc5qmkIsl1nFj8C3Ux3wL2bN4QYW4dM9fkGA8kYiP5Fxf1lT36ldkHk2O2lPFRSFJDvLxT8Tz+7GTko5fzwQ==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "13.0.2", "license": "MIT", @@ -17666,6 +17676,14 @@ "react": ">=16.8" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "license": "MIT", @@ -18184,6 +18202,33 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.4.tgz", + "integrity": "sha512-/BPMyLKJPtFEvVL0E9E9BTUM63MNyhPGlvxk1XjrfWTUlV+BR8jufjsovHzrtR6YNcBEcL7cMHovL1n9xHawEg==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 2e767b8..244549c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,9 @@ "react-native-safe-area-context": "^4.10.1", "react-native-screens": "3.31.1", "react-native-web": "~0.19.10", - "react-native-webview": "13.8.6" + "react-native-webview": "13.8.6", + "zustand": "^4.5.4", + "expo-image": "~1.12.13" }, "devDependencies": { "@babel/core": "^7.20.0", From 6138aa0e10754aef411e085694175df1bfc6bc76 Mon Sep 17 00:00:00 2001 From: Eero Salla <28145295+eerosal@users.noreply.github.com> Date: Thu, 18 Jul 2024 17:43:49 +0300 Subject: [PATCH 2/4] Fix Webview background to match the app background --- app/credits.tsx | 8 ++++++-- elements/about/AboutWebview.tsx | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/credits.tsx b/app/credits.tsx index 260b1af..1754404 100644 --- a/app/credits.tsx +++ b/app/credits.tsx @@ -2,10 +2,14 @@ import { useTranslation } from 'react-i18next'; import { ImageBackground, View } from 'react-native'; import { ScrollView } from 'react-native'; import { Text, useTheme } from 'react-native-paper'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Credits() { const theme = useTheme(); const { t } = useTranslation(); + + const safeArea = useSafeAreaInsets(); + return ( @@ -66,11 +70,11 @@ export default function Credits() { Luukas Pörtfors Otto Laakkonen - Eero Salla Onni Linnala Anto Keinänen Samu Kupiainen Miika Tuominen + Eero Salla { const [loading, setLoading] = useState(true); const { i18n } = useTranslation(); + const theme = useTheme(); const uri = `https://assembly.org/${i18n.language}/about`; const whitelist = [ @@ -32,6 +33,7 @@ const AboutWebview = () => { whitelistedUrls={whitelist} style={{ display: loading ? 'none' : 'flex', + backgroundColor: theme.colors.background, }} onLoad={() => setLoading(false)} source={{ uri }} From 366302808db371fe91b8882bed71477dfeed47a2 Mon Sep 17 00:00:00 2001 From: Eero Salla <28145295+eerosal@users.noreply.github.com> Date: Thu, 18 Jul 2024 17:46:09 +0300 Subject: [PATCH 3/4] Fix date selector to be higher z-indexed than the events to avoid issues when scaling --- elements/timetable/Timetable.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/elements/timetable/Timetable.tsx b/elements/timetable/Timetable.tsx index 66c7b66..d91b2c0 100644 --- a/elements/timetable/Timetable.tsx +++ b/elements/timetable/Timetable.tsx @@ -62,13 +62,19 @@ const Timetable = () => { ) : ( <> - 0} - /> + + 0} + /> + From 03564097f34ce46b4e819e6155b9378108f6698e Mon Sep 17 00:00:00 2001 From: Eero Salla <28145295+eerosal@users.noreply.github.com> Date: Thu, 18 Jul 2024 17:47:04 +0300 Subject: [PATCH 4/4] Prevent event titles from flowing under the favorite button --- components/timetable/Event.tsx | 73 +++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/components/timetable/Event.tsx b/components/timetable/Event.tsx index 5050082..9c03989 100644 --- a/components/timetable/Event.tsx +++ b/components/timetable/Event.tsx @@ -94,33 +94,58 @@ const Event = ({ id, title, location, start, end, color, thumbnail }: EventProps backgroundColor: 'rgba(0, 0, 0, 0.75)', }} /> - - - {title} - - {location && ( + + + {`${t('location')}: ${location}`} - )} - - {`${t('time')}: ${timeString}`} - - - {dayjs().isBefore(start) || - ((process.env.EXPO_PUBLIC_ENVIRONMENT === 'development' || - process.env.EXPO_PUBLIC_ENVIRONMENT === 'preview') && ( - toggleFavorite()} - icon={favorite ? 'heart' : 'heart-outline'} + variant='titleMedium' style={{ - position: 'absolute', - top: 0, - right: 0, + textAlign: 'center', }} - /> - ))} + > + {title} + + {location && ( + {`${t('location')}: ${location}`} + )} + + {`${t('time')}: ${timeString}`} + + + {isBeforeStart || + ((process.env.EXPO_PUBLIC_ENVIRONMENT === 'development' || + process.env.EXPO_PUBLIC_ENVIRONMENT === 'preview') && ( + setIsFavorite(!isFavorite)} + icon={isFavorite ? 'heart' : 'heart-outline'} + style={{ + height: 40, + width: 40, + }} + /> + ))} + ); };