diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5dc647a..aec41f6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -53,4 +53,4 @@ "ios": "expo run:ios" }, "devDependencies": {} -} \ No newline at end of file +} diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index c9f8d30..abb4c0f 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,12 +1,17 @@ import { useCallback, useState } from 'react'; -import { ActivityIndicator, View } from 'react-native'; +import { View } from 'react-native'; import { createStyleSheet, useStyles } from 'react-native-unistyles'; import { match, P } from 'ts-pattern'; +import { ActivityIndicator } from '../components/ActivityIndicator'; import { UploadButton } from '../components/UploadButton'; +import { CatCardSkeleton } from '../features/CatCard/CatCardSkeleton'; import { ImageList } from '../features/HomePage/ImageList'; +import { ImageListWrapper } from '../features/HomePage/ImageListWrapper'; import { NoImagesFound } from '../features/HomePage/NoImagesFound'; import { UploadImageSheet } from '../features/UploadImageModal/UploadImageSheet'; +import { useAppSelector } from '../store/overrides'; +import { getIsImageUploading } from '../store/selectors/getIsImageUploading'; import { useGetMyFavouritesQuery, useGetMyImagesQuery, @@ -20,29 +25,65 @@ const Home = () => { ); const { isLoading: isFavouritesLoading } = useGetMyFavouritesQuery(); const { isLoading: isVotesLoading } = useGetMyVotesQuery(); - - const isLoading = isImagesLoading || isFavouritesLoading || isVotesLoading; - + const isImageUploading = useAppSelector(getIsImageUploading); const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); const handleUploadButtonPress = useCallback(() => { setIsBottomSheetOpen(true); }, []); - const handleBottomSheetClose = useCallback(() => { setIsBottomSheetOpen(false); }, []); + const isLoading = isImagesLoading || isFavouritesLoading || isVotesLoading; + return ( - {match({ isLoading, images }) + {match({ isLoading, isImageUploading, images }) .with({ isLoading: true, images: P.any }, () => ( - + )) - .with({ isLoading: false, images: [] }, () => ) - .with({ isLoading: false, images: [P.any, ...P.array()] }, () => ( - + .with({ isLoading: false, isImageUploading: false, images: [] }, () => ( + )) + .with( + { + isLoading: false, + isImageUploading: true, + images: [], + }, + () => ( + + + + + ) + ) + .with( + { + isLoading: false, + isImageUploading: false, + images: [P.any, ...P.array()], + }, + () => ( + + + + ) + ) + .with( + { + isLoading: false, + isImageUploading: true, + images: [P.any, ...P.array()], + }, + () => ( + + + + + ) + ) .exhaustive()} diff --git a/apps/mobile/src/components/ActivityIndicator.tsx b/apps/mobile/src/components/ActivityIndicator.tsx new file mode 100644 index 0000000..75f1a09 --- /dev/null +++ b/apps/mobile/src/components/ActivityIndicator.tsx @@ -0,0 +1,39 @@ +import { ComponentProps } from 'react'; +import { ActivityIndicator as RNActivityIndicator, View } from 'react-native'; +import { + createStyleSheet, + UnistylesVariants, + useStyles, +} from 'react-native-unistyles'; + +export type ActivityIndicatorProps = ComponentProps< + typeof RNActivityIndicator +> & + UnistylesVariants; + +export const ActivityIndicator = ({ + expand = false, + size = 'large', +}: ActivityIndicatorProps) => { + const { styles, theme } = useStyles(stylesheet, { expand }); + return ( + + + + ); +}; + +const stylesheet = createStyleSheet((theme) => ({ + root: { + variants: { + expand: { + false: {}, + true: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + }, + }, + }, +})); diff --git a/apps/mobile/src/components/CatButton/CatButton.tsx b/apps/mobile/src/components/CatButton/CatButton.tsx index 0e4eecc..4b27246 100644 --- a/apps/mobile/src/components/CatButton/CatButton.tsx +++ b/apps/mobile/src/components/CatButton/CatButton.tsx @@ -7,18 +7,20 @@ import { GestureResponderEvent, Pressable, View } from 'react-native'; import Animated, { interpolate, useAnimatedStyle, - useSharedValue, withDelay, + useSharedValue, + withDelay, withRepeat, - withSequence, withSpring, withTiming + withSequence, + withSpring, + withTiming, } from 'react-native-reanimated'; import { useCallback, useEffect } from 'react'; type CatButtonProps = { onPress: ((event: GestureResponderEvent) => void) | null | undefined; -} - -const CatButton = ({ onPress }: CatButtonProps) => { +}; +export const CatButton = ({ onPress }: CatButtonProps) => { const xVal = useSharedValue(-1); const yVal = useSharedValue(0); const blinkVal = useSharedValue(1); @@ -28,19 +30,31 @@ const CatButton = ({ onPress }: CatButtonProps) => { 'worklet'; xVal.value = withRepeat( withSequence( - withDelay(2000 + Math.random() * 2000, withSpring(-1 + Math.random() * 2)), - withDelay(2000 + Math.random() * 3000, withSpring(0)), - withDelay(2000 + Math.random() * 2000, withSpring(-1 + Math.random() * 2)), + withDelay( + 2000 + Math.random() * 2000, + withSpring(-1 + Math.random() * 2) + ), withDelay(2000 + Math.random() * 3000, withSpring(0)), + withDelay( + 2000 + Math.random() * 2000, + withSpring(-1 + Math.random() * 2) + ), + withDelay(2000 + Math.random() * 3000, withSpring(0)) ), 0 ); yVal.value = withRepeat( withSequence( - withDelay(2000 + Math.random() * 3000, withSpring(-1 + Math.random() * 2)), - withDelay(2000 + Math.random() * 3000, withSpring(0)), - withDelay(2000 + Math.random() * 3000, withSpring(-1 + Math.random() * 2)), + withDelay( + 2000 + Math.random() * 3000, + withSpring(-1 + Math.random() * 2) + ), withDelay(2000 + Math.random() * 3000, withSpring(0)), + withDelay( + 2000 + Math.random() * 3000, + withSpring(-1 + Math.random() * 2) + ), + withDelay(2000 + Math.random() * 3000, withSpring(0)) ), 0 ); @@ -53,10 +67,10 @@ const CatButton = ({ onPress }: CatButtonProps) => { withDelay(1000, withSpring(0.9)), withTiming(0, { duration: 50 }), withTiming(1, { duration: 100 }), - withDelay(2000, withSpring(0.8)), + withDelay(2000, withSpring(0.8)) ), 0 - ) + ); }, [blinkVal, xVal, yVal]); const boop = useCallback(() => { @@ -67,17 +81,16 @@ const CatButton = ({ onPress }: CatButtonProps) => { blinkVal.value = withTiming(0.2); boopVal.value = withSequence( withTiming(1, { duration: 50 }), - withTiming(0, { duration: 50 }), + withTiming(0, { duration: 50 }) ); setTimeout(() => { idle(); if (onPress) { - // @ts-ignore - onPress(); + onPress({} as GestureResponderEvent); } }, 400); - }, [blinkVal, idle, xVal, yVal]) + }, [blinkVal, boopVal, idle, onPress, xVal, yVal]); useEffect(() => { idle(); @@ -87,9 +100,9 @@ const CatButton = ({ onPress }: CatButtonProps) => { return { transform: [ { translateY: interpolate(yVal.value, [-1, 1], [20, -20]) }, - { translateX: interpolate(xVal.value, [-1, 1], [-50, 30]) } - ] - } + { translateX: interpolate(xVal.value, [-1, 1], [-50, 30]) }, + ], + }; }); const leftEyeStyle = useAnimatedStyle(() => { @@ -97,9 +110,9 @@ const CatButton = ({ onPress }: CatButtonProps) => { transform: [ { translateY: interpolate(yVal.value, [-1, 1], [-30, -50]) }, { translateX: interpolate(xVal.value, [-1, 1], [-100, -60]) }, - { scaleY: interpolate(blinkVal.value, [0, 1], [0, 1]) } - ] - } + { scaleY: interpolate(blinkVal.value, [0, 1], [0, 1]) }, + ], + }; }); const rightEyeStyle = useAnimatedStyle(() => { @@ -107,9 +120,9 @@ const CatButton = ({ onPress }: CatButtonProps) => { transform: [ { translateY: interpolate(yVal.value, [-1, 1], [-30, -50]) }, { translateX: interpolate(xVal.value, [-1, 1], [60, 100]) }, - { scaleY: interpolate(blinkVal.value, [0, 1], [0, 1]) } - ] - } + { scaleY: interpolate(blinkVal.value, [0, 1], [0, 1]) }, + ], + }; }); const leftEarStyle = useAnimatedStyle(() => { @@ -117,9 +130,9 @@ const CatButton = ({ onPress }: CatButtonProps) => { transform: [ { translateX: -160 }, { translateY: interpolate(yVal.value, [-1, 1], [-160, -140]) }, - { scale: interpolate(xVal.value, [-1, 1], [0.95, 1.05]) } - ] - } + { scale: interpolate(xVal.value, [-1, 1], [0.95, 1.05]) }, + ], + }; }); const rightEarStyle = useAnimatedStyle(() => { @@ -127,39 +140,78 @@ const CatButton = ({ onPress }: CatButtonProps) => { transform: [ { translateX: 60 }, { translateY: interpolate(yVal.value, [-1, 1], [-160, -140]) }, - { scale: interpolate(xVal.value, [-1, 1], [1.05, 0.95]) } - ] - } + { scale: interpolate(xVal.value, [-1, 1], [1.05, 0.95]) }, + ], + }; }); return ( - - + - + - + - + - + - + - ) -} - -export default CatButton; + ); +}; diff --git a/apps/mobile/src/components/CatButton/CatEarL.tsx b/apps/mobile/src/components/CatButton/CatEarL.tsx index d97a56c..42cd994 100644 --- a/apps/mobile/src/components/CatButton/CatEarL.tsx +++ b/apps/mobile/src/components/CatButton/CatEarL.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import Svg, { SvgProps, Path } from "react-native-svg" -import { memo } from "react" +import * as React from 'react'; +import Svg, { SvgProps, Path } from 'react-native-svg'; +import { memo } from 'react'; const SvgComponent = (props: SvgProps & { secondaryColor: string }) => ( ( /> ); -const Memo = memo(SvgComponent) -export { Memo as CatEarL } +const Memo = memo(SvgComponent); +export { Memo as CatEarL }; diff --git a/apps/mobile/src/components/CatButton/CatEarR.tsx b/apps/mobile/src/components/CatButton/CatEarR.tsx index b73ce54..beb8293 100644 --- a/apps/mobile/src/components/CatButton/CatEarR.tsx +++ b/apps/mobile/src/components/CatButton/CatEarR.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import Svg, { SvgProps, Path } from "react-native-svg" -import { memo } from "react" +import * as React from 'react'; +import Svg, { SvgProps, Path } from 'react-native-svg'; +import { memo } from 'react'; const SvgComponent = (props: SvgProps & { secondaryColor: string }) => ( ( /> ); -const Memo = memo(SvgComponent) -export { Memo as CatEarR } +const Memo = memo(SvgComponent); +export { Memo as CatEarR }; diff --git a/apps/mobile/src/components/CatButton/CatEye.tsx b/apps/mobile/src/components/CatButton/CatEye.tsx index 1d167fe..5c2aacd 100644 --- a/apps/mobile/src/components/CatButton/CatEye.tsx +++ b/apps/mobile/src/components/CatButton/CatEye.tsx @@ -1,10 +1,10 @@ -import * as React from "react" -import Svg, { SvgProps, Circle } from "react-native-svg" -import { memo } from "react" +import * as React from 'react'; +import Svg, { SvgProps, Circle } from 'react-native-svg'; +import { memo } from 'react'; const SvgComponent = (props: SvgProps) => ( ); -const Memo = memo(SvgComponent) -export { Memo as CatEye } +const Memo = memo(SvgComponent); +export { Memo as CatEye }; diff --git a/apps/mobile/src/components/CatButton/CatHead.tsx b/apps/mobile/src/components/CatButton/CatHead.tsx index f2e9c3f..d019036 100644 --- a/apps/mobile/src/components/CatButton/CatHead.tsx +++ b/apps/mobile/src/components/CatButton/CatHead.tsx @@ -1,19 +1,13 @@ -import * as React from "react" -import Svg, { SvgProps, Path } from "react-native-svg" -import { memo } from "react" +import * as React from 'react'; +import Svg, { SvgProps, Path } from 'react-native-svg'; +import { memo } from 'react'; const SvgComponent = (props: SvgProps) => ( - + -) -const Memo = memo(SvgComponent) -export { Memo as CatHead } +); +const Memo = memo(SvgComponent); +export { Memo as CatHead }; diff --git a/apps/mobile/src/components/CatButton/CatNose.tsx b/apps/mobile/src/components/CatButton/CatNose.tsx index d1ceb6c..726693c 100644 --- a/apps/mobile/src/components/CatButton/CatNose.tsx +++ b/apps/mobile/src/components/CatButton/CatNose.tsx @@ -1,11 +1,10 @@ -import * as React from "react" -import Svg, { SvgProps, Path } from "react-native-svg" +import * as React from 'react'; +import Svg, { SvgProps, Path } from 'react-native-svg'; import { memo } from 'react'; import Animated from 'react-native-reanimated'; const AnimatedPath = Animated.createAnimatedComponent(Path); const SvgComponent = (props: SvgProps) => { - return ( { /> ); - -} +}; const Memo = memo(SvgComponent); -export { Memo as CatNose } +export { Memo as CatNose }; diff --git a/apps/mobile/src/components/CatButton/CatSnout.tsx b/apps/mobile/src/components/CatButton/CatSnout.tsx index 0400014..a34dc78 100644 --- a/apps/mobile/src/components/CatButton/CatSnout.tsx +++ b/apps/mobile/src/components/CatButton/CatSnout.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import Svg, { SvgProps, Path } from "react-native-svg" -import { memo } from "react" +import * as React from 'react'; +import Svg, { SvgProps, Path } from 'react-native-svg'; +import { memo } from 'react'; const SvgComponent = (props: SvgProps) => ( ( /> ); -const Memo = memo(SvgComponent) -export { Memo as CatSnout } +const Memo = memo(SvgComponent); +export { Memo as CatSnout }; diff --git a/apps/mobile/src/components/UploadButton.tsx b/apps/mobile/src/components/UploadButton.tsx index 663b8eb..e4843ea 100644 --- a/apps/mobile/src/components/UploadButton.tsx +++ b/apps/mobile/src/components/UploadButton.tsx @@ -1,7 +1,7 @@ import { ComponentProps } from 'react'; import { Pressable, StyleProp, ViewStyle } from 'react-native'; import { createStyleSheet, useStyles } from 'react-native-unistyles'; -import CatButton from './CatButton/CatButton'; +import { CatButton } from './CatButton/CatButton'; export type UploadButtonProps = ComponentProps; diff --git a/apps/mobile/src/features/CatCard/CatCardSkeleton.tsx b/apps/mobile/src/features/CatCard/CatCardSkeleton.tsx new file mode 100644 index 0000000..250246b --- /dev/null +++ b/apps/mobile/src/features/CatCard/CatCardSkeleton.tsx @@ -0,0 +1,98 @@ +import { useEffect } from 'react'; +import { createStyleSheet, useStyles } from 'react-native-unistyles'; +import FontAwesome from '@expo/vector-icons/FontAwesome'; +import Animated, { + StretchInY, + StretchOutY, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; + +export const CatCardSkeleton = () => { + const { styles } = useStyles(stylesheet); + + const sharedValue = useSharedValue(0.6); + + useEffect(() => { + 'worklet'; + sharedValue.value = withRepeat( + withSequence( + withTiming(1, { duration: 1000 }), + withTiming(0.6, { duration: 1000 }) + ), + 0 + ); + }, [sharedValue]); + + const derivedValue = useDerivedValue(() => { + return 0.6 + 1 - sharedValue.value; + }); + + const dotStyle = useAnimatedStyle(() => ({ + opacity: sharedValue.value, + })); + + const dotStyle2 = useAnimatedStyle(() => ({ + opacity: derivedValue.value, + })); + + return ( + + + + + + + + + + + + ); +}; + +const stylesheet = createStyleSheet((theme) => ({ + root: { + borderColor: theme.colors.background.$6, + borderRadius: theme.radii.$3, + borderWidth: theme.borderWidths.$1, + width: theme.space.full, + }, + base: { + borderBottomLeftRadius: theme.radii.$3, + borderBottomRightRadius: theme.radii.$3, + width: theme.space.full, + height: 80, + backgroundColor: theme.colors.background.$5, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + }, + image: { + borderTopLeftRadius: theme.radii.$3, + borderTopRightRadius: theme.radii.$3, + width: theme.space.full, + height: 180, + backgroundColor: '#c9c8c8', + justifyContent: 'flex-start', + alignItems: 'flex-end', + }, +})); diff --git a/apps/mobile/src/features/HomePage/ImageListWrapper.tsx b/apps/mobile/src/features/HomePage/ImageListWrapper.tsx new file mode 100644 index 0000000..85227b1 --- /dev/null +++ b/apps/mobile/src/features/HomePage/ImageListWrapper.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { View } from 'react-native'; +import { createStyleSheet, useStyles } from 'react-native-unistyles'; + +export type ImageListWrapperProps = { + children?: ReactNode; +}; + +export const ImageListWrapper = ({ children }: ImageListWrapperProps) => { + const { styles } = useStyles(stylesheet); + + return {children}; +}; + +const stylesheet = createStyleSheet((theme) => ({ + root: { + rowGap: theme.space.$2, + }, +})); diff --git a/apps/mobile/src/store/selectors/getIsImageUploading.ts b/apps/mobile/src/store/selectors/getIsImageUploading.ts new file mode 100644 index 0000000..05837f1 --- /dev/null +++ b/apps/mobile/src/store/selectors/getIsImageUploading.ts @@ -0,0 +1,4 @@ +import { RootState } from '../store'; + +export const getIsImageUploading = (state: RootState) => + state.imageActivity.isImageUploading; diff --git a/apps/mobile/src/store/slices/ImageActivitySlice.ts b/apps/mobile/src/store/slices/ImageActivitySlice.ts new file mode 100644 index 0000000..415713d --- /dev/null +++ b/apps/mobile/src/store/slices/ImageActivitySlice.ts @@ -0,0 +1,33 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { CatApi } from '../services/CatApi'; + +const initialState = { + isImageUploading: false, +}; + +export const ImageActivitySlice = createSlice({ + name: 'imageActivity', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addMatcher( + CatApi.endpoints.uploadImage.matchPending, + (state, { payload }) => { + state.isImageUploading = true; + } + ) + .addMatcher( + CatApi.endpoints.uploadImage.matchFulfilled, + (state, { payload }) => { + state.isImageUploading = false; + } + ) + .addMatcher( + CatApi.endpoints.uploadImage.matchRejected, + (state, { payload }) => { + state.isImageUploading = false; + } + ); + }, +}); diff --git a/apps/mobile/src/store/store.ts b/apps/mobile/src/store/store.ts index 0a62bc4..fd313c3 100644 --- a/apps/mobile/src/store/store.ts +++ b/apps/mobile/src/store/store.ts @@ -4,6 +4,7 @@ import devToolsEnhancer from 'redux-devtools-expo-dev-plugin'; import { ToastMiddleware } from './middleware/ToastMiddleware'; import { CatApi } from './services/CatApi'; +import { ImageActivitySlice } from './slices/ImageActivitySlice'; export const store = configureStore({ enhancers: (getDefaultEnhancers) => @@ -19,6 +20,8 @@ export const store = configureStore({ reducer: { // RTK-Query reducers [CatApi.reducerPath]: CatApi.reducer, + // RTK slices + [ImageActivitySlice.name]: ImageActivitySlice.reducer, }, });