feat: rsshub form layer

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-12-31 20:25:49 +08:00
parent fd373fb527
commit 6f9fb5ba92
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
22 changed files with 530 additions and 56 deletions

View File

@ -22,8 +22,10 @@
"@follow/models": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@gorhom/portal": "1.0.14",
"@hookform/resolvers": "3.9.1",
"@react-native-cookies/cookies": "^6.2.1",
"@react-native-picker/picker": "2.9.0",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@shopify/flash-list": "1.7.1",

View File

@ -0,0 +1,79 @@
import { cn } from "@follow/utils/src/utils"
import { useRef } from "react"
import { Pressable } from "react-native"
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from "react-native-reanimated"
import { useEventCallback } from "usehooks-ts"
import { CheckFilledIcon } from "@/src/icons/check_filled"
import { Copy2CuteReIcon } from "@/src/icons/copy_2_cute_re"
type Size = "sm" | "md"
interface CopyButtonProps {
onCopy: () => void
className?: string
size?: Size
}
const sizeClassNames = {
sm: "size-8",
md: "size-10",
}
const sizeIconSize = {
sm: 18,
md: 20,
}
export const CopyButton = ({ onCopy, className, size = "sm" }: CopyButtonProps) => {
const initialIconScale = useSharedValue(1)
const pressedIconScale = useSharedValue(0)
const initialStyle = useAnimatedStyle(() => ({
transform: [{ scale: initialIconScale.value }],
}))
const pressedStyle = useAnimatedStyle(() => ({
position: "absolute",
transform: [{ scale: pressedIconScale.value }],
}))
const animatedProgressingRef = useRef(false)
const handlePress = useEventCallback(() => {
onCopy()
if (animatedProgressingRef.current) return
animatedProgressingRef.current = true
initialIconScale.value = withTiming(0, { duration: 100 }, () => {
pressedIconScale.value = withTiming(1, { duration: 100 }, () => {
pressedIconScale.value = withDelay(
1000,
withTiming(0, { duration: 100 }, () => {
initialIconScale.value = withTiming(1, { duration: 100 }, () => {
animatedProgressingRef.current = false
})
}),
)
})
})
})
return (
<Pressable
className={cn(
"bg-red items-center justify-center rounded-lg",
sizeClassNames[size],
className,
)}
onPress={handlePress}
>
<Animated.View style={initialStyle}>
<Copy2CuteReIcon color="#fff" height={sizeIconSize[size]} width={sizeIconSize[size]} />
</Animated.View>
<Animated.View style={pressedStyle}>
<CheckFilledIcon color="#fff" height={sizeIconSize[size]} width={sizeIconSize[size]} />
</Animated.View>
</Pressable>
)
}

View File

@ -11,6 +11,6 @@ const node = (
}}
/>
)
export const HeaderBlur = () => {
export const BlurEffect = () => {
return node
}

View File

@ -0,0 +1,20 @@
import { cn } from "@follow/utils/src/utils"
import type { FC, PropsWithChildren } from "react"
import type { StyleProp, ViewStyle } from "react-native"
import { Text, View } from "react-native"
export const FormLabel: FC<
PropsWithChildren<{
label: string
optional?: boolean
className?: string
style?: StyleProp<ViewStyle>
}>
> = ({ label, optional, className, style }) => {
return (
<View className={cn("flex-row", className)} style={style}>
<Text className="text-label text-lg font-medium capitalize">{label}</Text>
{!optional && <Text className="text-red ml-1 align-sub">*</Text>}
</View>
)
}

View File

@ -0,0 +1,93 @@
/* eslint-disable @eslint-react/no-array-index-key */
import { cn } from "@follow/utils"
import { Portal } from "@gorhom/portal"
import { Picker } from "@react-native-picker/picker"
import { useMemo, useState } from "react"
import type { StyleProp, ViewStyle } from "react-native"
import { Pressable, Text, View } from "react-native"
import Animated, { SlideOutDown } from "react-native-reanimated"
import { useEventCallback } from "usehooks-ts"
import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line"
import { useColor } from "@/src/theme/colors"
import { BlurEffect } from "../../common/HeaderBlur"
interface SelectProps<T> {
options: { label: string; value: T }[]
value: T
onValueChange: (value: T) => void
wrapperClassName?: string
wrapperStyle?: StyleProp<ViewStyle>
}
export function Select<T>({
options,
value,
onValueChange,
wrapperClassName,
wrapperStyle,
}: SelectProps<T>) {
const [isOpen, setIsOpen] = useState(false)
const [currentValue, setCurrentValue] = useState(() => {
if (!value) {
return options[0].value
}
return value
})
const valueToLabelMap = useMemo(() => {
return options.reduce((acc, option) => {
acc.set(option.value, option.label)
return acc
}, new Map<T, string>())
}, [options])
const handleChangeValue = useEventCallback((value: T) => {
setCurrentValue(value)
onValueChange(value)
})
const systemFill = useColor("systemFill")
return (
<>
{/* Trigger */}
<Pressable onPress={() => setIsOpen(!isOpen)}>
<View
className={cn(
"border-system-fill/80 bg-system-fill/30 h-12 flex-row items-center rounded-lg border pl-4 pr-2",
wrapperClassName,
)}
style={wrapperStyle}
>
<Text className="text-text">{valueToLabelMap.get(currentValue)}</Text>
<View className="ml-auto shrink-0">
<MingcuteDownLineIcon color={systemFill} />
</View>
</View>
</Pressable>
{/* Picker */}
{isOpen && (
<Portal>
<Pressable
onPress={() => setIsOpen(false)}
className="absolute inset-0 flex flex-row items-end"
>
<Animated.View className="relative flex-1" exiting={SlideOutDown}>
<BlurEffect />
<Pressable onPress={(e) => e.stopPropagation()}>
<Picker selectedValue={currentValue} onValueChange={handleChangeValue}>
{options.map((option, index) => (
<Picker.Item key={index} label={option.label} value={option.value} />
))}
</Picker>
</Pressable>
</Animated.View>
</Pressable>
</Portal>
)}
</>
)
}

View File

@ -0,0 +1,38 @@
import { cn } from "@follow/utils/src/utils"
import type { FC } from "react"
import type { StyleProp, TextInputProps, ViewStyle } from "react-native"
import { StyleSheet, TextInput, View } from "react-native"
interface TextFieldProps {
wrapperClassName?: string
wrapperStyle?: StyleProp<ViewStyle>
}
export const TextField: FC<TextInputProps & TextFieldProps> = ({
className,
style,
wrapperClassName,
wrapperStyle,
...rest
}) => {
return (
<View
className={cn(
"bg-system-fill/40 relative h-12 flex-row items-center rounded-lg px-4",
wrapperClassName,
)}
style={wrapperStyle}
>
<TextInput
className={cn("text-text placeholder:text-placeholder-text", className)}
style={StyleSheet.flatten([styles.textField, style])}
{...rest}
/>
</View>
)
}
const styles = StyleSheet.create({
textField: {
fontSize: 16,
},
})

View File

@ -0,0 +1,26 @@
import * as React from "react"
import Svg, { G, Path } from "react-native-svg"
interface MingcuteDownLineIconProps {
width?: number
height?: number
color?: string
}
export const MingcuteDownLineIcon = ({
width = 24,
height = 24,
color = "#10161F",
}: MingcuteDownLineIconProps) => {
return (
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
<G fill="none" fillRule="evenodd">
<Path d="M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035q-.016-.005-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427q-.004-.016-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093q.019.005.029-.008l.004-.014-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014-.034.614q.001.018.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z" />
<Path
fill={color}
d="M12.707 15.707a1 1 0 0 1-1.414 0L5.636 10.05A1 1 0 1 1 7.05 8.636l4.95 4.95 4.95-4.95a1 1 0 0 1 1.414 1.414z"
/>
</G>
</Svg>
)
}

View File

@ -13,7 +13,8 @@ import { RSSHubCategoryCopyMap } from "./copy"
export const RecommendationListItem: FC<{
data: RSSHubRouteDeclaration
}> = memo(({ data }) => {
routePrefix: string
}> = memo(({ data, routePrefix }) => {
const { maintainers, categories } = useMemo(() => {
const maintainers = new Set<string>()
const categories = new Set<string>()
@ -90,23 +91,26 @@ export const RecommendationListItem: FC<{
<Grid columns={2} gap={8} className="mt-2">
{Object.keys(data.routes).map((route) => (
<TouchableOpacity
onPress={() => {
router.push({
pathname: "/rsshub-form",
params: {
url: data.url,
route,
},
})
}}
key={route}
className="bg-gray-5 h-10 flex-row items-center justify-center overflow-hidden rounded px-2"
>
<Text ellipsizeMode="middle" numberOfLines={1} className="whitespace-pre">
{data.routes[route].name}
</Text>
</TouchableOpacity>
<View className="relative" key={route}>
<TouchableOpacity
onPress={() => {
router.push({
pathname: "/rsshub-form",
params: {
routePrefix,
route: JSON.stringify(data.routes[route]),
name: data.name,
},
})
}}
className="bg-gray-5 h-10 flex-row items-center justify-center overflow-hidden rounded px-2"
/>
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
<Text ellipsizeMode="middle" numberOfLines={1} className="text-text whitespace-pre">
{data.routes[route].name}
</Text>
</View>
</View>
))}
</Grid>
</View>

View File

@ -6,7 +6,7 @@ import { useHeaderHeight } from "@react-navigation/elements"
import { FlashList } from "@shopify/flash-list"
import { useQuery } from "@tanstack/react-query"
import type { FC } from "react"
import { useCallback, useMemo, useRef, useState } from "react"
import { useCallback, useMemo, useRef } from "react"
import { Text, TouchableOpacity, View } from "react-native"
import type { PanGestureHandlerGestureEvent } from "react-native-gesture-handler"
import { PanGestureHandler } from "react-native-gesture-handler"
@ -183,7 +183,7 @@ const ItemRenderer = ({
// Render item
return (
<View className="mr-4">
<RecommendationListItem data={item.data} />
<RecommendationListItem data={item.data} routePrefix={item.key} />
</View>
)
}
@ -193,8 +193,6 @@ const NavigationSidebar: FC<{
alphabetGroups: (string | { key: string; data: RSSHubRouteDeclaration })[]
listRef: React.RefObject<FlashList<string | { key: string; data: RSSHubRouteDeclaration }>>
}> = ({ alphabetGroups, listRef }) => {
const [activeLetter, setActiveLetter] = useState<string>("")
const scrollToLetter = useCallback(
(letter: string, animated = true) => {
const index = alphabetGroups.findIndex((group) => {
@ -237,10 +235,8 @@ const NavigationSidebar: FC<{
const firstChar = letter[0].toUpperCase()
const firstCharIsAlphabet = /[A-Z]/.test(firstChar)
if (firstCharIsAlphabet) {
setActiveLetter(letter)
scrollToLetter(letter, false)
} else {
setActiveLetter("#")
scrollToLetter("#", false)
}
},
@ -256,15 +252,10 @@ const NavigationSidebar: FC<{
hitSlop={5}
key={title}
onPress={() => {
setActiveLetter(title)
scrollToLetter(title)
}}
>
<Text
className={`text-sm ${activeLetter !== title ? "text-secondary-text/60" : "text-accent"}`}
>
{title}
</Text>
<Text className="text-secondary-text/60 text-sm">{title}</Text>
</TouchableOpacity>
))}
</View>

View File

@ -16,7 +16,7 @@ import {
} from "react-native"
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
import { HeaderBlur } from "@/src/components/common/HeaderBlur"
import { BlurEffect } from "@/src/components/common/HeaderBlur"
import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re"
import { accentColor, useColor } from "@/src/theme/colors"
@ -29,7 +29,7 @@ export const SearchHeader = () => {
return (
<View style={{ height: headerHeight, paddingTop: insets.top }} className="relative">
<HeaderBlur />
<BlurEffect />
<View style={styles.header}>
<ComposeSearchBar />
</View>
@ -44,7 +44,7 @@ export const DiscoverHeader = () => {
return (
<View style={{ height: headerHeight, paddingTop: insets.top }} className="relative">
<HeaderBlur />
<BlurEffect />
<View style={styles.header}>
<PlaceholerSearchBar />
</View>
@ -53,11 +53,11 @@ export const DiscoverHeader = () => {
}
const PlaceholerSearchBar = () => {
const { colors } = useTheme()
const placeholderTextColor = useColor("placeholderText")
return (
<Pressable
style={{ backgroundColor: colors.card, ...styles.searchbar }}
style={styles.searchbar}
className="dark:bg-gray-6 bg-gray-5"
onPress={() => {
router.push("/search")
}}

View File

@ -0,0 +1,38 @@
import { cn, regexpPathToPath } from "@follow/utils"
import type { FC } from "react"
import { useMemo } from "react"
import type { UseFormReturn } from "react-hook-form"
import { Clipboard, Text, View } from "react-native"
import { CopyButton } from "@/src/components/common/CopyButton"
export const PreviewUrl: FC<{
watch: UseFormReturn<any>["watch"]
path: string
routePrefix: string
className?: string
}> = ({ watch, path, routePrefix, className }) => {
const data = watch()
const fullPath = useMemo(() => {
try {
return regexpPathToPath(path, data)
} catch (err: unknown) {
console.info((err as Error).message)
return path
}
}, [path, data])
const renderedPath = `rsshub://${routePrefix}${fullPath}`
return (
<View className={cn("relative min-w-0", className)}>
<Text className="text-text/80 w-full whitespace-pre-line break-words">{renderedPath}</Text>
<CopyButton
onCopy={() => {
Clipboard.setString(renderedPath)
}}
className="absolute right-0 top-0"
/>
</View>
)
}

View File

@ -1,4 +1,5 @@
import { jotaiStore } from "@follow/utils"
import { PortalProvider } from "@gorhom/portal"
import { ThemeProvider } from "@react-navigation/native"
import { QueryClientProvider } from "@tanstack/react-query"
import { useDrizzleStudio } from "expo-drizzle-studio-plugin"
@ -28,7 +29,9 @@ export const RootProviders = ({ children }: { children: ReactNode }) => {
<View style={[styles.flex, currentThemeColors]}>
<QueryClientProvider client={queryClient}>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<GestureHandlerRootView>{children}</GestureHandlerRootView>
<GestureHandlerRootView>
<PortalProvider>{children}</PortalProvider>
</GestureHandlerRootView>
</ThemeProvider>
</QueryClientProvider>
</View>

View File

@ -1,12 +1,162 @@
import { Stack } from "expo-router"
import type { RSSHubParameter, RSSHubParameterObject, RSSHubRoute } from "@follow/models/src/rsshub"
import { parseFullPathParams, parseRegexpPathParams } from "@follow/utils"
import { PortalProvider } from "@gorhom/portal"
import { zodResolver } from "@hookform/resolvers/zod"
import { router, Stack, useLocalSearchParams } from "expo-router"
import { useEffect, useMemo } from "react"
import type { UseFormReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { View } from "react-native"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { z } from "zod"
import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents"
import { FormLabel } from "@/src/components/ui/form/Label"
import { Select } from "@/src/components/ui/form/Select"
import { TextField } from "@/src/components/ui/form/TextField"
import { PreviewUrl } from "@/src/modules/rsshub/preview-url"
interface RsshubFormParams {
route: RSSHubRoute
routePrefix: string
name: string
}
export default function RsshubForm() {
const params = useLocalSearchParams()
const { route, routePrefix, name } = (params || {}) as Record<string, string>
const parsedRoute = useMemo(() => {
if (!route) return null
try {
return typeof route === "string" ? JSON.parse(route) : route
} catch {
return null
}
}, [route])
const canBack = router.canDismiss()
useEffect(() => {
if (!parsedRoute && canBack) {
router.dismiss()
}
}, [canBack, parsedRoute])
if (!parsedRoute || !routePrefix) {
return null
}
return <FormImpl route={parsedRoute} routePrefix={routePrefix as string} name={name} />
}
function FormImpl({ route, routePrefix, name }: RsshubFormParams) {
const { name: routeName } = route
const keys = useMemo(
() =>
parseRegexpPathParams(route.path, {
excludeNames: [
"routeParams",
"functionalFlag",
"fulltext",
"disableEmbed",
"date",
"language",
"lang",
"sort",
],
}),
[route.path],
)
const formPlaceholder = useMemo<Record<string, string>>(() => {
if (!route.example) return {}
return parseFullPathParams(route.example.replace(`/${routePrefix}`, ""), route.path)
}, [route.example, route.path, routePrefix])
const dynamicFormSchema = useMemo(
() =>
z.object({
...Object.fromEntries(
keys.map((keyItem) => [
keyItem.name,
keyItem.optional ? z.string().optional().nullable() : z.string().min(1),
]),
),
}),
[keys],
)
const defaultValue = useMemo(() => {
const ret = {} as Record<string, string | null>
if (!route.parameters) return ret
for (const key in route.parameters) {
const params = normalizeRSSHubParameters(route.parameters[key])
if (!params) continue
ret[key] = params.default
}
return ret
}, [route.parameters])
const form = useForm<z.infer<typeof dynamicFormSchema>>({
resolver: zodResolver(dynamicFormSchema),
defaultValues: defaultValue,
mode: "all",
}) as UseFormReturn<any>
return (
<View>
<Stack.Screen options={{ headerLeft: ModalHeaderCloseButton }} />
</View>
<PortalProvider>
<KeyboardAwareScrollView>
<Stack.Screen
options={{ headerLeft: ModalHeaderCloseButton, headerTitle: `${name} - ${routeName}` }}
/>
<PreviewUrl
className="my-6 px-6"
watch={form.watch}
path={route.path}
routePrefix={routePrefix}
/>
{/* Form */}
<View className="gap-4 px-2">
{keys.map((keyItem) => {
const parameters = normalizeRSSHubParameters(route.parameters[keyItem.name])
const formRegister = form.register(keyItem.name)
return (
<View key={keyItem.name}>
<FormLabel className="pl-1" label={keyItem.name} optional={keyItem.optional} />
{!parameters?.options && (
<TextField
wrapperClassName="mt-2"
placeholder={formPlaceholder[keyItem.name]}
value={form.getValues(keyItem.name)}
onChangeText={(text) => {
formRegister.onChange({
target: { value: text },
})
}}
/>
)}
{!!parameters?.options && (
<Select
wrapperClassName="mt-2"
options={parameters.options}
value={form.getValues(keyItem.name)}
onValueChange={(value) => {
formRegister.onChange({ target: { value } })
}}
/>
)}
</View>
)
})}
</View>
</KeyboardAwareScrollView>
</PortalProvider>
)
}
const normalizeRSSHubParameters = (parameters: RSSHubParameter): RSSHubParameterObject | null =>
parameters
? typeof parameters === "string"
? { description: parameters, default: null }
: parameters
: null

View File

@ -5,7 +5,7 @@ import { View } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import { runOnJS } from "react-native-reanimated"
import { HeaderBlur } from "@/src/components/common/HeaderBlur"
import { BlurEffect } from "@/src/components/common/HeaderBlur"
import { FollowIcon } from "@/src/components/ui/logo"
import { SafariCuteFi } from "@/src/icons/safari_cute_fi"
import { SafariCuteIcon } from "@/src/icons/safari_cute-re"
@ -23,7 +23,7 @@ export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarBackground: HeaderBlur,
tabBarBackground: BlurEffect,
tabBarStyle: {
position: "absolute",
},

View File

@ -1,7 +1,7 @@
import { Stack, useLocalSearchParams } from "expo-router"
import { ScrollView, Text, View } from "react-native"
import { HeaderBlur } from "@/src/components/common/HeaderBlur"
import { BlurEffect } from "@/src/components/common/HeaderBlur"
export default function Feed() {
const { feedId } = useLocalSearchParams()
@ -12,7 +12,7 @@ export default function Feed() {
options={{
headerShown: true,
headerBackTitle: "Subscriptions",
headerBackground: HeaderBlur,
headerBackground: BlurEffect,
headerTransparent: true,
headerTitle: "Feed",

View File

@ -74,7 +74,6 @@
"mdast-util-to-markdown": "^2.1.2",
"nanoid": "5.0.9",
"ofetch": "1.4.1",
"path-to-regexp": "8.2.0",
"plain-shiki": "0.0.12",
"re-resizable": "6.10.1",
"react-blurhash": "^0.3.0",

View File

@ -10,6 +10,12 @@ import {
} from "@follow/components/ui/select/index.jsx"
import type { FeedViewType } from "@follow/constants"
import { nextFrame } from "@follow/utils/dom"
import {
MissingOptionalParamError,
parseFullPathParams,
parseRegexpPathParams,
regexpPathToPath,
} from "@follow/utils/path-parser"
import { cn } from "@follow/utils/utils"
import { zodResolver } from "@hookform/resolvers/zod"
import { omit } from "es-toolkit/compat"
@ -25,12 +31,6 @@ import { getSidebarActiveView } from "~/atoms/sidebar"
import { CopyButton } from "~/components/ui/code-highlighter"
import { Markdown } from "~/components/ui/markdown/Markdown"
import { useCurrentModal, useIsTopModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
import {
MissingOptionalParamError,
parseFullPathParams,
parseRegexpPathParams,
regexpPathToPath,
} from "~/lib/path-parser"
import { getViewFromRoute } from "~/lib/utils"
import { FeedForm } from "./feed-form"

View File

@ -2,5 +2,6 @@ export * from "./src/cjk"
export * from "./src/color"
export * from "./src/dom"
export * from "./src/jotai"
export * from "./src/path-parser"
export * from "./src/url-for-video"
export * from "./src/utils"

View File

@ -32,6 +32,7 @@
"clsx": "2.1.1",
"framer-motion": "11.13.1",
"nanoid": "5.0.9",
"path-to-regexp": "8.2.0",
"tailwind-merge": "2.5.5",
"tldts": "6.1.66",
"uniqolor": "1.1.1"

View File

@ -8,7 +8,7 @@ import {
parseRegexpPathParams,
regexpPathToPath,
transformUriPath,
} from "../path-parser"
} from "./path-parser"
describe("test `transformUriPath()`", () => {
test("normal path", () => {

View File

@ -427,12 +427,18 @@ importers:
'@follow/utils':
specifier: workspace:*
version: link:../../packages/utils
'@gorhom/portal':
specifier: 1.0.14
version: 1.0.14(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
'@hookform/resolvers':
specifier: 3.9.1
version: 3.9.1(react-hook-form@7.54.0(react@18.3.1))
'@react-native-cookies/cookies':
specifier: ^6.2.1
version: 6.2.1(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))
'@react-native-picker/picker':
specifier: 2.9.0
version: 2.9.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
'@react-navigation/bottom-tabs':
specifier: ^7.0.0
version: 7.2.0(36el4dfrgnbt7wqu67o6leoq5q)
@ -806,9 +812,6 @@ importers:
ofetch:
specifier: 1.4.1
version: 1.4.1
path-to-regexp:
specifier: 8.2.0
version: 8.2.0
plain-shiki:
specifier: 0.0.12
version: 0.0.12(shiki@1.24.1)
@ -1449,6 +1452,9 @@ importers:
nanoid:
specifier: 5.0.9
version: 5.0.9
path-to-regexp:
specifier: 8.2.0
version: 8.2.0
tailwind-merge:
specifier: 2.5.5
version: 2.5.5
@ -3716,6 +3722,12 @@ packages:
'@gar/promisify@1.1.3':
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
'@gorhom/portal@1.0.14':
resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==}
peerDependencies:
react: '*'
react-native: '*'
'@grpc/grpc-js@1.9.15':
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
engines: {node: ^8.13.0 || >=10.10.0}
@ -4918,6 +4930,12 @@ packages:
peerDependencies:
react-native: '>= 0.60.2'
'@react-native-picker/picker@2.9.0':
resolution: {integrity: sha512-khEhIW/uhfMqq/+tvg4rEAiPGT8GX+Y6QydlP2TSMSmRHoSJK+ShXvXZXSr4Sii4imkj4BwvLunGywwtQDODqg==}
peerDependencies:
react: '*'
react-native: '*'
'@react-native/assets-registry@0.76.5':
resolution: {integrity: sha512-MN5dasWo37MirVcKWuysRkRr4BjNc81SXwUtJYstwbn8oEkfnwR9DaqdDTo/hHOnTdhafffLIa2xOOHcjDIGEw==}
engines: {node: '>=18'}
@ -17868,6 +17886,12 @@ snapshots:
'@gar/promisify@1.1.3': {}
'@gorhom/portal@1.0.14(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)':
dependencies:
nanoid: 3.3.8
react: 18.3.1
react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)
'@grpc/grpc-js@1.9.15':
dependencies:
'@grpc/proto-loader': 0.7.13
@ -19417,6 +19441,11 @@ snapshots:
invariant: 2.2.4
react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)
'@react-native-picker/picker@2.9.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)':
dependencies:
react: 18.3.1
react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)
'@react-native/assets-registry@0.76.5': {}
'@react-native/babel-plugin-codegen@0.76.5(@babel/preset-env@7.26.0(@babel/core@7.26.0))':