Initial commit

This commit is contained in:
2025-10-05 13:55:28 +02:00
commit 68d521677a
767 changed files with 46947 additions and 0 deletions
@@ -0,0 +1,20 @@
import { Image } from "tamagui";
type AppLogoProps = {
width?: number;
height?: number;
};
export const AppIcon = (props: AppLogoProps) => {
const { width = 80, height = 80 } = props;
return (
<Image
source={require("@/assets/images/logo.png")}
width={height}
height={width}
objectFit="contain"
marginBottom="$2"
/>
);
};
@@ -0,0 +1,11 @@
import { ActivityIndicator } from "react-native";
import { View } from "tamagui";
import { Caption } from "@/ui/components/typography";
export const LoadingView = () => (
<View flex={1} padding="$4" backgroundColor="$background" alignItems="center" justifyContent="center" gap="$4">
<ActivityIndicator />
<Caption>Chargement...</Caption>
</View>
);
@@ -0,0 +1,13 @@
import React from "react";
import { Caption } from "@/ui/components/typography";
type ArticleCategoryPillProps = {
category: string;
};
export const ArticleCategoryPill = (props: ArticleCategoryPillProps) => {
const { category } = props;
return <Caption>{category}</Caption>;
};
@@ -0,0 +1,19 @@
import { GetProps, Image, styled } from "tamagui";
const StyledImage = styled(Image, {
borderRadius: "$4",
backgroundColor: "$gray3",
objectFit: "cover",
});
type ArticleCoverImageProps = GetProps<typeof StyledImage> & {
uri: string;
width: string | number;
height: number;
};
export const ArticleCoverImage = (props: ArticleCoverImageProps) => {
const { width, height, uri, ...rest } = props;
return <StyledImage source={{ uri, cache: "force-cache" }} width={width} height={height} {...rest} />;
};
@@ -0,0 +1,115 @@
import React, { useCallback } from "react";
import { ActivityIndicator, Dimensions, FlatList, FlatListProps } from "react-native";
import { View, XStack, YStack } from "tamagui";
import { ArticleOverview } from "@/api/schema/feed-management/article";
import { ArticleMagazineCard } from "@/ui/components/content/article/ArticleMagazineCard";
import { ArticleOverviewCard } from "@/ui/components/content/article/ArticleOverviewCard";
import { ArticleTextOnlyCard } from "@/ui/components/content/article/ArticleTextOnlyCard";
import { Text } from "@/ui/components/typography";
const { width: screenWidth } = Dimensions.get("window");
const HorizontalSeparator = () => <XStack width="$1" />;
const VerticalSeparator = () => <YStack height="$1" />;
const LoadingIndicator = () => (
<>
<YStack height="$1" />
<ActivityIndicator />
<YStack height="$1" />
</>
);
export type ArticleListDisplayMode = "card" | "magazine" | "text-only";
type ArticleListProps = Omit<FlatListProps<ArticleOverview>, "renderItem"> & {
data: ArticleOverview[];
horizontal?: boolean;
infiniteScroll?: boolean;
displayMode?: ArticleListDisplayMode;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
};
type ArticleListComponent = React.FC<ArticleListProps> & {
HorizontalSeparator: typeof HorizontalSeparator;
VerticalSeparator: typeof VerticalSeparator;
LoadingIndicator: typeof LoadingIndicator;
};
const keyExtractor = (item: ArticleOverview) => item.id;
const selectDisplayComponent = (mode: ArticleListDisplayMode) => {
switch (mode) {
case "card":
return ArticleOverviewCard;
case "magazine":
return ArticleMagazineCard;
case "text-only":
return ArticleTextOnlyCard;
default:
throw new Error(`Unknown display mode: ${mode}`);
}
};
const ArticleList: ArticleListComponent = (props: ArticleListProps) => {
const {
data,
displayMode = "magazine",
horizontal = false,
infiniteScroll = false,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
refreshing,
...rest
} = props;
const renderItem = useCallback(
({ item }: { item: ArticleOverview }) => {
const itemWidth = horizontal ? screenWidth * 0.7 : undefined;
const DisplayComponent = selectDisplayComponent(displayMode);
return (
<View style={{ width: itemWidth }}>
<DisplayComponent data={item} />
</View>
);
},
[horizontal, displayMode]
);
const handleOnEndReached = useCallback(async () => {
if (infiniteScroll && hasNextPage && !isFetchingNextPage && fetchNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage, infiniteScroll]);
return (
<FlatList
{...rest}
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
ItemSeparatorComponent={horizontal ? HorizontalSeparator : VerticalSeparator}
horizontal={horizontal}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
onEndReachedThreshold={0.5}
removeClippedSubviews={true}
onEndReached={handleOnEndReached}
refreshing={refreshing}
ListFooterComponent={infiniteScroll ? LoadingIndicator : undefined}
ListEmptyComponent={() => <Text>Pas darticles disponibles pour le moment.</Text>}
/>
);
};
ArticleList.HorizontalSeparator = HorizontalSeparator;
ArticleList.VerticalSeparator = VerticalSeparator;
ArticleList.LoadingIndicator = LoadingIndicator;
export { ArticleList };
@@ -0,0 +1,45 @@
import React from "react";
import { Link } from "expo-router";
import { Card, XStack, YStack } from "tamagui";
import { ArticleOverview } from "@/api/schema/feed-management/article";
import { useRelativeTime } from "@/hooks/use-relative-time";
import { ArticleCoverImage } from "@/ui/components/content/article/ArticleCoverImage";
import { SourceReferencePill } from "@/ui/components/content/source/SourceReferencePill";
import { Caption, Text } from "@/ui/components/typography";
type ArticleMagazineCardProps = {
data: ArticleOverview;
};
export const ArticleMagazineCard = (props: ArticleMagazineCardProps) => {
const { data } = props;
const relativeTime = useRelativeTime(data.publishedAt);
return (
<Card width="100%" backgroundColor="transparent" borderRadius="$4" padding={0}>
<Link href={`/(authed)/(tabs)/articles/${data.id}`}>
<XStack flexDirection="row" gap="$3" alignItems="center">
<YStack flex={1} gap="$2">
<Text numberOfLines={2} fontWeight="600" fontSize="$5">
{data.title}
</Text>
<Text size="$3" numberOfLines={2} color="$colorHover">
{data.excerpt}
</Text>
</YStack>
{data.image && <ArticleCoverImage uri={data.image} width={120} height={90} />}
</XStack>
</Link>
<YStack marginTop="$3">
<XStack justifyContent="space-between" alignItems="center">
<SourceReferencePill data={data.source} />
<Caption>{relativeTime}</Caption>
</XStack>
</YStack>
</Card>
);
};
@@ -0,0 +1,44 @@
import React from "react";
import { Link } from "expo-router";
import { Card, XStack, YStack } from "tamagui";
import { ArticleOverview } from "@/api/schema/feed-management/article";
import { useRelativeTime } from "@/hooks/use-relative-time";
import { ArticleCoverImage } from "@/ui/components/content/article/ArticleCoverImage";
import { SourceReferencePill } from "@/ui/components/content/source/SourceReferencePill";
import { Caption, Text } from "@/ui/components/typography";
type ArticleOverviewCardProps = {
data: ArticleOverview;
};
export const ArticleOverviewCard = (props: ArticleOverviewCardProps) => {
const { data } = props;
const relativeTime = useRelativeTime(data.publishedAt);
return (
<Card backgroundColor="transparent">
<Link href={`/(authed)/(tabs)/articles/${data.id}`} asChild>
<>
{data.image && <ArticleCoverImage uri={data.image} width="100%" height={200} />}
<YStack marginTop="$2" gap="$2">
<Text numberOfLines={2} fontWeight="600" fontSize="$5">
{data.title}
</Text>
<Text size="$3" numberOfLines={2}>
{data.excerpt}
</Text>
</YStack>
</>
</Link>
<YStack marginTop="$2">
<XStack justifyContent="space-between" alignItems="center">
<SourceReferencePill data={data.source} />
<Caption>{relativeTime}</Caption>
</XStack>
</YStack>
</Card>
);
};
@@ -0,0 +1,129 @@
import React, { useCallback } from "react";
import ContentLoader, { Circle, Rect } from "react-content-loader/native";
import { Dimensions, FlatList } from "react-native";
import { View } from "tamagui";
import { ArticleList, ArticleListDisplayMode } from "@/ui/components/content/article/ArticleList";
const { width: screenWidth } = Dimensions.get("window");
const data: number[] = new Array(5).fill(0);
type ArticleSkeletonListProps = {
horizontal?: boolean;
displayMode?: ArticleListDisplayMode;
};
const OverviewCardSkeleton = (props: any) => (
<ContentLoader
speed={1}
interval={0.3}
backgroundColor="#D4D5D8"
foregroundColor="white"
height={350}
animate={true}
width="100%"
{...props}
>
<Rect x="0" y="0" rx="8" ry="8" width="100%" height="200" />
<Rect x="0" y="216" rx="4" ry="4" width="80%" height="10" />
<Rect x="0" y="232" rx="4" ry="4" width="100%" height="10" />
<Rect x="0" y="256" rx="4" ry="4" width="100%" height="10" />
<Rect x="0" y="272" rx="4" ry="4" width="60%" height="10" />
<Circle cx="10" cy="310" r="9" />
<Rect x="30" y="305" rx="4" ry="4" width="15%" height="10" />
<Rect x="215" y="305" rx="4" ry="4" width="20%" height="10" />
</ContentLoader>
);
const MagazineCardSkeleton = (props: any) => (
<ContentLoader
speed={1.5}
backgroundColor="#D4D5D8"
foregroundColor="white"
height={140}
animate={true}
width="100%"
{...props}
>
<Rect x="235" y="0" rx="8" ry="8" width="120" height="90" />
<Rect x="0" y="0" rx="4" ry="4" width="54%" height="10" />
<Rect x="0" y="16" rx="4" ry="4" width="56%" height="10" />
<Rect x="0" y="40" rx="4" ry="4" width="55%" height="10" />
<Rect x="0" y="56" rx="4" ry="4" width="55%" height="10" />
<Rect x="0" y="72" rx="4" ry="4" width="55%" height="10" />
<Circle cx="10" cy="110" r="9" />
<Rect x="30" y="105" rx="4" ry="4" width="15%" height="10" />
<Rect x="315" y="105" rx="4" ry="4" width="40" height="10" />
</ContentLoader>
);
const TextOnlyCardSkeleton = (props: any) => (
<ContentLoader
speed={1.5}
backgroundColor="#D4D5D8"
foregroundColor="white"
height={150}
animate={true}
width="100%"
{...props}
>
<Rect x="0" y="16" rx="4" ry="4" width="80%" height="10" />
<Rect x="0" y="32" rx="4" ry="4" width="100%" height="10" />
<Rect x="0" y="56" rx="4" ry="4" width="100%" height="10" />
<Rect x="0" y="72" rx="4" ry="4" width="60%" height="10" />
<Circle cx="10" cy="110" r="9" />
<Rect x="30" y="105" rx="4" ry="4" width="15%" height="10" />
<Rect x="215" y="105" rx="4" ry="4" width="20%" height="10" />
</ContentLoader>
);
const keyExtractor = (_: number, index: number) => index.toString();
const selectSkeletonComponent = (displayMode: ArticleListDisplayMode) => {
switch (displayMode) {
case "magazine":
return MagazineCardSkeleton;
case "text-only":
return TextOnlyCardSkeleton;
default:
return OverviewCardSkeleton;
}
};
export const ArticleSkeletonList = (props: ArticleSkeletonListProps) => {
const { horizontal = false, displayMode = "magazine" } = props;
const ItemSeparator = horizontal ? ArticleList.HorizontalSeparator : ArticleList.VerticalSeparator;
const renderItem = useCallback(() => {
const itemWidth = horizontal ? screenWidth * 0.7 : screenWidth;
const SkeletonComponent = selectSkeletonComponent(displayMode);
return (
<View style={{ width: itemWidth }}>
<SkeletonComponent />
</View>
);
}, [horizontal, displayMode]);
return (
<FlatList
data={data}
scrollEnabled={false}
renderItem={renderItem}
keyExtractor={keyExtractor}
ItemSeparatorComponent={ItemSeparator}
horizontal={horizontal}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 0 }}
removeClippedSubviews={true}
/>
);
};
@@ -0,0 +1,42 @@
import React from "react";
import { Link } from "expo-router";
import { Card, XStack, YStack } from "tamagui";
import { ArticleOverview } from "@/api/schema/feed-management/article";
import { useRelativeTime } from "@/hooks/use-relative-time";
import { SourceReferencePill } from "@/ui/components/content/source/SourceReferencePill";
import { Caption, Text } from "@/ui/components/typography";
type ArticleTextOnlyCardProps = {
data: ArticleOverview;
};
export const ArticleTextOnlyCard = (props: ArticleTextOnlyCardProps) => {
const { data } = props;
const relativeTime = useRelativeTime(data.publishedAt);
return (
<Card width="100%" backgroundColor="transparent" borderRadius="$4" padding={0}>
<Link href={`/(authed)/(tabs)/articles/${data.id}`}>
<XStack flexDirection="row" gap="$3" alignItems="center">
<YStack flex={1} gap="$2">
<Text numberOfLines={2} fontWeight="600" fontSize="$5">
{data.title}
</Text>
<Text size="$3" numberOfLines={2} color="$colorHover">
{data.excerpt}
</Text>
</YStack>
</XStack>
</Link>
<YStack marginTop="$3">
<XStack justifyContent="space-between" alignItems="center">
<SourceReferencePill data={data.source} />
<Caption>{relativeTime}</Caption>
</XStack>
</YStack>
</Card>
);
};
@@ -0,0 +1,7 @@
export { ArticleCategoryPill } from "@/ui/components/content/article/ArticleCategoryPill";
export { ArticleCoverImage } from "@/ui/components/content/article/ArticleCoverImage";
export { ArticleList } from "@/ui/components/content/article/ArticleList";
export { ArticleSkeletonList } from "@/ui/components/content/article/ArticleSkeleton";
export { ArticleMagazineCard } from "@/ui/components/content/article/ArticleMagazineCard";
export { ArticleOverviewCard } from "@/ui/components/content/article/ArticleOverviewCard";
export { ArticleTextOnlyCard } from "@/ui/components/content/article/ArticleTextOnlyCard";
@@ -0,0 +1,48 @@
import React from "react";
import { Link } from "expo-router";
import { Card, XStack, YStack } from "tamagui";
import { Bookmark } from "@/api/schema/feed-management/bookmark";
import { useRelativeTime } from "@/hooks/use-relative-time";
import { Caption, Text } from "@/ui/components/typography";
type BookmarkCardProps = {
data: Bookmark;
};
export const BookmarkCard = (props: BookmarkCardProps) => {
const { data } = props;
const relativeTime = useRelativeTime(data.createdAt);
return (
<Card width="100%" backgroundColor="$gray7" borderRadius="$4" padding="$4">
<XStack gap="$4" justifyContent="space-between">
<YStack>
<XStack flexDirection="row" gap="$3" alignItems="center">
<Link href={`/(authed)/(tabs)/bookmarks/${data.id}`}>
<YStack flex={1} gap="$2">
<Text numberOfLines={2} fontWeight="600" fontSize="$5">
{data.name}
</Text>
{data.description && (
<Text size="$3" numberOfLines={2} color="$colorHover">
{data.description}
</Text>
)}
</YStack>
</Link>
</XStack>
<YStack marginTop="$3">
<XStack justifyContent="space-between" alignItems="center">
<Caption>{data.isPublic}</Caption>
<Caption>{data.articlesCount} articles</Caption>
<Caption>{relativeTime}</Caption>
</XStack>
</YStack>
</YStack>
</XStack>
</Card>
);
};
@@ -0,0 +1,17 @@
import { YStack } from "tamagui";
import BookmarkIllustration from "@/assets/illustrations/BookmarkIllustration";
import { CreateBookmarkSheet } from "@/ui/components/content/bookmark/CreateBookmarkSheet";
import { Heading, Text } from "@/ui/components/typography";
export const BookmarkEmptyState = () => {
return (
<YStack flex={1} alignItems="center" justifyContent="center" gap="$2">
<BookmarkIllustration width={250} height={250} />
<Heading alignSelf="center">Empty Bookmarks</Heading>
<Text textAlign="center">Create a bookmark to save your favorite articles and access them later.</Text>
<CreateBookmarkSheet />
</YStack>
);
};
@@ -0,0 +1,70 @@
import React, { useCallback } from "react";
import { ActivityIndicator, FlatList, FlatListProps } from "react-native";
import { YStack } from "tamagui";
import { Bookmark } from "@/api/schema/feed-management/bookmark";
import { BookmarkCard } from "@/ui/components/content/bookmark/BookmarkCard";
import { BookmarkEmptyState } from "@/ui/components/content/bookmark/BookmarkEmptyState";
const VerticalSeparator = () => <YStack height="$0.75" />;
const LoadingIndicator = () => (
<>
<YStack height="$1" />
<ActivityIndicator />
<YStack height="$1" />
</>
);
type BookmarkListProps = Omit<FlatListProps<Bookmark>, "renderItem"> & {
data: Bookmark[];
infiniteScroll?: boolean;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
};
type BookmarkListComponent = React.FC<BookmarkListProps> & {
VerticalSeparator: typeof VerticalSeparator;
LoadingIndicator: typeof LoadingIndicator;
};
const keyExtractor = (item: Bookmark) => item.id;
const renderItem = ({ item }: { item: Bookmark }) => {
return <BookmarkCard data={item} />;
};
const BookmarkList: BookmarkListComponent = (props: BookmarkListProps) => {
const { data, infiniteScroll = false, hasNextPage, isFetchingNextPage, fetchNextPage, refreshing, ...rest } = props;
const handleOnEndReached = useCallback(async () => {
if (infiniteScroll && hasNextPage && !isFetchingNextPage && fetchNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage, infiniteScroll]);
return (
<FlatList
{...rest}
data={data}
renderItem={renderItem}
onEndReached={handleOnEndReached}
keyExtractor={keyExtractor}
ItemSeparatorComponent={VerticalSeparator}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
onEndReachedThreshold={0.5}
removeClippedSubviews={true}
refreshing={refreshing}
ListEmptyComponent={<BookmarkEmptyState />}
ListFooterComponent={infiniteScroll && refreshing ? LoadingIndicator : undefined}
/>
);
};
BookmarkList.VerticalSeparator = VerticalSeparator;
BookmarkList.LoadingIndicator = LoadingIndicator;
export { BookmarkList };
@@ -0,0 +1,104 @@
import { useState } from "react";
import { joiResolver } from "@hookform/resolvers/joi";
import { Sheet } from "@tamagui/sheet";
import { useForm } from "react-hook-form";
import Toast from "react-native-toast-message";
import { Button, YStack } from "tamagui";
import { useCreateBookmark } from "@/api/request/feed-management/bookmark";
import { BookmarkPayload, BookmarkPayloadSchema } from "@/api/schema/feed-management/bookmark";
import { ErrorResponse, safeMessage } from "@/api/shared";
import { FormSwitch, FormTextArea, FormTextInput } from "@/ui/components/controls/forms";
import { SubmitButton } from "@/ui/components/controls/SubmitButton";
export const CreateBookmarkSheet = () => {
const { mutate, isPending } = useCreateBookmark();
const [open, setOpen] = useState(false);
const { control, handleSubmit, formState } = useForm<BookmarkPayload>({
resolver: joiResolver(BookmarkPayloadSchema),
});
const onSubmit = (data: BookmarkPayload) => {
mutate(data, {
onSuccess: () => {
Toast.show({
text1: "Félicitations !",
text2: "Votre signet a été créé avec succès.",
type: "success",
});
setOpen(false);
},
onError: (error: ErrorResponse) => {
Toast.show({
text1: "Erreur",
text2: safeMessage(error),
type: "error",
});
},
});
};
return (
<YStack alignItems="center" justifyContent="center">
<Button onPress={() => setOpen(true)}>Ajouter un signet</Button>
<Sheet
modal={true}
open={open}
onOpenChange={setOpen}
snapPointsMode="percent"
snapPoints={[65, 90]}
dismissOnOverlayPress={true}
dismissOnSnapToBottom={true}
animation="medium"
>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Sheet.Frame
flex={1}
backgroundColor="$background"
padding="$4"
gap="$4"
alignItems="center"
justifyContent="flex-start"
>
<YStack width="100%">
<Sheet.Handle theme="accent" />
<FormTextInput
name="name"
control={control}
label="Name"
caption="Enter a name for your bookmark."
placeholder="My awesome bookmark"
/>
<FormTextArea
name="description"
control={control}
caption="Describe your bookmark for easy retrieval."
label="Description"
placeholder="A brief description..."
/>
<FormSwitch
name="isPublic"
control={control}
label="Public"
description="A public bookmark is visible and accessible to other users"
/>
</YStack>
<SubmitButton
label="Créer le signet"
onPress={handleSubmit(onSubmit)}
isPending={isPending}
isValid={formState.isValid}
/>
</Sheet.Frame>
</Sheet>
</YStack>
);
};
@@ -0,0 +1,115 @@
import { useEffect, useState } from "react";
import { joiResolver } from "@hookform/resolvers/joi";
import { Sheet } from "@tamagui/sheet";
import { useForm } from "react-hook-form";
import Toast from "react-native-toast-message";
import { Button, YStack } from "tamagui";
import { useUpdateBookmark } from "@/api/request/feed-management/bookmark";
import { Bookmark, BookmarkPayload, BookmarkPayloadSchema } from "@/api/schema/feed-management/bookmark";
import { ErrorResponse, safeMessage } from "@/api/shared";
import { FormSwitch } from "@/ui/components/controls/forms/Switch";
import { FormTextArea } from "@/ui/components/controls/forms/TextArea";
import { FormTextInput } from "@/ui/components/controls/forms/TextInput";
import { SubmitButton } from "@/ui/components/controls/SubmitButton";
type UpdateBookmarkSheetProps = {
data: Bookmark;
};
export const UpdateBookmarkSheet = (props: UpdateBookmarkSheetProps) => {
const { data } = props;
const { mutate, isPending } = useUpdateBookmark(data.id);
const [open, setOpen] = useState(false);
const { control, handleSubmit, formState, reset } = useForm<BookmarkPayload>({
resolver: joiResolver(BookmarkPayloadSchema),
});
useEffect(() => {
reset({ ...data });
}, [data, reset]);
const onSubmit = (data: BookmarkPayload) => {
mutate(data, {
onSuccess: () => {
Toast.show({
text1: "Félicitations !",
text2: "Votre signet a été modifié avec succès.",
type: "success",
});
setOpen(false);
},
onError: (error: ErrorResponse) => {
Toast.show({
text1: "Erreur",
text2: safeMessage(error),
type: "error",
});
},
});
};
return (
<YStack alignItems="center" justifyContent="center">
<Button onPress={() => setOpen(true)}>Modifier</Button>
<Sheet
modal={true}
open={open}
onOpenChange={setOpen}
snapPointsMode="percent"
snapPoints={[65, 90]}
dismissOnOverlayPress={true}
dismissOnSnapToBottom={true}
animation="medium"
>
<Sheet.Overlay
animation="lazy"
backgroundColor="rgba(0, 0, 0, 0.8)"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Sheet.Frame
flex={1}
backgroundColor="$background"
padding="$4"
gap="$4"
alignItems="center"
justifyContent="flex-start"
>
<YStack width="100%">
<Sheet.Handle theme="accent" />
<FormTextInput
name="name"
control={control}
label="Name"
caption="Enter a name for your bookmark."
placeholder="My awesome bookmark"
/>
<FormTextArea
name="description"
control={control}
caption="Describe your bookmark for easy retrieval."
label="Description"
placeholder="A brief description..."
/>
<FormSwitch
name="isPublic"
control={control}
label="Public"
description="A public bookmark is visible and accessible to other users"
/>
</YStack>
<SubmitButton
label="Modifier le signet"
onPress={handleSubmit(onSubmit)}
isPending={isPending}
isValid={formState.isValid}
/>
</Sheet.Frame>
</Sheet>
</YStack>
);
};
@@ -0,0 +1,5 @@
export { BookmarkCard } from "@/ui/components/content/bookmark/BookmarkCard";
export { BookmarkEmptyState } from "@/ui/components/content/bookmark/BookmarkEmptyState";
export { BookmarkList } from "@/ui/components/content/bookmark/BookmarkList";
export { CreateBookmarkSheet } from "@/ui/components/content/bookmark/CreateBookmarkSheet";
export { UpdateBookmarkSheet } from "@/ui/components/content/bookmark/UpdateBookmarkSheet";
@@ -0,0 +1,62 @@
import { useCallback, useState } from "react";
import { ActivityIndicator, Alert } from "react-native";
import { Button, GetProps } from "tamagui";
import { useFollowSource, useUnfollowSource } from "@/api/request/feed-management/source";
type SourceFollowButtonProps = GetProps<typeof Button> & {
id: string;
name: string;
followed: boolean;
};
export const SourceFollowButton = (props: SourceFollowButtonProps) => {
const { id, followed, name, ...rest } = props;
const [isFollowed, setIsFollowed] = useState<boolean>(followed);
const { mutate: follow, isPending: following } = useFollowSource(id);
const { mutate: unfollow, isPending: unfollowing } = useUnfollowSource(id);
const loading = following || unfollowing;
const handlePress = useCallback(() => {
if (isFollowed) {
Alert.alert(
"Confirmation",
`Êtes-vous sûr de vouloir ne plus suivre ${name} ?`,
[
{
text: "Annuler",
style: "cancel",
},
{
text: "Ne plus suivre",
style: "destructive",
onPress: () => {
unfollow();
setIsFollowed(prev => !prev);
},
},
],
{ cancelable: false }
);
} else {
follow();
setIsFollowed(prev => !prev);
}
}, [isFollowed, name, unfollow, follow, setIsFollowed]);
return (
<Button
size="$2"
theme={isFollowed ? "alt1" : "surface1"}
chromeless={isFollowed}
disabled={loading}
onPress={handlePress}
minWidth={80}
paddingHorizontal="$2"
{...rest}
>
{loading ? <ActivityIndicator /> : isFollowed ? "Suivi" : "Suivre"}
</Button>
);
};
@@ -0,0 +1,55 @@
import React, { useCallback } from "react";
import { FlatList, FlatListProps } from "react-native";
import { Paragraph, XStack, YStack } from "tamagui";
import { SourceOverview } from "@/api/schema/feed-management/source";
import { SourceOverviewCard } from "@/ui/components/content/source/SourceOverviewCard";
const HorizontalSeparator = () => <XStack width="$1" />;
const VerticalSeparator = () => <YStack height="$0.5" />;
type SourceOverviewListProps = Omit<FlatListProps<SourceOverview>, "renderItem"> & {
data: SourceOverview[];
horizontal?: boolean;
infiniteScroll?: boolean;
};
type SourceOverviewListComponent = React.FC<SourceOverviewListProps> & {
HorizontalSeparator: typeof HorizontalSeparator;
VerticalSeparator: typeof VerticalSeparator;
};
const keyExtractor = (item: SourceOverview) => item.name;
const SourceList: SourceOverviewListComponent = (props: SourceOverviewListProps) => {
const { data, horizontal = false, ...rest } = props;
const renderItem = useCallback(
({ item }: { item: SourceOverview }) => {
return <SourceOverviewCard data={item} horizontal={horizontal} />;
},
[horizontal]
);
return (
<FlatList
{...rest}
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
ItemSeparatorComponent={horizontal ? HorizontalSeparator : VerticalSeparator}
horizontal={horizontal}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
onEndReachedThreshold={0.5}
removeClippedSubviews={true}
ListEmptyComponent={() => <Paragraph>Pas de sources disponibles pour le moment.</Paragraph>}
/>
);
};
SourceList.HorizontalSeparator = HorizontalSeparator;
SourceList.VerticalSeparator = VerticalSeparator;
export { SourceList };
@@ -0,0 +1,76 @@
import { Link } from "expo-router";
import { GetProps, styled, XStack, YStack } from "tamagui";
import { SourceOverview } from "@/api/schema/feed-management/source";
import { SourceFollowButton } from "@/ui/components/content/source/SourceFollowButton";
import { SourceProfileImage } from "@/ui/components/content/source/SourceProfileImage";
import { Text } from "@/ui/components/typography";
const SourceCardFrame = styled(YStack, {
alignItems: "center",
gap: "$2",
borderRadius: "$4",
variants: {
horizontal: {
true: {
maxWidth: 100,
flexShrink: 0,
},
false: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
gap: "$4",
paddingVertical: "$2",
},
},
},
} as const);
type SourceCardProps = GetProps<typeof SourceCardFrame> & {
data: SourceOverview;
horizontal?: boolean;
};
export const SourceOverviewCard = (props: SourceCardProps) => {
const { data, horizontal = true, ...rest } = props;
const nameFontSize = horizontal ? "$3" : "$4";
return (
<SourceCardFrame horizontal={horizontal} {...rest}>
<Link href={`/(authed)/(tabs)/sources/${data.name}`}>
<SourceProfileImage name={data.name} image={data.image} size={horizontal ? 65 : 50} />
</Link>
<Link href={`/(authed)/(tabs)/sources/${data.name}`} asChild>
{horizontal ? (
<Text
fontSize={nameFontSize}
fontWeight="bold"
numberOfLines={1}
textAlign="center"
maxWidth="100%"
>
{data.displayName ?? data.name}
</Text>
) : (
<YStack flex={1} gap="$1">
<XStack alignItems="center" gap="$1">
<Text fontSize={nameFontSize} fontWeight="bold" numberOfLines={1}>
{data.displayName ?? data.name}
</Text>
</XStack>
<Text color="$accent6" fontSize="$3" numberOfLines={1}>
{data.url}
</Text>
</YStack>
)}
</Link>
<SourceFollowButton id={data.id} name={data.displayName ?? data.name} followed={data.followed} />
</SourceCardFrame>
);
};
@@ -0,0 +1,32 @@
import type React from "react";
import { GetProps, Image, styled } from "tamagui";
const StyledImage = styled(Image, {
borderRadius: "$12",
backgroundColor: "white",
});
type SourceAvatarProps = GetProps<typeof StyledImage> & {
image: string;
name: string;
size?: number;
};
export const SourceProfileImage = (props: SourceAvatarProps) => {
const { image, name, size = 50, ...rest } = props;
return (
<StyledImage
accessibilityLabel={name}
source={{
uri: image,
cache: "force-cache",
}}
objectFit="contain"
width={size}
height={size}
{...rest}
/>
);
};
@@ -0,0 +1,37 @@
import React from "react";
import { Link } from "expo-router";
import { Avatar, GetProps, XStack } from "tamagui";
import { SourceReference } from "@/api/schema/feed-management/source";
import { Text } from "@/ui/components/typography";
type SourceReferencePillProps = GetProps<typeof XStack> & {
data: SourceReference;
};
export function SourceReferencePill(props: SourceReferencePillProps) {
const { data, ...rest } = props;
return (
<Link href={`/(authed)/(tabs)/sources/${data.name}`}>
<XStack alignItems="center" gap="$2" justifyContent="flex-start" {...rest}>
<Avatar circular size="$1">
<Avatar.Image
accessibilityLabel={data.name}
objectFit="contain"
backgroundColor="white"
source={{
uri: data.image,
cache: "force-cache",
}}
/>
<Avatar.Fallback backgroundColor="$gray10" />
</Avatar>
<Text size="$2" fontWeight="bold">
{data.displayName ?? data.name}
</Text>
</XStack>
</Link>
);
}
@@ -0,0 +1,82 @@
import React, { useCallback } from "react";
import ContentLoader, { Circle, Rect } from "react-content-loader/native";
import { FlatList } from "react-native";
import { YStack } from "tamagui";
import { SourceList } from "@/ui/components/content/source/SourceList";
const data: number[] = new Array(5).fill(0);
type SourceSkeletonListProps = {
horizontal?: boolean;
};
const VerticalSkeleton = (props: any) => (
<ContentLoader
speed={1.5}
backgroundColor="#D4D5D8"
foregroundColor="white"
height={70}
animate={true}
width="100%"
{...props}
>
<Circle cx="25" cy="30" r="25" />
<Rect x="70" y="10" rx="4" ry="4" width="25%" height="10" />
<Rect x="70" y="40" rx="4" ry="4" width="45%" height="10" />
<Rect x="280" y="15" rx="4" ry="4" width="20%" height="30" />
</ContentLoader>
);
const HorizontalSkeleton = (props: any) => (
<ContentLoader
speed={1.5}
backgroundColor="#D4D5D8"
foregroundColor="white"
height={180}
animate={true}
width={110}
{...props}
>
<Circle cx="60" cy="40" r="33" />
<Rect x="10" y="85" rx="4" ry="4" width="100" height="10" />
<Rect x="25" y="105" rx="8" ry="8" width="70" height="25" />
</ContentLoader>
);
const keyExtractor = (_: number, index: number) => index.toString();
const selectSkeletonComponent = (horizontal: boolean) => {
return horizontal ? (
<HorizontalSkeleton />
) : (
<YStack width="100%">
<VerticalSkeleton />
</YStack>
);
};
export const SourceSkeletonList = (props: SourceSkeletonListProps) => {
const { horizontal = false } = props;
const ItemSeparator = horizontal ? SourceList.HorizontalSeparator : SourceList.VerticalSeparator;
const renderItem = useCallback(() => {
return selectSkeletonComponent(horizontal);
}, [horizontal]);
return (
<FlatList
data={data}
scrollEnabled={false}
renderItem={renderItem}
keyExtractor={keyExtractor}
ItemSeparatorComponent={ItemSeparator}
horizontal={horizontal}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 0 }}
removeClippedSubviews={true}
/>
);
};
@@ -0,0 +1,6 @@
export { SourceList } from "@/ui/components/content/source/SourceList";
export { SourceFollowButton } from "@/ui/components/content/source/SourceFollowButton";
export { SourceReferencePill } from "@/ui/components/content/source/SourceReferencePill";
export { SourceSkeletonList } from "@/ui/components/content/source/SourceSkeleton";
export { SourceProfileImage } from "@/ui/components/content/source/SourceProfileImage";
export { SourceOverviewCard } from "@/ui/components/content/source/SourceOverviewCard";
@@ -0,0 +1,25 @@
import { ArrowLeft } from "@tamagui/lucide-icons";
import { Button, ButtonProps } from "tamagui";
type BackButtonProps = {
onPress: () => void;
};
export const BackButton = (props: BackButtonProps & ButtonProps) => {
const { onPress, ...rest } = props;
return (
<Button
chromeless
alignSelf="flex-start"
size="$4"
width="$4"
height="$4"
borderRadius="$12"
// backgroundColor="$gray6"
icon={<ArrowLeft size="$1" />}
onPress={onPress}
{...rest}
/>
);
};
@@ -0,0 +1,22 @@
import { Button, ButtonProps } from "tamagui";
type IconButtonProps = {
onPress: () => void;
};
export const IconButton = (props: IconButtonProps & ButtonProps) => {
const { onPress, ...rest } = props;
return (
<Button
chromeless
alignSelf="flex-start"
size="$4"
width="$4"
height="$4"
borderRadius="$12"
onPress={onPress}
{...rest}
/>
);
};
@@ -0,0 +1,28 @@
import { ActivityIndicator } from "react-native";
import { Button, GetProps, styled } from "tamagui";
const StyledButton = styled(Button, {
fontWeight: "bold",
width: "100%",
});
type SubmitButtonProps = GetProps<typeof StyledButton> & {
label: string;
isValid: boolean;
isPending: boolean;
};
export const SubmitButton = (props: SubmitButtonProps) => {
const { isValid, isPending, label, ...rest } = props;
return (
<StyledButton
disabled={isPending}
theme={!isValid || isPending ? "disabled" : "accent"}
fontWeight="bold"
{...rest}
>
{isPending ? <ActivityIndicator /> : label}
</StyledButton>
);
};
@@ -0,0 +1,25 @@
import { Mail } from "@tamagui/lucide-icons";
import { Input, InputProps } from "@/ui/components/controls/forms/Input";
import { withController } from "@/ui/components/controls/forms/withController";
export const EmailInput = (props: InputProps) => {
const { label = "Email", caption, error, onChangeText, ...rest } = props;
return (
<Input
label={label}
caption={caption}
error={error}
leadingAdornment={Mail}
onChangeText={onChangeText}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
placeholder="votre@email.com..."
{...rest}
/>
);
};
export const FormEmailInput = withController<InputProps>(EmailInput);
@@ -0,0 +1,73 @@
import React, { useMemo } from "react";
import { IconProps } from "@tamagui/helpers-icon";
import { ColorTokens, GetProps, Input as TamaguiInput, Label, SizeTokens, styled, XStack, YStack } from "tamagui";
import { Caption } from "@/ui/components/typography";
const StyledInput = styled(TamaguiInput, {
size: "$large",
flex: 1,
borderWidth: 0,
placeholderTextColor: "$gray8",
backgroundColor: "transparent",
});
export type InputProps = GetProps<typeof StyledInput> & {
label?: string;
caption?: string;
error?: string;
leadingAdornment?: React.ComponentType<IconProps & { size?: SizeTokens; color?: ColorTokens }>;
trailingAdornment?: React.ReactNode;
onChangeText?: (text: string) => void;
id?: string;
};
export const Input = (props: InputProps) => {
const { label, caption, error, leadingAdornment, trailingAdornment, onChangeText, id, ...rest } = props;
const isInvalid = !!error;
const leadingAdornmentComponent = useMemo(() => {
return leadingAdornment ? (
<XStack paddingLeft="$3" style={{ justifyContent: "center", alignItems: "center" }}>
{React.createElement(leadingAdornment, {
size: "$1",
color: "$gray9",
})}
</XStack>
) : undefined;
}, [leadingAdornment]);
return (
<YStack gap="$1">
<YStack>
{label && (
<Label htmlFor={id} fontWeight="bold" color={isInvalid ? "$red9" : undefined}>
{label}
</Label>
)}
<XStack
backgroundColor="$gray4"
alignItems="center"
borderRadius="$4"
borderWidth="$0.5"
borderColor={isInvalid ? "$red9" : "transparent"}
focusStyle={{
borderColor: "$accent8",
}}
pressStyle={{
borderColor: "$accent8",
}}
>
{leadingAdornmentComponent}
<StyledInput id={id} onChangeText={onChangeText} {...rest} />
{trailingAdornment}
</XStack>
</YStack>
{caption && !isInvalid && <Caption>{caption}</Caption>}
{isInvalid && error && <Caption color="$red9">{error}</Caption>}
</YStack>
);
};
@@ -0,0 +1,37 @@
import { useState } from "react";
import { Eye, EyeOff, Lock } from "@tamagui/lucide-icons";
import { XStack } from "tamagui";
import { Input, InputProps } from "@/ui/components/controls/forms/Input";
import { withController } from "@/ui/components/controls/forms/withController";
export const PasswordInput = (props: InputProps) => {
const { label = "Mot de passe", onChangeText, caption, error, ...rest } = props;
const [showPassword, setShowPassword] = useState(false);
return (
<Input
label={label}
onChangeText={onChangeText}
caption={caption}
error={error}
leadingAdornment={Lock}
secureTextEntry={!showPassword}
paddingRight="$6"
placeholder="Mot de passe"
trailingAdornment={
<XStack
paddingRight="$3"
onPress={() => setShowPassword(!showPassword)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
{showPassword ? <Eye size="$1" color="$gray9" /> : <EyeOff size="$1" color="$gray9" />}
</XStack>
}
{...rest}
/>
);
};
export const FormPasswordInput = withController<InputProps>(PasswordInput);
@@ -0,0 +1,32 @@
import { GetProps, Label, Switch as TamaguiSwitch, XStack, YStack } from "tamagui";
import { withController } from "@/ui/components/controls/forms/withController";
import { Caption } from "@/ui/components/typography";
type SwitchProps = GetProps<typeof TamaguiSwitch> & {
label: string;
description?: string;
isChecked: boolean;
onCheckedChange: (checked: boolean) => void;
id?: string;
};
export const Switch = (props: SwitchProps) => {
const { label, description, isChecked, onCheckedChange, id, ...rest } = props;
return (
<XStack alignItems="center" gap="$10" justifyContent="space-between" width="100%">
<YStack flex={1} gap="$1">
<Label fontWeight="bold" htmlFor={id}>
{label}
</Label>
{description && <Caption>{description}</Caption>}
</YStack>
<TamaguiSwitch id={id} checked={isChecked} onCheckedChange={onCheckedChange} size="$3" {...rest}>
<TamaguiSwitch.Thumb animation="bouncy" />
</TamaguiSwitch>
</XStack>
);
};
export const FormSwitch = withController<SwitchProps>(Switch);
@@ -0,0 +1,58 @@
import { GetProps, Label, styled, TextArea as TamaguiTextArea, XStack, YStack } from "tamagui";
import { withController } from "@/ui/components/controls/forms/withController";
import { Caption } from "@/ui/components/typography";
const StyledTextArea = styled(TamaguiTextArea, {
size: "$4",
flex: 1,
minHeight: 100,
borderWidth: 0,
placeholderTextColor: "$gray8",
backgroundColor: "transparent",
});
type TextAreaProps = GetProps<typeof StyledTextArea> & {
label?: string;
caption?: string;
error?: string;
onChangeText?: (text: string) => void;
id?: string;
};
export const TextArea = (props: TextAreaProps) => {
const { label, caption, error, onChangeText, id, ...rest } = props;
const isInvalid = !!error;
return (
<YStack gap="$2">
{label && (
<Label htmlFor={id} fontWeight="bold" color={isInvalid ? "$red9" : undefined}>
{label}
</Label>
)}
<XStack
alignItems="flex-start"
backgroundColor="$gray4"
borderRadius="$4"
borderWidth="$0.5"
borderColor={isInvalid ? "$red9" : "transparent"}
focusStyle={{
borderColor: "$accent8",
}}
pressStyle={{
borderColor: "$accent8",
}}
>
<StyledTextArea id={id} onChangeText={onChangeText} {...rest} />
</XStack>
{caption && !isInvalid && <Caption>{caption}</Caption>}
{isInvalid && error && <Caption color="$red9">{error}</Caption>}
</YStack>
);
};
export const FormTextArea = withController<TextAreaProps>(TextArea);
@@ -0,0 +1,19 @@
import { Input, InputProps } from "@/ui/components/controls/forms/Input";
import { withController } from "@/ui/components/controls/forms/withController";
export const TextInput = (props: InputProps) => {
const { label, caption, error, leadingAdornment, onChangeText, ...rest } = props;
return (
<Input
label={label}
caption={caption}
error={error}
leadingAdornment={leadingAdornment}
onChangeText={onChangeText}
{...rest}
/>
);
};
export const FormTextInput = withController<InputProps>(TextInput);
@@ -0,0 +1,5 @@
export { EmailInput, FormEmailInput } from "@/ui/components/controls/forms/EmailInput";
export { TextInput, FormTextInput } from "@/ui/components/controls/forms/TextInput";
export { PasswordInput, FormPasswordInput } from "@/ui/components/controls/forms/PasswordInput";
export { Switch, FormSwitch } from "@/ui/components/controls/forms/Switch";
export { TextArea, FormTextArea } from "@/ui/components/controls/forms/TextArea";
@@ -0,0 +1,50 @@
import React from "react";
import { Controller, ControllerProps } from "react-hook-form";
type WithControllerProps = {
value?: any;
onChangeText?: (val: any) => void;
isChecked?: boolean;
onCheckedChange?: (val: boolean) => void;
error?: string;
};
type ControllerWrapperProps<T> = {
name: string;
control: ControllerProps<any, any, any>["control"];
} & Omit<T, keyof WithControllerProps>;
export const withController = <T extends WithControllerProps>(Component: React.ComponentType<T>) => {
const ControllerWrapper = (props: ControllerWrapperProps<T>) => {
const { name, control, ...rest } = props;
return (
<Controller
name={name}
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => {
const hasSwitchProps = "isChecked" in rest || "onCheckedChange" in rest;
const controllerProps: WithControllerProps = {
error: error?.message,
};
if (hasSwitchProps) {
controllerProps.isChecked = value;
controllerProps.onCheckedChange = onChange;
} else {
controllerProps.value = value;
controllerProps.onChangeText = onChange;
}
return <Component {...(rest as unknown as T)} {...(controllerProps as T)} />;
}}
/>
);
};
ControllerWrapper.displayName = `withController(${Component.displayName || Component.name || "Component"})`;
return ControllerWrapper;
};
@@ -0,0 +1,55 @@
import React from "react";
import { styled, View, XStack } from "tamagui";
import { Text } from "@/ui/components/typography";
const ActionContainer = styled(XStack, {
alignItems: "center",
minWidth: "$5",
gap: "$1",
});
interface ScreenHeadingProps {
leadingAction?: React.ReactNode;
title?: string;
trailingActions?: React.ReactNode | React.ReactNode[];
paddingHorizontal?: number | string;
marginBottom?: number | string;
}
export const ScreenHeading = (props: ScreenHeadingProps) => {
const { leadingAction, title, trailingActions, paddingHorizontal = "$4", marginBottom = "$2" } = props;
const trailingActionsArray = Array.isArray(trailingActions)
? trailingActions
: trailingActions
? [trailingActions]
: [];
return (
<XStack
alignItems="center"
justifyContent="space-between"
height="$6"
backgroundColor="$background"
paddingHorizontal={paddingHorizontal}
marginBottom={marginBottom}
>
<ActionContainer>{leadingAction}</ActionContainer>
<XStack flex={1} justifyContent="center">
{title ? (
<Text fontWeight="600" fontSize="$6">
{title}
</Text>
) : (
<View />
)}
</XStack>
<ActionContainer>
{trailingActionsArray.map((action, index) => (
<React.Fragment key={index}>{action}</React.Fragment>
))}
</ActionContainer>
</XStack>
);
};
@@ -0,0 +1,47 @@
import type React from "react";
import { ArrowRight } from "@tamagui/lucide-icons";
import { Href, Link } from "expo-router";
import { GetProps, Paragraph, styled, XStack } from "tamagui";
import { Text } from "@/ui/components/typography";
const SectionContainer = styled(XStack, {
alignItems: "center",
justifyContent: "space-between",
width: "100%",
paddingVertical: "$2",
});
type ScreenSectionProps = GetProps<typeof SectionContainer> & {
title: string;
forwardLink?: Href;
};
type ScreenSectionLinkProps = {
href: Href;
};
const ScreenSectionLink = ({ href }: ScreenSectionLinkProps) => (
<Link href={href} push asChild>
<XStack gap="2" alignItems="center">
<Paragraph color="$accent5" fontWeight={500}>
Voir tout
</Paragraph>
<ArrowRight color="$accent5" />
</XStack>
</Link>
);
export const ScreenSection = (props: ScreenSectionProps) => {
const { title, forwardLink, ...rest } = props;
return (
<SectionContainer {...rest}>
<Text fontSize="$6" fontWeight="bold" color="$color" numberOfLines={1} flexShrink={1} marginRight="$2">
{title}
</Text>
{forwardLink && <ScreenSectionLink href={forwardLink} />}
</SectionContainer>
);
};
@@ -0,0 +1,78 @@
import React from "react";
import { StatusBar } from "expo-status-bar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { styled, YStack } from "tamagui";
import { ScreenHeading } from "@/ui/components/layout/ScreenHeading";
import { ScreenSection } from "@/ui/components/layout/ScreenSection";
type ScreenViewProps = React.ComponentProps<typeof YStack> & {
showStatusBar?: boolean;
statusBarStyle?: "auto" | "inverted" | "light" | "dark";
statusBarBackgroundColor?: string;
children?: React.ReactNode;
};
type ScreenViewComponent = React.FC<React.PropsWithChildren<ScreenViewProps>> & {
Heading: typeof ScreenHeading;
Section: typeof ScreenSection;
};
const ScreenContent = styled(YStack, {
gap: "$4",
paddingHorizontal: "$4",
alignItems: "center",
});
const ScreenView: ScreenViewComponent = (props: React.PropsWithChildren<ScreenViewProps>) => {
const {
showStatusBar = true,
statusBarStyle = "auto",
statusBarBackgroundColor = "transparent",
padding,
children,
...rest
} = props;
const insets = useSafeAreaInsets();
let headingElement: React.ReactNode | null = null;
const otherChildren: React.ReactNode[] = [];
// Iterate through children to find the Heading and separate others
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
if (child.type === ScreenView.Heading) {
headingElement = child;
} else {
otherChildren.push(child);
}
} else {
otherChildren.push(child);
}
});
return (
<>
{showStatusBar ? <StatusBar style={statusBarStyle} backgroundColor={statusBarBackgroundColor} /> : null}
<YStack flex={1} paddingTop={insets.top} backgroundColor="$background">
{headingElement}
<ScreenContent
flex={1}
paddingBottom={insets.bottom}
paddingHorizontal={padding ?? rest.paddingHorizontal ?? "$4"}
{...rest}
>
{otherChildren}
</ScreenContent>
</YStack>
</>
);
};
ScreenView.Heading = ScreenHeading;
ScreenView.Section = ScreenSection;
export { ScreenView };
@@ -0,0 +1 @@
export { ScreenView } from "@/ui/components/layout/ScreenView";
@@ -0,0 +1,13 @@
import type React from "react";
import { Paragraph, ParagraphProps } from "tamagui";
export const Caption = (props: React.PropsWithChildren<ParagraphProps>) => {
const { children, ...rest } = props;
return (
<Paragraph fontSize="$2" lineHeight="$1" color="$gray10" {...rest}>
{children}
</Paragraph>
);
};
@@ -0,0 +1,13 @@
import type React from "react";
import { H2, ParagraphProps } from "tamagui";
export const Display = (props: React.PropsWithChildren<ParagraphProps>) => {
const { children, ...rest } = props;
return (
<H2 fontWeight="bold" lineHeight="$8" {...rest}>
{children}
</H2>
);
};
@@ -0,0 +1,13 @@
import type React from "react";
import { H4, ParagraphProps } from "tamagui";
export const Heading = (props: React.PropsWithChildren<ParagraphProps>) => {
const { children, ...rest } = props;
return (
<H4 fontWeight="bold" alignSelf="flex-start" {...rest}>
{children}
</H4>
);
};
@@ -0,0 +1,8 @@
import type React from "react";
import { Paragraph, ParagraphProps } from "tamagui";
export const Text = (props: React.PropsWithChildren<ParagraphProps>) => {
const { children, ...rest } = props;
return <Paragraph {...rest}>{children}</Paragraph>;
};
@@ -0,0 +1,4 @@
export { Display } from "@/ui/components/typography/Display";
export { Heading } from "@/ui/components/typography/Heading";
export { Caption } from "@/ui/components/typography/Caption";
export { Text } from "@/ui/components/typography/Text";