diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/api/axiosInstance.ts b/api/_axiosInstance.ts similarity index 70% rename from api/axiosInstance.ts rename to api/_axiosInstance.ts index 89e2059..8d8d47c 100644 --- a/api/axiosInstance.ts +++ b/api/_axiosInstance.ts @@ -1,13 +1,15 @@ +import { deleteAuthToken, getAuthToken } from "@/utils/token-storage"; import axios from "axios"; + const axiosInstance = axios.create({ baseURL: process.env.EXPO_PUBLIC_BACKEND_BASE_URL, timeout: 30 * 1000 }); axiosInstance.interceptors.request.use( - (config) => { - const token = localStorage.getItem("access_token"); + async config => { + const token = await getAuthToken(); if (token) config.headers.Authorization = `Bearer ${token}`; return config; @@ -16,7 +18,7 @@ axiosInstance.interceptors.request.use( ); axiosInstance.interceptors.response.use(undefined, async (error) => { if (error.response?.status === 401) { - localStorage.removeItem("access_token"); + await deleteAuthToken(); } throw error; diff --git a/api/_client.ts b/api/_client.ts new file mode 100644 index 0000000..ccb304d --- /dev/null +++ b/api/_client.ts @@ -0,0 +1,39 @@ +import axiosInstance from "./_axiosInstance"; +import { AuthLoginResponse, Pagination, QuestionResponse, UserResponse } from "./types"; + +export const get_questions = async (page: number, test_id?: number, question_id?: number) => { + const response = await axiosInstance.get>("/api/questions/", { + params: { + page, + test_id, + question_id + } + }) + + return response.data; +} + +export const get_question = async (id: number) => { + const response = await axiosInstance.get<{ + data: QuestionResponse + }>(`/api/questions/${id}`); + + return response.data; +} + +export const get_current_user = async () => { + const response = await axiosInstance.get<{ + user: UserResponse + }>("/api/auth/me/"); + + return response.data; +} + +export const post_login = async (email: string, password: string) => { + const response = await axiosInstance.post("/api/auth/login/", { + email, + password + }); + + return response.data; +} \ No newline at end of file diff --git a/api/auth.ts b/api/auth.ts new file mode 100644 index 0000000..ea36bf2 --- /dev/null +++ b/api/auth.ts @@ -0,0 +1,34 @@ +import { setAuthToken } from "@/utils/token-storage" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { AxiosError } from "axios" +import { get_current_user, post_login } from "./_client" +import { UserResponse } from "./types" + +export const useCurrentUser = () => { + return useQuery({ + queryKey: ["current-user"], + queryFn: async () => { + const result = await get_current_user(); + return result.user; + }, + }) +} + +interface LoginFuncAttrs { + email: string, + password: string +} +export const useLoginMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ email, password }: LoginFuncAttrs) => post_login(email, password), + onSuccess: async (data) => { + await setAuthToken(data.access_token); + + queryClient.invalidateQueries({ + queryKey: ["current-user"] + }); + } + }); +} \ No newline at end of file diff --git a/api/client.ts b/api/client.ts deleted file mode 100644 index ee28664..0000000 --- a/api/client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import axiosInstance from "./axiosInstance"; -import { Pagination, QuestionResponse } from "./types"; - -export const get_questions = async (page: number, test_id?: number, question_id?: number) => { - const response = await axiosInstance.get>("/api/questions/", { - params: { - page, - test_id, - question_id - } - }) - - return response.data; -} diff --git a/api/questions.ts b/api/questions.ts index 39bf8eb..aa34fed 100644 --- a/api/questions.ts +++ b/api/questions.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { get_questions } from "./client"; +import { get_question, get_questions } from "./_client"; interface useQuestionsAttr { page?: number; @@ -13,3 +13,13 @@ export const useQuestions = ({ page = 1, test_id, question_id }: useQuestionsAtt }) } + +export const useQuestion = (id: number) => { + return useQuery({ + queryKey: ['question', id], + queryFn: async () => { + const res = await get_question(id); + return res.data; + } + }) +} \ No newline at end of file diff --git a/api/types.ts b/api/types.ts index c0412e5..2c4be95 100644 --- a/api/types.ts +++ b/api/types.ts @@ -64,4 +64,9 @@ export interface UserResponse { type: UserTypes; created_at: string; updated_at: string; +} +export interface AuthLoginResponse { + access_token: string; + token_type: string; + user: UserResponse; } \ No newline at end of file diff --git a/app.json b/app.json index 79ee422..42340dd 100644 --- a/app.json +++ b/app.json @@ -38,7 +38,8 @@ "backgroundColor": "#000000" } } - ] + ], + "expo-secure-store" ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6d29145..9b77a9d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -6,10 +6,15 @@ import { HelloWave } from "@/components/hello-wave"; import ParallaxScrollView from "@/components/parallax-scroll-view"; import Question from "@/components/question"; import { ThemedView } from "@/components/themed-view"; +import useAuthContext from "@/components/ui/auth-provider/hook"; import { ThemedText } from "@/components/ui/themed-text"; +import { router } from "expo-router"; export default function HomeScreen() { const { data: questions, isLoading: isLoadingQuestions } = useQuestions(); + const { user, isAuthorized } = useAuthContext(); + + const username = isAuthorized ? user!.username : "Guest"; const questionsLoaded = !isLoadingQuestions && questions && questions.meta.total > 0; @@ -25,13 +30,18 @@ export default function HomeScreen() { } > - Questions! + Welcome, {username}! + Questions {questionsLoaded && questions.data.map((question) => ( - + router.push(`/questions/${question.id}`)} + /> ))} ); diff --git a/app/(tabs)/me.tsx b/app/(tabs)/me.tsx index ba6b0d8..4182983 100644 --- a/app/(tabs)/me.tsx +++ b/app/(tabs)/me.tsx @@ -1,57 +1,20 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedView } from '@/components/themed-view'; -import { ThemedText } from '@/components/ui/themed-text'; +import useAuthContext from '@/components/ui/auth-provider/hook'; +import { Button, ButtonText } from '@/components/ui/button'; +import Content from '@/components/ui/content'; +import { Divider } from '@/components/ui/divider'; +import UserHeader from '@/components/user-header'; + +export default function MeScreen() { + const { user, logout, isAuthorized } = useAuthContext(); -export default function HomeScreen() { return ( - - }> - - Welcome! - - - Step 1: Try it - - Edit app/(tabs)/index.tsx to see changes. - Press{' '} - - {Platform.select({ - ios: 'cmd + d', - android: 'cmd + m', - web: 'F12', - })} - {' '} - to open developer tools. - - - - ); -} + + -const styles = StyleSheet.create({ - titleContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - stepContainer: { - gap: 8, - marginBottom: 8, - }, - reactLogo: { - height: 178, - width: 290, - bottom: 0, - left: 0, - position: 'absolute', - }, -}); + + + {isAuthorized && } + + ); +} \ No newline at end of file diff --git a/app/(tabs)/questions.tsx b/app/(tabs)/questions.tsx index 9decaba..ecc05f1 100644 --- a/app/(tabs)/questions.tsx +++ b/app/(tabs)/questions.tsx @@ -1,112 +1,32 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; +import { View } from 'react-native'; -import { ExternalLink } from '@/components/external-link'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedView } from '@/components/themed-view'; -import { Collapsible } from '@/components/ui/collapsible'; -import { IconSymbol } from '@/components/ui/icon-symbol'; +import { useQuestions } from '@/api'; +import Question from '@/components/question'; +import Content from '@/components/ui/content'; import { ThemedText } from '@/components/ui/themed-text'; -import { Fonts } from '@/constants/theme'; +import { router } from 'expo-router'; + +export default function QuestionsScreen() { + const { data: questions, isLoading: isLoadingQuestions } = useQuestions(); + + const questionsLoaded = + !isLoadingQuestions && questions && questions.meta.total > 0; -export default function TabTwoScreen() { return ( - - }> - - - Explore - - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful{' '} - - react-native-reanimated - {' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - - ); -} + + Questions -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); + + {questionsLoaded && + questions.data.map((question) => ( + router.push(`/questions/${question.id}`)} + /> + ))} + + + + ) +} \ No newline at end of file diff --git a/app/(tabs)/tests.tsx b/app/(tabs)/tests.tsx index ba6b0d8..b410ce2 100644 --- a/app/(tabs)/tests.tsx +++ b/app/(tabs)/tests.tsx @@ -1,57 +1,11 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedView } from '@/components/themed-view'; +import Content from '@/components/ui/content'; import { ThemedText } from '@/components/ui/themed-text'; -export default function HomeScreen() { +export default function TestsScreen() { return ( - - }> - - Welcome! - - - Step 1: Try it - - Edit app/(tabs)/index.tsx to see changes. - Press{' '} - - {Platform.select({ - ios: 'cmd + d', - android: 'cmd + m', - web: 'F12', - })} - {' '} - to open developer tools. - - - + + Tests + ); } - -const styles = StyleSheet.create({ - titleContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - stepContainer: { - gap: 8, - marginBottom: 8, - }, - reactLogo: { - height: 178, - width: 290, - bottom: 0, - left: 0, - position: 'absolute', - }, -}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 3203d9d..b91e533 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,29 +1,50 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Stack } from 'expo-router'; -import { StatusBar } from 'expo-status-bar'; -import 'react-native-reanimated'; +import { + DarkTheme, + DefaultTheme, + ThemeProvider, +} from "@react-navigation/native"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import "react-native-reanimated"; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { useColorScheme } from "@/hooks/use-color-scheme"; + +import AuthProvider from "@/components/ui/auth-provider"; +import { GluestackUIProvider } from "@/components/ui/gluestack-ui-provider"; +import "@/global.css"; +import { ToastProvider } from 'react-native-toast-notifications'; export const unstable_settings = { - anchor: '(tabs)', + anchor: "(tabs)", }; const queryClient = new QueryClient(); export default function RootLayout() { const colorScheme = useColorScheme(); + const mode = colorScheme === "dark" ? "dark" : "light"; return ( - - - - - - - - - + + + + + + + + + + + + + + + ); } diff --git a/app/login.tsx b/app/login.tsx new file mode 100644 index 0000000..4561db0 --- /dev/null +++ b/app/login.tsx @@ -0,0 +1,39 @@ +import useAuthContext from "@/components/ui/auth-provider/hook"; +import Content from "@/components/ui/content"; +import LoginForm, { LoginFormData } from "@/components/ui/login-form"; +import Panel from "@/components/ui/panel"; +import { ThemedText } from "@/components/ui/themed-text"; +import getErrorAxiosMessage from "@/utils/get-error-axios-message"; +import { Stack } from "expo-router"; +import { useToast } from "react-native-toast-notifications"; + +const LoginScreen = () => { + const { login, isPendingLogin } = useAuthContext(); + const toast = useToast(); + + const onLoginSubmit = (data: LoginFormData) => { + login(data.email, data.password, { + onSuccess: (data) => { + toast.show("Hello, " + data.user.username, { type: "success" }); + }, + onError: (error) => { + toast.show(getErrorAxiosMessage(error), { type: "danger" }); + } + }); + } + + return ( + <> + + + + Login + Hello there + + + + + ) +} + +export default LoginScreen; \ No newline at end of file diff --git a/app/questions/[id].tsx b/app/questions/[id].tsx new file mode 100644 index 0000000..3e17747 --- /dev/null +++ b/app/questions/[id].tsx @@ -0,0 +1,23 @@ +import { useQuestion } from "@/api"; +import Question from "@/components/question"; +import Content from "@/components/ui/content"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Stack, useLocalSearchParams } from "expo-router"; + +const QuestionScreen = () => { + const { id: idParam } = useLocalSearchParams<{ id: string }>(); + const id = +idParam; + const { data, isLoading } = useQuestion(id); + + return ( + <> + + + {isLoading && } + { data && } + + + ) +} + +export default QuestionScreen; \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..c854b8b --- /dev/null +++ b/babel.config.js @@ -0,0 +1,22 @@ +module.exports = function (api) { + api.cache(true); + + return { + presets: [['babel-preset-expo'], 'nativewind/babel'], + + plugins: [ + [ + 'module-resolver', + { + root: ['./'], + + alias: { + '@': './', + 'tailwind.config': './tailwind.config.js', + }, + }, + ], + 'react-native-worklets/plugin', + ], + }; +}; diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx index 6f674a7..2628502 100644 --- a/components/parallax-scroll-view.tsx +++ b/components/parallax-scroll-view.tsx @@ -8,6 +8,7 @@ import Animated, { } from 'react-native-reanimated'; import { ThemedView } from '@/components/themed-view'; +import { ContentPadding } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { useThemeColor } from '@/hooks/use-theme-color'; @@ -72,7 +73,7 @@ const styles = StyleSheet.create({ }, content: { flex: 1, - padding: 32, + padding: ContentPadding, gap: 16, overflow: 'hidden', }, diff --git a/components/themed-view.tsx b/components/themed-view.tsx index 6f181d8..fa06570 100644 --- a/components/themed-view.tsx +++ b/components/themed-view.tsx @@ -8,7 +8,7 @@ export type ThemedViewProps = ViewProps & { }; export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { - const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); + const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'defaultBackground'); return ; } diff --git a/components/ui/auth-provider/context.tsx b/components/ui/auth-provider/context.tsx new file mode 100644 index 0000000..41ff0ba --- /dev/null +++ b/components/ui/auth-provider/context.tsx @@ -0,0 +1,30 @@ +import { AuthLoginResponse, UserResponse } from "@/api/types"; +import { AxiosError } from "axios"; +import { createContext } from "react"; + +export interface LoginOptions { + onSuccess?: (data: AuthLoginResponse) => void; + onError?: (error: AxiosError) => void +} + +interface AuthContextType { + user?: UserResponse; + isAuthorized: boolean; + isLoading: boolean; + login: (email: string, password: string, options?: LoginOptions) => void; + logout: () => void; + errorLogin?: Error|null; + isPendingLogin: boolean; +} + +const AuthContext = createContext({ + user: undefined, + isAuthorized: false, + isLoading: false, + login: (email, password) => {}, + logout: () => {}, + errorLogin: null, + isPendingLogin: false +}); + +export default AuthContext; \ No newline at end of file diff --git a/components/ui/auth-provider/hook.ts b/components/ui/auth-provider/hook.ts new file mode 100644 index 0000000..199c8e3 --- /dev/null +++ b/components/ui/auth-provider/hook.ts @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import AuthContext from "./context"; + +const useAuthContext = () => useContext(AuthContext); + +export default useAuthContext; \ No newline at end of file diff --git a/components/ui/auth-provider/index.tsx b/components/ui/auth-provider/index.tsx new file mode 100644 index 0000000..7d96089 --- /dev/null +++ b/components/ui/auth-provider/index.tsx @@ -0,0 +1,80 @@ +import { useCurrentUser, useLoginMutation } from "@/api/auth"; +import { deleteAuthToken, getAuthToken } from "@/utils/token-storage"; +import { useQueryClient } from "@tanstack/react-query"; +import { router } from "expo-router"; +import { ReactNode, useEffect, useState } from "react"; +import AuthContext, { LoginOptions } from "./context"; + +interface AuthProviderProps { + children: ReactNode; +} + + +function AuthProvider({ children }: AuthProviderProps) { + const [storageStatus, setStorageStatus] = useState<"pending"|"hasKey"|"noKey">("pending"); + const { data: user, isLoading: isLoadingUser, refetch: refetchUser } = useCurrentUser(); + const { + mutate: mutateLogin, + isPending: isPendingLogin, + error: errorLogin, + } = useLoginMutation(); + const queryClient = useQueryClient(); + + useEffect(() => { + const authTokenFunc = async () => { + const token = await getAuthToken() + if(token) + setStorageStatus("hasKey"); + else + setStorageStatus("noKey"); + } + authTokenFunc(); + }, []); + + const isAuthorized = !!user; + const isLoading = (isLoadingUser || isPendingLogin) && storageStatus === "hasKey"; + + const login = (email: string, password: string, options?: LoginOptions) => { + mutateLogin({ + email, + password, + }, { + onSuccess: (data) => { + refetchUser(); + setStorageStatus("hasKey"); + router.push("/(tabs)/me"); + + if(!options) + return; + + if(options.onSuccess) + options.onSuccess(data); + }, + onError: (error) => { + if(!options) + return; + + if(options.onError) + options.onError(error); + } + }); + }; + const logout = async () => { + await deleteAuthToken(); + setStorageStatus("noKey"); + queryClient.invalidateQueries({ + queryKey: ["current-user"] + }); + queryClient.setQueryData(["current-user"], null); + }; + + return ( + + {children} + + ); +} + +export default AuthProvider; diff --git a/components/ui/avatar/index.tsx b/components/ui/avatar/index.tsx new file mode 100644 index 0000000..20acba7 --- /dev/null +++ b/components/ui/avatar/index.tsx @@ -0,0 +1,185 @@ +'use client'; +import React from 'react'; +import { createAvatar } from '@gluestack-ui/core/avatar/creator'; + +import { View, Text, Image, Platform } from 'react-native'; + +import { tva } from '@gluestack-ui/utils/nativewind-utils'; +import { + withStyleContext, + useStyleContext, +} from '@gluestack-ui/utils/nativewind-utils'; +const SCOPE = 'AVATAR'; +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; + +const UIAvatar = createAvatar({ + Root: withStyleContext(View, SCOPE), + Badge: View, + Group: View, + Image: Image, + FallbackText: Text, +}); + +const avatarStyle = tva({ + base: 'rounded-full justify-center items-center relative bg-primary-600 group-[.avatar-group]/avatar-group:-ml-2.5', + variants: { + size: { + 'xs': 'w-6 h-6', + 'sm': 'w-8 h-8', + 'md': 'w-12 h-12', + 'lg': 'w-16 h-16', + 'xl': 'w-24 h-24', + '2xl': 'w-32 h-32', + }, + }, +}); + +const avatarFallbackTextStyle = tva({ + base: 'text-typography-0 font-semibold overflow-hidden text-transform:uppercase web:cursor-default', + + parentVariants: { + size: { + 'xs': 'text-2xs', + 'sm': 'text-xs', + 'md': 'text-base', + 'lg': 'text-xl', + 'xl': 'text-3xl', + '2xl': 'text-5xl', + }, + }, +}); + +const avatarGroupStyle = tva({ + base: 'group/avatar-group flex-row-reverse relative avatar-group', +}); + +const avatarBadgeStyle = tva({ + base: 'w-5 h-5 bg-success-500 rounded-full absolute right-0 bottom-0 border-background-0 border-2', + parentVariants: { + size: { + 'xs': 'w-2 h-2', + 'sm': 'w-2 h-2', + 'md': 'w-3 h-3', + 'lg': 'w-4 h-4', + 'xl': 'w-6 h-6', + '2xl': 'w-8 h-8', + }, + }, +}); + +const avatarImageStyle = tva({ + base: 'h-full w-full rounded-full absolute', +}); + +type IAvatarProps = Omit< + React.ComponentPropsWithoutRef, + 'context' +> & + VariantProps; + +const Avatar = React.forwardRef< + React.ComponentRef, + IAvatarProps +>(function Avatar({ className, size = 'md', ...props }, ref) { + return ( + + ); +}); + +type IAvatarBadgeProps = React.ComponentPropsWithoutRef & + VariantProps; + +const AvatarBadge = React.forwardRef< + React.ComponentRef, + IAvatarBadgeProps +>(function AvatarBadge({ className, size, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + + return ( + + ); +}); + +type IAvatarFallbackTextProps = React.ComponentPropsWithoutRef< + typeof UIAvatar.FallbackText +> & + VariantProps; +const AvatarFallbackText = React.forwardRef< + React.ComponentRef, + IAvatarFallbackTextProps +>(function AvatarFallbackText({ className, size, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + + return ( + + ); +}); + +type IAvatarImageProps = React.ComponentPropsWithoutRef & + VariantProps; + +const AvatarImage = React.forwardRef< + React.ComponentRef, + IAvatarImageProps +>(function AvatarImage({ className, ...props }, ref) { + return ( + + ); +}); + +type IAvatarGroupProps = React.ComponentPropsWithoutRef & + VariantProps; + +const AvatarGroup = React.forwardRef< + React.ComponentRef, + IAvatarGroupProps +>(function AvatarGroup({ className, ...props }, ref) { + return ( + + ); +}); + +export { Avatar, AvatarBadge, AvatarFallbackText, AvatarImage, AvatarGroup }; diff --git a/components/ui/button/index.tsx b/components/ui/button/index.tsx new file mode 100644 index 0000000..fd4b639 --- /dev/null +++ b/components/ui/button/index.tsx @@ -0,0 +1,434 @@ +'use client'; +import React from 'react'; +import { createButton } from '@gluestack-ui/core/button/creator'; +import { + tva, + withStyleContext, + useStyleContext, + type VariantProps, +} from '@gluestack-ui/utils/nativewind-utils'; +import { cssInterop } from 'nativewind'; +import { ActivityIndicator, Pressable, Text, View } from 'react-native'; +import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator'; + +const SCOPE = 'BUTTON'; + +const Root = withStyleContext(Pressable, SCOPE); + +const UIButton = createButton({ + Root: Root, + Text, + Group: View, + Spinner: ActivityIndicator, + Icon: UIIcon, +}); + +cssInterop(PrimitiveIcon, { + className: { + target: 'style', + nativeStyleToProp: { + height: true, + width: true, + fill: true, + color: 'classNameColor', + stroke: true, + }, + }, +}); + +const buttonStyle = tva({ + base: 'group/button rounded bg-primary-500 flex-row items-center justify-center data-[focus-visible=true]:web:outline-none data-[focus-visible=true]:web:ring-2 data-[disabled=true]:opacity-40 gap-2', + variants: { + action: { + primary: + 'bg-primary-500 data-[hover=true]:bg-primary-600 data-[active=true]:bg-primary-700 border-primary-300 data-[hover=true]:border-primary-400 data-[active=true]:border-primary-500 data-[focus-visible=true]:web:ring-indicator-info', + secondary: + 'bg-secondary-500 border-secondary-300 data-[hover=true]:bg-secondary-600 data-[hover=true]:border-secondary-400 data-[active=true]:bg-secondary-700 data-[active=true]:border-secondary-700 data-[focus-visible=true]:web:ring-indicator-info', + positive: + 'bg-success-500 border-success-300 data-[hover=true]:bg-success-600 data-[hover=true]:border-success-400 data-[active=true]:bg-success-700 data-[active=true]:border-success-500 data-[focus-visible=true]:web:ring-indicator-info', + negative: + 'bg-error-500 border-error-300 data-[hover=true]:bg-error-600 data-[hover=true]:border-error-400 data-[active=true]:bg-error-700 data-[active=true]:border-error-500 data-[focus-visible=true]:web:ring-indicator-info', + default: + 'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent', + }, + variant: { + link: 'px-0', + outline: + 'bg-transparent border data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent', + solid: '', + }, + + size: { + xs: 'px-3.5 h-8', + sm: 'px-4 h-9', + md: 'px-5 h-10', + lg: 'px-6 h-11', + xl: 'px-7 h-12', + }, + }, + compoundVariants: [ + { + action: 'primary', + variant: 'link', + class: + 'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent', + }, + { + action: 'secondary', + variant: 'link', + class: + 'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent', + }, + { + action: 'positive', + variant: 'link', + class: + 'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent', + }, + { + action: 'negative', + variant: 'link', + class: + 'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent', + }, + { + action: 'primary', + variant: 'outline', + class: + 'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent', + }, + { + action: 'secondary', + variant: 'outline', + class: + 'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent', + }, + { + action: 'positive', + variant: 'outline', + class: + 'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent', + }, + { + action: 'negative', + variant: 'outline', + class: + 'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent', + }, + ], +}); + +const buttonTextStyle = tva({ + base: 'text-typography-0 font-semibold web:select-none', + parentVariants: { + action: { + primary: + 'text-primary-600 data-[hover=true]:text-primary-600 data-[active=true]:text-primary-700', + secondary: + 'text-typography-500 data-[hover=true]:text-typography-600 data-[active=true]:text-typography-700', + positive: + 'text-success-600 data-[hover=true]:text-success-600 data-[active=true]:text-success-700', + negative: + 'text-error-600 data-[hover=true]:text-error-600 data-[active=true]:text-error-700', + }, + variant: { + link: 'data-[hover=true]:underline data-[active=true]:underline', + outline: '', + solid: + 'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0', + }, + size: { + xs: 'text-xs', + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + xl: 'text-xl', + }, + }, + parentCompoundVariants: [ + { + variant: 'solid', + action: 'primary', + class: + 'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0', + }, + { + variant: 'solid', + action: 'secondary', + class: + 'text-typography-800 data-[hover=true]:text-typography-800 data-[active=true]:text-typography-800', + }, + { + variant: 'solid', + action: 'positive', + class: + 'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0', + }, + { + variant: 'solid', + action: 'negative', + class: + 'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0', + }, + { + variant: 'outline', + action: 'primary', + class: + 'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500', + }, + { + variant: 'outline', + action: 'secondary', + class: + 'text-typography-500 data-[hover=true]:text-primary-600 data-[active=true]:text-typography-700', + }, + { + variant: 'outline', + action: 'positive', + class: + 'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500', + }, + { + variant: 'outline', + action: 'negative', + class: + 'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500', + }, + ], +}); + +const buttonIconStyle = tva({ + base: 'fill-none', + parentVariants: { + variant: { + link: 'data-[hover=true]:underline data-[active=true]:underline', + outline: '', + solid: + 'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0', + }, + size: { + xs: 'h-3.5 w-3.5', + sm: 'h-4 w-4', + md: 'h-[18px] w-[18px]', + lg: 'h-[18px] w-[18px]', + xl: 'h-5 w-5', + }, + action: { + primary: + 'text-primary-600 data-[hover=true]:text-primary-600 data-[active=true]:text-primary-700', + secondary: + 'text-typography-500 data-[hover=true]:text-typography-600 data-[active=true]:text-typography-700', + positive: + 'text-success-600 data-[hover=true]:text-success-600 data-[active=true]:text-success-700', + + negative: + 'text-error-600 data-[hover=true]:text-error-600 data-[active=true]:text-error-700', + }, + }, + parentCompoundVariants: [ + { + variant: 'solid', + action: 'primary', + class: + 'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0', + }, + { + variant: 'solid', + action: 'secondary', + class: + 'text-typography-800 data-[hover=true]:text-typography-800 data-[active=true]:text-typography-800', + }, + { + variant: 'solid', + action: 'positive', + class: + 'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0', + }, + { + variant: 'solid', + action: 'negative', + class: + 'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0', + }, + ], +}); + +const buttonGroupStyle = tva({ + base: '', + variants: { + space: { + 'xs': 'gap-1', + 'sm': 'gap-2', + 'md': 'gap-3', + 'lg': 'gap-4', + 'xl': 'gap-5', + '2xl': 'gap-6', + '3xl': 'gap-7', + '4xl': 'gap-8', + }, + isAttached: { + true: 'gap-0', + }, + flexDirection: { + 'row': 'flex-row', + 'column': 'flex-col', + 'row-reverse': 'flex-row-reverse', + 'column-reverse': 'flex-col-reverse', + }, + }, +}); + +type IButtonProps = Omit< + React.ComponentPropsWithoutRef, + 'context' +> & + VariantProps & { className?: string }; + +const Button = React.forwardRef< + React.ElementRef, + IButtonProps +>( + ( + { className, variant = 'solid', size = 'md', action = 'primary', ...props }, + ref + ) => { + return ( + + ); + } +); + +type IButtonTextProps = React.ComponentPropsWithoutRef & + VariantProps & { className?: string }; + +const ButtonText = React.forwardRef< + React.ElementRef, + IButtonTextProps +>(({ className, variant, size, action, ...props }, ref) => { + const { + variant: parentVariant, + size: parentSize, + action: parentAction, + } = useStyleContext(SCOPE); + + return ( + + ); +}); + +const ButtonSpinner = UIButton.Spinner; + +type IButtonIcon = React.ComponentPropsWithoutRef & + VariantProps & { + className?: string | undefined; + as?: React.ElementType; + height?: number; + width?: number; + }; + +const ButtonIcon = React.forwardRef< + React.ElementRef, + IButtonIcon +>(({ className, size, ...props }, ref) => { + const { + variant: parentVariant, + size: parentSize, + action: parentAction, + } = useStyleContext(SCOPE); + + if (typeof size === 'number') { + return ( + + ); + } else if ( + (props.height !== undefined || props.width !== undefined) && + size === undefined + ) { + return ( + + ); + } + return ( + + ); +}); + +type IButtonGroupProps = React.ComponentPropsWithoutRef & + VariantProps; + +const ButtonGroup = React.forwardRef< + React.ElementRef, + IButtonGroupProps +>( + ( + { + className, + space = 'md', + isAttached = false, + flexDirection = 'column', + ...props + }, + ref + ) => { + return ( + + ); + } +); + +Button.displayName = 'Button'; +ButtonText.displayName = 'ButtonText'; +ButtonSpinner.displayName = 'ButtonSpinner'; +ButtonIcon.displayName = 'ButtonIcon'; +ButtonGroup.displayName = 'ButtonGroup'; + +export { Button, ButtonText, ButtonSpinner, ButtonIcon, ButtonGroup }; diff --git a/components/ui/content.tsx b/components/ui/content.tsx new file mode 100644 index 0000000..a6cec6f --- /dev/null +++ b/components/ui/content.tsx @@ -0,0 +1,19 @@ +import { ContentPadding } from "@/constants/theme"; +import { ReactNode } from "react"; +import { ScrollView, StyleSheet } from "react-native"; + +interface ContentProps { + children?: ReactNode +} + +const Content = ({ children }: ContentProps) => { + return { children } +} + +const styles = StyleSheet.create({ + content: { + padding: ContentPadding + } +}); + +export default Content; \ No newline at end of file diff --git a/components/ui/divider/index.tsx b/components/ui/divider/index.tsx new file mode 100644 index 0000000..f8a7306 --- /dev/null +++ b/components/ui/divider/index.tsx @@ -0,0 +1,40 @@ +'use client'; +import React from 'react'; +import { tva } from '@gluestack-ui/utils/nativewind-utils'; +import { Platform, View } from 'react-native'; +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; + +const dividerStyle = tva({ + base: 'bg-background-200', + variants: { + orientation: { + vertical: 'w-px h-full', + horizontal: 'h-px w-full', + }, + }, +}); + +type IUIDividerProps = React.ComponentPropsWithoutRef & + VariantProps; + +const Divider = React.forwardRef< + React.ComponentRef, + IUIDividerProps +>(function Divider({ className, orientation = 'horizontal', ...props }, ref) { + return ( + + ); +}); + +Divider.displayName = 'Divider'; + +export { Divider }; diff --git a/components/ui/error-text.tsx b/components/ui/error-text.tsx new file mode 100644 index 0000000..4c11bc0 --- /dev/null +++ b/components/ui/error-text.tsx @@ -0,0 +1,17 @@ +import { useThemeColor } from "@/hooks/use-theme-color"; +import { ReactNode } from "react"; +import { ThemedText } from "./themed-text"; + +interface ErrorTextProps { + children?: ReactNode +} + +const ErrorText = ({ children }: ErrorTextProps) => { + const color = useThemeColor({ }, 'errorText'); + + return ( + {children} + ) +} + +export default ErrorText \ No newline at end of file diff --git a/components/ui/form-control/index.tsx b/components/ui/form-control/index.tsx new file mode 100644 index 0000000..a9056b5 --- /dev/null +++ b/components/ui/form-control/index.tsx @@ -0,0 +1,468 @@ +'use client'; +import { Text, View } from 'react-native'; +import React from 'react'; +import { createFormControl } from '@gluestack-ui/core/form-control/creator'; +import { tva } from '@gluestack-ui/utils/nativewind-utils'; +import { + withStyleContext, + useStyleContext, +} from '@gluestack-ui/utils/nativewind-utils'; +import { cssInterop } from 'nativewind'; +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; +import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator'; + +const SCOPE = 'FORM_CONTROL'; + +const formControlStyle = tva({ + base: 'flex flex-col', + variants: { + size: { + sm: '', + md: '', + lg: '', + }, + }, +}); + +const formControlErrorIconStyle = tva({ + base: 'text-error-700 fill-none', + variants: { + size: { + '2xs': 'h-3 w-3', + 'xs': 'h-3.5 w-3.5', + 'sm': 'h-4 w-4', + 'md': 'h-[18px] w-[18px]', + 'lg': 'h-5 w-5', + 'xl': 'h-6 w-6', + }, + }, +}); + +const formControlErrorStyle = tva({ + base: 'flex flex-row justify-start items-center mt-1 gap-1', +}); + +const formControlErrorTextStyle = tva({ + base: 'text-error-700', + variants: { + isTruncated: { + true: 'web:truncate', + }, + bold: { + true: 'font-bold', + }, + underline: { + true: 'underline', + }, + strikeThrough: { + true: 'line-through', + }, + size: { + '2xs': 'text-2xs', + 'xs': 'text-xs', + 'sm': 'text-sm', + 'md': 'text-base', + 'lg': 'text-lg', + 'xl': 'text-xl', + '2xl': 'text-2xl', + '3xl': 'text-3xl', + '4xl': 'text-4xl', + '5xl': 'text-5xl', + '6xl': 'text-6xl', + }, + sub: { + true: 'text-xs', + }, + italic: { + true: 'italic', + }, + highlight: { + true: 'bg-yellow-500', + }, + }, +}); + +const formControlHelperStyle = tva({ + base: 'flex flex-row justify-start items-center mt-1', +}); + +const formControlHelperTextStyle = tva({ + base: 'text-typography-500', + variants: { + isTruncated: { + true: 'web:truncate', + }, + bold: { + true: 'font-bold', + }, + underline: { + true: 'underline', + }, + strikeThrough: { + true: 'line-through', + }, + size: { + '2xs': 'text-2xs', + 'xs': 'text-xs', + 'sm': 'text-xs', + 'md': 'text-sm', + 'lg': 'text-base', + 'xl': 'text-xl', + '2xl': 'text-2xl', + '3xl': 'text-3xl', + '4xl': 'text-4xl', + '5xl': 'text-5xl', + '6xl': 'text-6xl', + }, + sub: { + true: 'text-xs', + }, + italic: { + true: 'italic', + }, + highlight: { + true: 'bg-yellow-500', + }, + }, +}); + +const formControlLabelStyle = tva({ + base: 'flex flex-row justify-start items-center mb-1', +}); + +const formControlLabelTextStyle = tva({ + base: 'font-medium text-typography-900', + variants: { + isTruncated: { + true: 'web:truncate', + }, + bold: { + true: 'font-bold', + }, + underline: { + true: 'underline', + }, + strikeThrough: { + true: 'line-through', + }, + size: { + '2xs': 'text-2xs', + 'xs': 'text-xs', + 'sm': 'text-sm', + 'md': 'text-base', + 'lg': 'text-lg', + 'xl': 'text-xl', + '2xl': 'text-2xl', + '3xl': 'text-3xl', + '4xl': 'text-4xl', + '5xl': 'text-5xl', + '6xl': 'text-6xl', + }, + sub: { + true: 'text-xs', + }, + italic: { + true: 'italic', + }, + highlight: { + true: 'bg-yellow-500', + }, + }, +}); + +const formControlLabelAstrickStyle = tva({ + base: 'font-medium text-typography-900', + variants: { + isTruncated: { + true: 'web:truncate', + }, + bold: { + true: 'font-bold', + }, + underline: { + true: 'underline', + }, + strikeThrough: { + true: 'line-through', + }, + size: { + '2xs': 'text-2xs', + 'xs': 'text-xs', + 'sm': 'text-sm', + 'md': 'text-base', + 'lg': 'text-lg', + 'xl': 'text-xl', + '2xl': 'text-2xl', + '3xl': 'text-3xl', + '4xl': 'text-4xl', + '5xl': 'text-5xl', + '6xl': 'text-6xl', + }, + sub: { + true: 'text-xs', + }, + italic: { + true: 'italic', + }, + highlight: { + true: 'bg-yellow-500', + }, + }, +}); + +type IFormControlLabelAstrickProps = React.ComponentPropsWithoutRef< + typeof Text +> & + VariantProps; + +const FormControlLabelAstrick = React.forwardRef< + React.ComponentRef, + IFormControlLabelAstrickProps +>(function FormControlLabelAstrick({ className, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + + return ( + + ); +}); + +export const UIFormControl = createFormControl({ + Root: withStyleContext(View, SCOPE), + Error: View, + ErrorText: Text, + ErrorIcon: UIIcon, + Label: View, + LabelText: Text, + LabelAstrick: FormControlLabelAstrick, + Helper: View, + HelperText: Text, +}); + +cssInterop(PrimitiveIcon, { + className: { + target: 'style', + nativeStyleToProp: { + height: true, + width: true, + fill: true, + color: true, + stroke: true, + }, + }, +}); + +type IFormControlProps = React.ComponentProps & + VariantProps; + +const FormControl = React.forwardRef< + React.ComponentRef, + IFormControlProps +>(function FormControl({ className, size = 'md', ...props }, ref) { + return ( + + ); +}); + +type IFormControlErrorProps = React.ComponentProps & + VariantProps; + +const FormControlError = React.forwardRef< + React.ComponentRef, + IFormControlErrorProps +>(function FormControlError({ className, ...props }, ref) { + return ( + + ); +}); + +type IFormControlErrorTextProps = React.ComponentProps< + typeof UIFormControl.Error.Text +> & + VariantProps; + +const FormControlErrorText = React.forwardRef< + React.ComponentRef, + IFormControlErrorTextProps +>(function FormControlErrorText({ className, size, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + return ( + + ); +}); + +type IFormControlErrorIconProps = React.ComponentProps< + typeof UIFormControl.Error.Icon +> & + VariantProps & { + height?: number; + width?: number; + }; + +const FormControlErrorIcon = React.forwardRef< + React.ComponentRef, + IFormControlErrorIconProps +>(function FormControlErrorIcon({ className, size, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + + if (typeof size === 'number') { + return ( + + ); + } else if ( + (props.height !== undefined || props.width !== undefined) && + size === undefined + ) { + return ( + + ); + } + return ( + + ); +}); + +type IFormControlLabelProps = React.ComponentProps & + VariantProps; + +const FormControlLabel = React.forwardRef< + React.ComponentRef, + IFormControlLabelProps +>(function FormControlLabel({ className, ...props }, ref) { + return ( + + ); +}); + +type IFormControlLabelTextProps = React.ComponentProps< + typeof UIFormControl.Label.Text +> & + VariantProps; + +const FormControlLabelText = React.forwardRef< + React.ComponentRef, + IFormControlLabelTextProps +>(function FormControlLabelText({ className, size, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + + return ( + + ); +}); + +type IFormControlHelperProps = React.ComponentProps< + typeof UIFormControl.Helper +> & + VariantProps; + +const FormControlHelper = React.forwardRef< + React.ComponentRef, + IFormControlHelperProps +>(function FormControlHelper({ className, ...props }, ref) { + return ( + + ); +}); + +type IFormControlHelperTextProps = React.ComponentProps< + typeof UIFormControl.Helper.Text +> & + VariantProps; + +const FormControlHelperText = React.forwardRef< + React.ComponentRef, + IFormControlHelperTextProps +>(function FormControlHelperText({ className, size, ...props }, ref) { + const { size: parentSize } = useStyleContext(SCOPE); + + return ( + + ); +}); + +FormControl.displayName = 'FormControl'; +FormControlError.displayName = 'FormControlError'; +FormControlErrorText.displayName = 'FormControlErrorText'; +FormControlErrorIcon.displayName = 'FormControlErrorIcon'; +FormControlLabel.displayName = 'FormControlLabel'; +FormControlLabelText.displayName = 'FormControlLabelText'; +FormControlLabelAstrick.displayName = 'FormControlLabelAstrick'; +FormControlHelper.displayName = 'FormControlHelper'; +FormControlHelperText.displayName = 'FormControlHelperText'; + +export { + FormControl, + FormControlError, + FormControlErrorText, + FormControlErrorIcon, + FormControlLabel, + FormControlLabelText, + FormControlLabelAstrick, + FormControlHelper, + FormControlHelperText, +}; \ No newline at end of file diff --git a/components/ui/form-input.tsx b/components/ui/form-input.tsx new file mode 100644 index 0000000..ccc3661 --- /dev/null +++ b/components/ui/form-input.tsx @@ -0,0 +1,52 @@ +import { ComponentProps } from "react"; +import { Controller } from "react-hook-form"; +import { View } from "react-native"; +import ErrorText from "./error-text"; +import { Input, InputField } from "./input"; +import { ThemedText } from "./themed-text"; + +interface FormInputProps { + name: string; + control: any; + label?: string; + className?: string; + inputProps?: ComponentProps; + fieldProps?: ComponentProps; +} + +const FormInput = ({ + name, + control, + inputProps, + fieldProps, + label, + className, +}: FormInputProps) => { + return ( + + ( + + {label && ( + {label} + )} + + + + {!!renderProps.fieldState.error && ( + {renderProps.fieldState.error.message} + )} + + )} + /> + + ); +}; + +export default FormInput; diff --git a/components/ui/gluestack-ui-provider/config.ts b/components/ui/gluestack-ui-provider/config.ts new file mode 100644 index 0000000..f388cc6 --- /dev/null +++ b/components/ui/gluestack-ui-provider/config.ts @@ -0,0 +1,309 @@ +'use client'; +import { vars } from 'nativewind'; + +export const config = { + light: vars({ + '--color-primary-0': '179 179 179', + '--color-primary-50': '153 153 153', + '--color-primary-100': '128 128 128', + '--color-primary-200': '115 115 115', + '--color-primary-300': '102 102 102', + '--color-primary-400': '82 82 82', + '--color-primary-500': '51 51 51', + '--color-primary-600': '41 41 41', + '--color-primary-700': '31 31 31', + '--color-primary-800': '13 13 13', + '--color-primary-900': '10 10 10', + '--color-primary-950': '8 8 8', + + /* Secondary */ + '--color-secondary-0': '253 253 253', + '--color-secondary-50': '251 251 251', + '--color-secondary-100': '246 246 246', + '--color-secondary-200': '242 242 242', + '--color-secondary-300': '237 237 237', + '--color-secondary-400': '230 230 231', + '--color-secondary-500': '217 217 219', + '--color-secondary-600': '198 199 199', + '--color-secondary-700': '189 189 189', + '--color-secondary-800': '177 177 177', + '--color-secondary-900': '165 164 164', + '--color-secondary-950': '157 157 157', + + /* Tertiary */ + '--color-tertiary-0': '255 250 245', + '--color-tertiary-50': '255 242 229', + '--color-tertiary-100': '255 233 213', + '--color-tertiary-200': '254 209 170', + '--color-tertiary-300': '253 180 116', + '--color-tertiary-400': '251 157 75', + '--color-tertiary-500': '231 129 40', + '--color-tertiary-600': '215 117 31', + '--color-tertiary-700': '180 98 26', + '--color-tertiary-800': '130 73 23', + '--color-tertiary-900': '108 61 19', + '--color-tertiary-950': '84 49 18', + + /* Error */ + '--color-error-0': '254 233 233', + '--color-error-50': '254 226 226', + '--color-error-100': '254 202 202', + '--color-error-200': '252 165 165', + '--color-error-300': '248 113 113', + '--color-error-400': '239 68 68', + '--color-error-500': '230 53 53', + '--color-error-600': '220 38 38', + '--color-error-700': '185 28 28', + '--color-error-800': '153 27 27', + '--color-error-900': '127 29 29', + '--color-error-950': '83 19 19', + + /* Success */ + '--color-success-0': '228 255 244', + '--color-success-50': '202 255 232', + '--color-success-100': '162 241 192', + '--color-success-200': '132 211 162', + '--color-success-300': '102 181 132', + '--color-success-400': '72 151 102', + '--color-success-500': '52 131 82', + '--color-success-600': '42 121 72', + '--color-success-700': '32 111 62', + '--color-success-800': '22 101 52', + '--color-success-900': '20 83 45', + '--color-success-950': '27 50 36', + + /* Warning */ + '--color-warning-0': '255 249 245', + '--color-warning-50': '255 244 236', + '--color-warning-100': '255 231 213', + '--color-warning-200': '254 205 170', + '--color-warning-300': '253 173 116', + '--color-warning-400': '251 149 75', + '--color-warning-500': '231 120 40', + '--color-warning-600': '215 108 31', + '--color-warning-700': '180 90 26', + '--color-warning-800': '130 68 23', + '--color-warning-900': '108 56 19', + '--color-warning-950': '84 45 18', + + /* Info */ + '--color-info-0': '236 248 254', + '--color-info-50': '199 235 252', + '--color-info-100': '162 221 250', + '--color-info-200': '124 207 248', + '--color-info-300': '87 194 246', + '--color-info-400': '50 180 244', + '--color-info-500': '13 166 242', + '--color-info-600': '11 141 205', + '--color-info-700': '9 115 168', + '--color-info-800': '7 90 131', + '--color-info-900': '5 64 93', + '--color-info-950': '3 38 56', + + /* Typography */ + '--color-typography-0': '254 254 255', + '--color-typography-50': '245 245 245', + '--color-typography-100': '229 229 229', + '--color-typography-200': '219 219 220', + '--color-typography-300': '212 212 212', + '--color-typography-400': '163 163 163', + '--color-typography-500': '140 140 140', + '--color-typography-600': '115 115 115', + '--color-typography-700': '82 82 82', + '--color-typography-800': '64 64 64', + '--color-typography-900': '38 38 39', + '--color-typography-950': '23 23 23', + + /* Outline */ + '--color-outline-0': '253 254 254', + '--color-outline-50': '243 243 243', + '--color-outline-100': '230 230 230', + '--color-outline-200': '221 220 219', + '--color-outline-300': '211 211 211', + '--color-outline-400': '165 163 163', + '--color-outline-500': '140 141 141', + '--color-outline-600': '115 116 116', + '--color-outline-700': '83 82 82', + '--color-outline-800': '65 65 65', + '--color-outline-900': '39 38 36', + '--color-outline-950': '26 23 23', + + /* Background */ + '--color-background-0': '255 255 255', + '--color-background-50': '246 246 246', + '--color-background-100': '242 241 241', + '--color-background-200': '220 219 219', + '--color-background-300': '213 212 212', + '--color-background-400': '162 163 163', + '--color-background-500': '142 142 142', + '--color-background-600': '116 116 116', + '--color-background-700': '83 82 82', + '--color-background-800': '65 64 64', + '--color-background-900': '39 38 37', + '--color-background-950': '18 18 18', + + /* Background Special */ + '--color-background-error': '254 241 241', + '--color-background-warning': '255 243 234', + '--color-background-success': '237 252 242', + '--color-background-muted': '247 248 247', + '--color-background-info': '235 248 254', + + /* Focus Ring Indicator */ + '--color-indicator-primary': '55 55 55', + '--color-indicator-info': '83 153 236', + '--color-indicator-error': '185 28 28', + }), + dark: vars({ + '--color-primary-0': '166 166 166', + '--color-primary-50': '175 175 175', + '--color-primary-100': '186 186 186', + '--color-primary-200': '197 197 197', + '--color-primary-300': '212 212 212', + '--color-primary-400': '221 221 221', + '--color-primary-500': '230 230 230', + '--color-primary-600': '240 240 240', + '--color-primary-700': '250 250 250', + '--color-primary-800': '253 253 253', + '--color-primary-900': '254 249 249', + '--color-primary-950': '253 252 252', + + /* Secondary */ + '--color-secondary-0': '20 20 20', + '--color-secondary-50': '23 23 23', + '--color-secondary-100': '31 31 31', + '--color-secondary-200': '39 39 39', + '--color-secondary-300': '44 44 44', + '--color-secondary-400': '56 57 57', + '--color-secondary-500': '63 64 64', + '--color-secondary-600': '86 86 86', + '--color-secondary-700': '110 110 110', + '--color-secondary-800': '135 135 135', + '--color-secondary-900': '150 150 150', + '--color-secondary-950': '164 164 164', + + /* Tertiary */ + '--color-tertiary-0': '84 49 18', + '--color-tertiary-50': '108 61 19', + '--color-tertiary-100': '130 73 23', + '--color-tertiary-200': '180 98 26', + '--color-tertiary-300': '215 117 31', + '--color-tertiary-400': '231 129 40', + '--color-tertiary-500': '251 157 75', + '--color-tertiary-600': '253 180 116', + '--color-tertiary-700': '254 209 170', + '--color-tertiary-800': '255 233 213', + '--color-tertiary-900': '255 242 229', + '--color-tertiary-950': '255 250 245', + + /* Error */ + '--color-error-0': '83 19 19', + '--color-error-50': '127 29 29', + '--color-error-100': '153 27 27', + '--color-error-200': '185 28 28', + '--color-error-300': '220 38 38', + '--color-error-400': '230 53 53', + '--color-error-500': '239 68 68', + '--color-error-600': '249 97 96', + '--color-error-700': '229 91 90', + '--color-error-800': '254 202 202', + '--color-error-900': '254 226 226', + '--color-error-950': '254 233 233', + + /* Success */ + '--color-success-0': '27 50 36', + '--color-success-50': '20 83 45', + '--color-success-100': '22 101 52', + '--color-success-200': '32 111 62', + '--color-success-300': '42 121 72', + '--color-success-400': '52 131 82', + '--color-success-500': '72 151 102', + '--color-success-600': '102 181 132', + '--color-success-700': '132 211 162', + '--color-success-800': '162 241 192', + '--color-success-900': '202 255 232', + '--color-success-950': '228 255 244', + + /* Warning */ + '--color-warning-0': '84 45 18', + '--color-warning-50': '108 56 19', + '--color-warning-100': '130 68 23', + '--color-warning-200': '180 90 26', + '--color-warning-300': '215 108 31', + '--color-warning-400': '231 120 40', + '--color-warning-500': '251 149 75', + '--color-warning-600': '253 173 116', + '--color-warning-700': '254 205 170', + '--color-warning-800': '255 231 213', + '--color-warning-900': '255 244 237', + '--color-warning-950': '255 249 245', + + /* Info */ + '--color-info-0': '3 38 56', + '--color-info-50': '5 64 93', + '--color-info-100': '7 90 131', + '--color-info-200': '9 115 168', + '--color-info-300': '11 141 205', + '--color-info-400': '13 166 242', + '--color-info-500': '50 180 244', + '--color-info-600': '87 194 246', + '--color-info-700': '124 207 248', + '--color-info-800': '162 221 250', + '--color-info-900': '199 235 252', + '--color-info-950': '236 248 254', + + /* Typography */ + '--color-typography-0': '23 23 23', + '--color-typography-50': '38 38 39', + '--color-typography-100': '64 64 64', + '--color-typography-200': '82 82 82', + '--color-typography-300': '115 115 115', + '--color-typography-400': '140 140 140', + '--color-typography-500': '163 163 163', + '--color-typography-600': '212 212 212', + '--color-typography-700': '219 219 220', + '--color-typography-800': '229 229 229', + '--color-typography-900': '245 245 245', + '--color-typography-950': '254 254 255', + + /* Outline */ + '--color-outline-0': '26 23 23', + '--color-outline-50': '39 38 36', + '--color-outline-100': '65 65 65', + '--color-outline-200': '83 82 82', + '--color-outline-300': '115 116 116', + '--color-outline-400': '140 141 141', + '--color-outline-500': '165 163 163', + '--color-outline-600': '211 211 211', + '--color-outline-700': '221 220 219', + '--color-outline-800': '230 230 230', + '--color-outline-900': '243 243 243', + '--color-outline-950': '253 254 254', + + /* Background */ + '--color-background-0': '18 18 18', + '--color-background-50': '39 38 37', + '--color-background-100': '65 64 64', + '--color-background-200': '83 82 82', + '--color-background-300': '116 116 116', + '--color-background-400': '142 142 142', + '--color-background-500': '162 163 163', + '--color-background-600': '213 212 212', + '--color-background-700': '229 228 228', + '--color-background-800': '242 241 241', + '--color-background-900': '246 246 246', + '--color-background-950': '255 255 255', + + /* Background Special */ + '--color-background-error': '66 43 43', + '--color-background-warning': '65 47 35', + '--color-background-success': '28 43 33', + '--color-background-muted': '51 51 51', + '--color-background-info': '26 40 46', + + /* Focus Ring Indicator */ + '--color-indicator-primary': '247 247 247', + '--color-indicator-info': '161 199 245', + '--color-indicator-error': '232 70 69', + }), +}; diff --git a/components/ui/gluestack-ui-provider/index.next15.tsx b/components/ui/gluestack-ui-provider/index.next15.tsx new file mode 100644 index 0000000..4fafc40 --- /dev/null +++ b/components/ui/gluestack-ui-provider/index.next15.tsx @@ -0,0 +1,87 @@ +// This is a Next.js 15 compatible version of the GluestackUIProvider +'use client'; +import React, { useEffect, useLayoutEffect } from 'react'; +import { config } from './config'; +import { OverlayProvider } from '@gluestack-ui/core/overlay/creator'; +import { ToastProvider } from '@gluestack-ui/core/toast/creator'; +import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils'; +import { script } from './script'; + +const variableStyleTagId = 'nativewind-style'; +const createStyle = (styleTagId: string) => { + const style = document.createElement('style'); + style.id = styleTagId; + style.appendChild(document.createTextNode('')); + return style; +}; + +export const useSafeLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export function GluestackUIProvider({ + mode = 'light', + ...props +}: { + mode?: 'light' | 'dark' | 'system'; + children?: React.ReactNode; +}) { + let cssVariablesWithMode = ``; + Object.keys(config).forEach((configKey) => { + cssVariablesWithMode += + configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`; + const cssVariables = Object.keys( + config[configKey as keyof typeof config] + ).reduce((acc: string, curr: string) => { + acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `; + return acc; + }, ''); + cssVariablesWithMode += `${cssVariables} \n}`; + }); + + setFlushStyles(cssVariablesWithMode); + + const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => { + script(e.matches ? 'dark' : 'light'); + }, []); + + useSafeLayoutEffect(() => { + if (mode !== 'system') { + const documentElement = document.documentElement; + if (documentElement) { + documentElement.classList.add(mode); + documentElement.classList.remove(mode === 'light' ? 'dark' : 'light'); + documentElement.style.colorScheme = mode; + } + } + }, [mode]); + + useSafeLayoutEffect(() => { + if (mode !== 'system') return; + const media = window.matchMedia('(prefers-color-scheme: dark)'); + + media.addListener(handleMediaQuery); + + return () => media.removeListener(handleMediaQuery); + }, [handleMediaQuery]); + + useSafeLayoutEffect(() => { + if (typeof window !== 'undefined') { + const documentElement = document.documentElement; + if (documentElement) { + const head = documentElement.querySelector('head'); + let style = head?.querySelector(`[id='${variableStyleTagId}']`); + if (!style) { + style = createStyle(variableStyleTagId); + style.innerHTML = cssVariablesWithMode; + if (head) head.appendChild(style); + } + } + } + }, []); + + return ( + + {props.children} + + ); +} diff --git a/components/ui/gluestack-ui-provider/index.tsx b/components/ui/gluestack-ui-provider/index.tsx new file mode 100644 index 0000000..3453713 --- /dev/null +++ b/components/ui/gluestack-ui-provider/index.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import { config } from './config'; +import { View, ViewProps } from 'react-native'; +import { OverlayProvider } from '@gluestack-ui/core/overlay/creator'; +import { ToastProvider } from '@gluestack-ui/core/toast/creator'; +import { useColorScheme } from 'nativewind'; + +export type ModeType = 'light' | 'dark' | 'system'; + +export function GluestackUIProvider({ + mode = 'light', + ...props +}: { + mode?: ModeType; + children?: React.ReactNode; + style?: ViewProps['style']; +}) { + const { colorScheme, setColorScheme } = useColorScheme(); + + useEffect(() => { + setColorScheme(mode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + + return ( + + + {props.children} + + + ); +} diff --git a/components/ui/gluestack-ui-provider/index.web.tsx b/components/ui/gluestack-ui-provider/index.web.tsx new file mode 100644 index 0000000..610b6ad --- /dev/null +++ b/components/ui/gluestack-ui-provider/index.web.tsx @@ -0,0 +1,96 @@ +'use client'; +import React, { useEffect, useLayoutEffect } from 'react'; +import { config } from './config'; +import { OverlayProvider } from '@gluestack-ui/core/overlay/creator'; +import { ToastProvider } from '@gluestack-ui/core/toast/creator'; +import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils'; +import { script } from './script'; + +export type ModeType = 'light' | 'dark' | 'system'; + +const variableStyleTagId = 'nativewind-style'; +const createStyle = (styleTagId: string) => { + const style = document.createElement('style'); + style.id = styleTagId; + style.appendChild(document.createTextNode('')); + return style; +}; + +export const useSafeLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export function GluestackUIProvider({ + mode = 'light', + ...props +}: { + mode?: ModeType; + children?: React.ReactNode; +}) { + let cssVariablesWithMode = ``; + Object.keys(config).forEach((configKey) => { + cssVariablesWithMode += + configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`; + const cssVariables = Object.keys( + config[configKey as keyof typeof config] + ).reduce((acc: string, curr: string) => { + acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `; + return acc; + }, ''); + cssVariablesWithMode += `${cssVariables} \n}`; + }); + + setFlushStyles(cssVariablesWithMode); + + const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => { + script(e.matches ? 'dark' : 'light'); + }, []); + + useSafeLayoutEffect(() => { + if (mode !== 'system') { + const documentElement = document.documentElement; + if (documentElement) { + documentElement.classList.add(mode); + documentElement.classList.remove(mode === 'light' ? 'dark' : 'light'); + documentElement.style.colorScheme = mode; + } + } + }, [mode]); + + useSafeLayoutEffect(() => { + if (mode !== 'system') return; + const media = window.matchMedia('(prefers-color-scheme: dark)'); + + media.addListener(handleMediaQuery); + + return () => media.removeListener(handleMediaQuery); + }, [handleMediaQuery]); + + useSafeLayoutEffect(() => { + if (typeof window !== 'undefined') { + const documentElement = document.documentElement; + if (documentElement) { + const head = documentElement.querySelector('head'); + let style = head?.querySelector(`[id='${variableStyleTagId}']`); + if (!style) { + style = createStyle(variableStyleTagId); + style.innerHTML = cssVariablesWithMode; + if (head) head.appendChild(style); + } + } + } + }, []); + + return ( + <> +