From 9d29735c569a8c55b8d91d0998d495756ce6bea6 Mon Sep 17 00:00:00 2001 From: Carlos Mesquita <52337966+carlos3g@users.noreply.github.com> Date: Sun, 5 Jan 2025 23:35:33 -0300 Subject: [PATCH] feat(app): [WIP] manage tags --- packages/app/App.tsx | 5 +- packages/app/package.json | 2 + .../quote/contracts/quote-service.contract.ts | 1 - .../src/features/tag/components/tag-card.tsx | 58 +++++ .../tag/contracts/tag-service.contract.ts | 21 ++ .../app/src/features/tag/services/index.ts | 7 + .../src/features/tag/services/tag.service.ts | 22 ++ .../app/src/features/tag/validations/index.ts | 5 + packages/app/src/navigation/app.navigator.tsx | 2 +- packages/app/src/screens/app/home/index.tsx | 2 +- .../app/src/screens/app/manage-tags/index.tsx | 229 +++++++++++++++++- packages/app/src/types/entities.ts | 10 + packages/app/yarn.lock | 22 +- 13 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/features/tag/components/tag-card.tsx create mode 100644 packages/app/src/features/tag/contracts/tag-service.contract.ts create mode 100644 packages/app/src/features/tag/services/index.ts create mode 100644 packages/app/src/features/tag/services/tag.service.ts create mode 100644 packages/app/src/features/tag/validations/index.ts diff --git a/packages/app/App.tsx b/packages/app/App.tsx index c0fcb32..c807387 100644 --- a/packages/app/App.tsx +++ b/packages/app/App.tsx @@ -4,6 +4,7 @@ import { gestureHandlerRootHOC } from 'react-native-gesture-handler'; import './global.css'; import { useReactQueryDevTools } from '@dev-plugins/react-query'; +import { Host } from 'react-native-portalize'; import { Poppins_100Thin, Poppins_100Thin_Italic, @@ -87,7 +88,9 @@ const App = () => { - + + + diff --git a/packages/app/package.json b/packages/app/package.json index a1d73dd..46203ec 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -22,6 +22,7 @@ "@dev-plugins/react-query": "^0.1.0", "@expo-google-fonts/poppins": "^0.2.3", "@expo/vector-icons": "^14.0.4", + "@gorhom/bottom-sheet": "^5.0.6", "@hookform/resolvers": "^3.9.0", "@react-native-community/netinfo": "11.4.1", "@react-native-masked-view/masked-view": "0.3.2", @@ -55,6 +56,7 @@ "react-native": "0.76.5", "react-native-gesture-handler": "~2.20.2", "react-native-mmkv": "2.12.2", + "react-native-portalize": "^1.0.7", "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", diff --git a/packages/app/src/features/quote/contracts/quote-service.contract.ts b/packages/app/src/features/quote/contracts/quote-service.contract.ts index 4b2af0c..fb149ee 100644 --- a/packages/app/src/features/quote/contracts/quote-service.contract.ts +++ b/packages/app/src/features/quote/contracts/quote-service.contract.ts @@ -7,7 +7,6 @@ export type GetQuoteOutput = Quote; export type ListQuotesPayload = { paginate?: Paginate; - page?: number; }; export type ListQuotesOutput = ApiPaginatedResult; diff --git a/packages/app/src/features/tag/components/tag-card.tsx b/packages/app/src/features/tag/components/tag-card.tsx new file mode 100644 index 0000000..03954b1 --- /dev/null +++ b/packages/app/src/features/tag/components/tag-card.tsx @@ -0,0 +1,58 @@ +import { Ionicons as ExpoIonicons } from '@expo/vector-icons'; +import { cssInterop } from 'nativewind'; +import ContentLoader, { Rect } from 'react-content-loader/native'; +import { Dimensions, View } from 'react-native'; +import type { Tag } from '@/types/entities'; +import { Text } from '@/shared/components/ui/text'; + +const { width: wWidth } = Dimensions.get('window'); + +const Ionicons = cssInterop(ExpoIonicons, { + className: { + target: 'style', + nativeStyleToProp: { + color: 'color', + }, + }, +}); + +interface TagCardProps { + data: Tag; +} + +export const TagCard: React.FC = (props) => { + const { data } = props; + + return ( + + + + + {data.title} + + + + 23 quotes + + + ); +}; + +export interface TagCardSkeletonProps {} + +export const TagCardSkeleton: React.FC = () => { + return ( + + + + + + ); +}; diff --git a/packages/app/src/features/tag/contracts/tag-service.contract.ts b/packages/app/src/features/tag/contracts/tag-service.contract.ts new file mode 100644 index 0000000..30fa2a5 --- /dev/null +++ b/packages/app/src/features/tag/contracts/tag-service.contract.ts @@ -0,0 +1,21 @@ +import type { ApiPaginatedResult, Paginate } from '@/types/api'; +import type { Tag } from '@/types/entities'; + +export type CreateTagPayload = { + title: string; +}; + +export type CreateTagOutput = Tag; + +export type ListTagsPayload = { + paginate?: Paginate; + page?: number; +}; + +export type ListTagsOutput = ApiPaginatedResult; + +export abstract class TagServiceContract { + public abstract list(payload: ListTagsPayload): Promise; + + public abstract create(payload: CreateTagPayload): Promise; +} diff --git a/packages/app/src/features/tag/services/index.ts b/packages/app/src/features/tag/services/index.ts new file mode 100644 index 0000000..664692b --- /dev/null +++ b/packages/app/src/features/tag/services/index.ts @@ -0,0 +1,7 @@ +import type { TagServiceContract } from '@/features/tag/contracts/tag-service.contract'; +import { TagService } from '@/features/tag/services/tag.service'; +import { httpClientService } from '@/shared/services'; + +const tagService: TagServiceContract = new TagService(httpClientService); + +export { tagService }; diff --git a/packages/app/src/features/tag/services/tag.service.ts b/packages/app/src/features/tag/services/tag.service.ts new file mode 100644 index 0000000..d1ce4b4 --- /dev/null +++ b/packages/app/src/features/tag/services/tag.service.ts @@ -0,0 +1,22 @@ +import type { + CreateTagOutput, + CreateTagPayload, + ListTagsOutput, + ListTagsPayload, + TagServiceContract, +} from '@/features/tag/contracts/tag-service.contract'; +import type { HttpClientServiceContract } from '@/shared/contracts/http-client-service.contract'; + +export class TagService implements TagServiceContract { + constructor(private readonly httpClientService: HttpClientServiceContract) {} + + public list(payload: ListTagsPayload): Promise { + return this.httpClientService.get('/tags', { + ...payload, + }); + } + + public async create(payload: CreateTagPayload): Promise { + return this.httpClientService.post('/tags', payload); + } +} diff --git a/packages/app/src/features/tag/validations/index.ts b/packages/app/src/features/tag/validations/index.ts new file mode 100644 index 0000000..32a7c80 --- /dev/null +++ b/packages/app/src/features/tag/validations/index.ts @@ -0,0 +1,5 @@ +import * as z from 'zod'; + +export const createTagFormSchema = z.object({ + title: z.string(), +}); diff --git a/packages/app/src/navigation/app.navigator.tsx b/packages/app/src/navigation/app.navigator.tsx index 7ee3b59..edef2e8 100644 --- a/packages/app/src/navigation/app.navigator.tsx +++ b/packages/app/src/navigation/app.navigator.tsx @@ -29,7 +29,7 @@ const options: { [key in keyof AppTabParams]: TabBarItemOptions } = { ManageTagsScreen: { onBlurIcon: 'pricetags-outline', onFocusIcon: 'pricetags', - title: 'Manage Tags', + title: 'Gerenciar tags', tabBarButtonTestID: 'manage-tags-tab-button', }, SettingsNavigator: { diff --git a/packages/app/src/screens/app/home/index.tsx b/packages/app/src/screens/app/home/index.tsx index 27846f6..e9b0fb4 100644 --- a/packages/app/src/screens/app/home/index.tsx +++ b/packages/app/src/screens/app/home/index.tsx @@ -21,7 +21,7 @@ export const HomeScreen: React.FC = () => { const { isRefetching, refetch, hasNextPage, fetchNextPage, data, isLoading } = useInfiniteQuery({ queryKey: ['quotes'], queryFn: ({ pageParam }) => quoteService.list({ paginate: { page: pageParam as number } }), - initialPageParam: 0, + initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.meta.next, getPreviousPageParam: (lastPage) => lastPage.meta.prev, }); diff --git a/packages/app/src/screens/app/manage-tags/index.tsx b/packages/app/src/screens/app/manage-tags/index.tsx index 6ad9b75..9ddce04 100644 --- a/packages/app/src/screens/app/manage-tags/index.tsx +++ b/packages/app/src/screens/app/manage-tags/index.tsx @@ -1,12 +1,235 @@ -import { View } from 'react-native'; +import { Ionicons as ExpoIonicons } from '@expo/vector-icons'; +import type { BottomSheetFooterProps } from '@gorhom/bottom-sheet'; +import RNBottomSheet, { BottomSheetView, BottomSheetFooter as RNBottomSheetFooter } from '@gorhom/bottom-sheet'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { ListRenderItem } from '@shopify/flash-list'; +import { FlashList } from '@shopify/flash-list'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { cssInterop } from 'nativewind'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import type { PressableProps } from 'react-native'; +import { Pressable, RefreshControl, StyleSheet, View } from 'react-native'; +import { Portal } from 'react-native-portalize'; +import type { AnimatedProps } from 'react-native-reanimated'; +import Animated, { + interpolate, + SlideInDown, + useAnimatedStyle, + useSharedValue, + withSequence, + withSpring, +} from 'react-native-reanimated'; +import { toast } from 'sonner-native'; +import type { z } from 'zod'; +import type { HttpError } from '@/types/http'; +import type { Tag } from '@/types/entities'; +import type { ApiResponseError } from '@/types/api'; +import { useAppSafeArea } from '@/shared/hooks/use-app-safe-area'; import { Text } from '@/shared/components/ui/text'; +import { Button } from '@/shared/components/ui/button'; +import { ControlledTextInput } from '@/shared/components/form/controlled-text-input'; +import { createTagFormSchema } from '@/features/tag/validations'; +import { tagService } from '@/features/tag/services'; +import type { CreateTagOutput, CreateTagPayload, ListTagsOutput } from '@/features/tag/contracts/tag-service.contract'; +import { TagCard, TagCardSkeleton } from '@/features/tag/components/tag-card'; + +const Ionicons = cssInterop(ExpoIonicons, { + className: { + target: 'style', + nativeStyleToProp: { + color: 'color', + }, + }, +}); + +const BottomSheet = cssInterop(RNBottomSheet, { + className: { + target: 'style', + }, + handleClassName: { + target: 'handleStyle', + }, + containerClassName: { + target: 'containerStyle', + }, + backgroundClassName: { + target: 'backgroundStyle', + }, + handleIndicatorClassName: { + target: 'handleIndicatorStyle', + }, +}); + +const BottomSheetFooter = cssInterop(RNBottomSheetFooter, { + className: { + target: 'style', + }, +}); + +const AnimatedPressable = Animated.createAnimatedComponent( + React.forwardRef((props: PressableProps, ref: React.LegacyRef) => ) +); + +interface FabProps extends Omit, 'onPress'> { + onPress?: PressableProps['onPress']; +} + +export const Fab: React.FC = (props) => { + const { onPress, ...rest } = props; + + const progress = useSharedValue(0); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: interpolate(progress.value, [0, 1], [1, 1.1]) }], + })); + + return ( + { + progress.value = withSequence(withSpring(1, { duration: 300 }), withSpring(0, { duration: 200 })); + onPress?.(e); + }} + entering={SlideInDown.delay(200).duration(1000)} + className="absolute bottom-8 right-6" + {...rest} + style={animatedStyle} + > + + + + + ); +}; + +type CreateTagFormData = z.infer; + +interface CreateTagBottomSheetProps {} + +export const CreateTagBottomSheet = React.forwardRef((props, ref) => { + const { bottom } = useAppSafeArea(); + + const form = useForm({ + resolver: zodResolver(createTagFormSchema), + }); + + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation< + CreateTagOutput, + HttpError>, + CreateTagPayload + >({ + mutationFn: async (payload) => tagService.create(payload), + onSuccess: () => { + toast.success('Tag criada com sucesso!'); + form.reset(); + void queryClient.invalidateQueries({ queryKey: ['tags'] }); + }, + onError: () => { + toast.error('Tivemos um erro :/', { + description: 'Tente novamente', + }); + }, + }); + + const onSubmit = form.handleSubmit((data: CreateTagFormData) => { + mutate(data); + }); + + const renderFooter = useCallback( + (footerProps: BottomSheetFooterProps) => ( + +