aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile/components
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-08-26 15:47:05 +0300
committerGitHub <noreply@github.com>2025-08-26 13:47:05 +0100
commited86f7ef012fb558fe8a8974e1e162ce75cbfd15 (patch)
treea3470b0e1a01aede90b75bc61eeba2545e51fe83 /apps/mobile/components
parentec56ea33b5e37d02e87e480da305038a5ce7de49 (diff)
downloadkarakeep-ed86f7ef012fb558fe8a8974e1e162ce75cbfd15.tar.zst
feat(mobile): Retheme the mobile app (#1872)
* Add nativewindui * migrate to nativewindui text * Replace buttons with nativewindui buttons * Use nativewindui search input * fix the divider color * More changes * fix manage tag icon * fix styling of bookmark card * fix ios compilation * fix search clear * fix tag pill border color * Store theme setting in app settings * fix setting color appearance * fix coloring of search input * fix following system theme * add a save button to info * fix the grey colors on android * fix icon active tint color * drop the use of TextField
Diffstat (limited to 'apps/mobile/components')
-rw-r--r--apps/mobile/components/FullPageError.tsx7
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx18
-rw-r--r--apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx9
-rw-r--r--apps/mobile/components/bookmarks/BookmarkList.tsx5
-rw-r--r--apps/mobile/components/bookmarks/PDFViewer.tsx3
-rw-r--r--apps/mobile/components/bookmarks/TagPill.tsx2
-rw-r--r--apps/mobile/components/ui/Button.tsx239
-rw-r--r--apps/mobile/components/ui/ChevronRight.tsx11
-rw-r--r--apps/mobile/components/ui/Divider.tsx2
-rw-r--r--apps/mobile/components/ui/Input.tsx25
-rw-r--r--apps/mobile/components/ui/List.tsx469
-rw-r--r--apps/mobile/components/ui/PageTitle.tsx2
-rw-r--r--apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx187
-rw-r--r--apps/mobile/components/ui/SearchInput/SearchInput.tsx114
-rw-r--r--apps/mobile/components/ui/SearchInput/index.ts1
-rw-r--r--apps/mobile/components/ui/SearchInput/types.ts13
-rw-r--r--apps/mobile/components/ui/Text.tsx52
-rw-r--r--apps/mobile/components/ui/Toast.tsx3
18 files changed, 1059 insertions, 103 deletions
diff --git a/apps/mobile/components/FullPageError.tsx b/apps/mobile/components/FullPageError.tsx
index 57fd62ed..f340d052 100644
--- a/apps/mobile/components/FullPageError.tsx
+++ b/apps/mobile/components/FullPageError.tsx
@@ -1,4 +1,5 @@
-import { Text, View } from "react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/Text";
import { Button } from "./ui/Button";
@@ -16,7 +17,9 @@ export default function FullPageError({
Something Went Wrong
</Text>
<Text className="text-foreground"> {error}</Text>
- <Button onPress={() => onRetry()} label="Retry" />
+ <Button onPress={onRetry}>
+ <Text>Retry</Text>
+ </Button>
</View>
</View>
);
diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx
index 461967b4..e4c2eee8 100644
--- a/apps/mobile/components/bookmarks/BookmarkCard.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx
@@ -7,7 +7,6 @@ import {
Pressable,
ScrollView,
Share,
- Text,
View,
} from "react-native";
import * as Clipboard from "expo-clipboard";
@@ -15,6 +14,7 @@ import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
import { router, useRouter } from "expo-router";
import * as Sharing from "expo-sharing";
+import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
import { api } from "@/lib/trpc";
import { MenuView } from "@react-native-menu/menu";
@@ -332,9 +332,7 @@ function LinkCard({
<TagList bookmark={bookmark} />
<Divider orientation="vertical" className="mt-2 h-0.5 w-full" />
<View className="mt-2 flex flex-row justify-between px-2 pb-2">
- <Text className="my-auto line-clamp-1 text-foreground">
- {parsedUrl.host}
- </Text>
+ <Text className="my-auto line-clamp-1">{parsedUrl.host}</Text>
<ActionBar bookmark={bookmark} />
</View>
</View>
@@ -357,7 +355,7 @@ function TextCard({
<View className="flex max-h-96 gap-2 p-2">
<Pressable onPress={onOpenBookmark}>
{bookmark.title && (
- <Text className="line-clamp-2 text-xl font-bold text-foreground">
+ <Text className="line-clamp-2 text-xl font-bold">
{bookmark.title}
</Text>
)}
@@ -404,9 +402,7 @@ function AssetCard({
<View className="flex gap-2 p-2">
<Pressable onPress={onOpenBookmark}>
{title && (
- <Text className="line-clamp-2 text-xl font-bold text-foreground">
- {title}
- </Text>
+ <Text className="line-clamp-2 text-xl font-bold">{title}</Text>
)}
</Pressable>
<TagList bookmark={bookmark} />
@@ -481,9 +477,5 @@ export default function BookmarkCard({
break;
}
- return (
- <View className="overflow-hidden rounded-xl border-b border-accent bg-background">
- {comp}
- </View>
- );
+ return <View className="overflow-hidden rounded-xl bg-card">{comp}</View>;
}
diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx
index c4a059cc..730bcd08 100644
--- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx
@@ -1,11 +1,12 @@
import { useState } from "react";
-import { Pressable, Text, View } from "react-native";
+import { Pressable, View } from "react-native";
import ImageView from "react-native-image-viewing";
import WebView from "react-native-webview";
import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes";
+import { Text } from "@/components/ui/Text";
import { useAssetUrl } from "@/lib/hooks";
import { api } from "@/lib/trpc";
-import { useColorScheme } from "nativewind";
+import { useColorScheme } from "@/lib/useColorScheme";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
@@ -36,7 +37,7 @@ export function BookmarkLinkReaderPreview({
}: {
bookmark: ZBookmark;
}) {
- const { colorScheme } = useColorScheme();
+ const { isDarkColorScheme: isDark } = useColorScheme();
const {
data: bookmarkWithContent,
@@ -60,8 +61,6 @@ export function BookmarkLinkReaderPreview({
throw new Error("Wrong content type rendered");
}
- const isDark = colorScheme === "dark";
-
return (
<View className="flex-1 bg-background">
<WebView
diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx
index 7be63ed6..adcf12e0 100644
--- a/apps/mobile/components/bookmarks/BookmarkList.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkList.tsx
@@ -1,6 +1,7 @@
import { useRef } from "react";
-import { ActivityIndicator, Keyboard, Text, View } from "react-native";
+import { ActivityIndicator, Keyboard, View } from "react-native";
import Animated, { LinearTransition } from "react-native-reanimated";
+import { Text } from "@/components/ui/Text";
import { useScrollToTop } from "@react-navigation/native";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
@@ -38,7 +39,7 @@ export default function BookmarkList({
renderItem={(b) => <BookmarkCard bookmark={b.item} />}
ListEmptyComponent={
<View className="items-center justify-center pt-4">
- <Text className="text-xl text-foreground">No Bookmarks</Text>
+ <Text variant="title3">No Bookmarks</Text>
</View>
}
data={bookmarks}
diff --git a/apps/mobile/components/bookmarks/PDFViewer.tsx b/apps/mobile/components/bookmarks/PDFViewer.tsx
index 24b9edfb..c6412431 100644
--- a/apps/mobile/components/bookmarks/PDFViewer.tsx
+++ b/apps/mobile/components/bookmarks/PDFViewer.tsx
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from "react";
-import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
+import { ActivityIndicator, StyleSheet, View } from "react-native";
import ReactNativeBlobUtil from "react-native-blob-util";
import Pdf from "react-native-pdf";
+import { Text } from "@/components/ui/Text";
import { useQuery } from "@tanstack/react-query";
import { useColorScheme } from "nativewind";
diff --git a/apps/mobile/components/bookmarks/TagPill.tsx b/apps/mobile/components/bookmarks/TagPill.tsx
index eb9945e5..caf0f636 100644
--- a/apps/mobile/components/bookmarks/TagPill.tsx
+++ b/apps/mobile/components/bookmarks/TagPill.tsx
@@ -7,7 +7,7 @@ export default function TagPill({ tag }: { tag: ZBookmarkTags }) {
return (
<View
key={tag.id}
- className="rounded-full border border-accent px-2.5 py-0.5 text-xs font-semibold"
+ className="rounded-full border border-input px-2.5 py-0.5 text-xs font-semibold"
>
<Link className="text-foreground" href={`dashboard/tags/${tag.id}`}>
{tag.name}
diff --git a/apps/mobile/components/ui/Button.tsx b/apps/mobile/components/ui/Button.tsx
index 0f3b4ab3..312c3129 100644
--- a/apps/mobile/components/ui/Button.tsx
+++ b/apps/mobile/components/ui/Button.tsx
@@ -1,81 +1,200 @@
import type { VariantProps } from "class-variance-authority";
-import { Text, TouchableOpacity } from "react-native";
+import * as React from "react";
+import {
+ Platform,
+ Pressable,
+ PressableProps,
+ View,
+ ViewStyle,
+} from "react-native";
+import { TextClassContext } from "@/components/ui/Text";
+import { useColorScheme } from "@/lib/useColorScheme";
import { cn } from "@/lib/utils";
+import { COLORS } from "@/theme/colors";
+import * as Slot from "@rn-primitives/slot";
import { cva } from "class-variance-authority";
-const buttonVariants = cva(
- "flex flex-row items-center justify-center rounded-md",
- {
- variants: {
- variant: {
- default: "bg-primary",
- secondary: "bg-secondary",
- destructive: "bg-destructive",
- ghost: "bg-slate-700",
- link: "text-primary underline-offset-4",
- },
- size: {
- default: "h-10 px-4",
- sm: "h-8 px-2",
- lg: "h-12 px-8",
- },
+const buttonVariants = cva("flex-row items-center justify-center gap-2", {
+ variants: {
+ variant: {
+ primary: "ios:active:opacity-80 bg-primary",
+ secondary:
+ "ios:border-primary ios:active:bg-primary/5 border border-foreground/40",
+ tonal:
+ "ios:bg-primary/10 dark:ios:bg-primary/10 ios:active:bg-primary/15 bg-primary/15 dark:bg-primary/30",
+ plain: "ios:active:opacity-70",
+ destructive:
+ "ios:bg-destructive border border-destructive/5 bg-destructive/80",
},
- defaultVariants: {
- variant: "default",
- size: "default",
+ size: {
+ none: "",
+ sm: "rounded-full px-2.5 py-1",
+ md: "ios:rounded-lg ios:py-1.5 ios:px-3.5 rounded-full px-5 py-2",
+ lg: "ios:py-2 gap-2 rounded-xl px-5 py-2.5",
+ icon: "ios:rounded-lg h-10 w-10 rounded-full",
},
},
-);
+ defaultVariants: {
+ variant: "primary",
+ size: "md",
+ },
+});
+
+const androidRootVariants = cva("overflow-hidden", {
+ variants: {
+ size: {
+ none: "",
+ icon: "rounded-full",
+ sm: "rounded-full",
+ md: "rounded-full",
+ lg: "rounded-xl",
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ },
+});
-const buttonTextVariants = cva("text-center font-medium", {
+const buttonTextVariants = cva("font-medium", {
variants: {
variant: {
- default: "text-primary-foreground",
- secondary: "text-secondary-foreground",
- destructive: "text-destructive-foreground",
- ghost: "text-primary-foreground",
- link: "text-primary-foreground underline",
+ primary: "text-white",
+ secondary: "ios:text-primary text-foreground",
+ tonal: "ios:text-primary text-foreground",
+ plain: "text-foreground",
+ destructive: "text-white",
},
size: {
- default: "text-base",
- sm: "text-sm",
- lg: "text-xl",
+ none: "",
+ icon: "",
+ sm: "text-[15px] leading-5",
+ md: "text-[17px] leading-7",
+ lg: "text-[17px] leading-7",
},
},
defaultVariants: {
- variant: "default",
- size: "default",
+ variant: "primary",
+ size: "md",
},
});
-interface ButtonProps
- extends React.ComponentPropsWithoutRef<typeof TouchableOpacity>,
- VariantProps<typeof buttonVariants> {
- label: string;
- labelClasses?: string;
+function convertToRGBA(rgb: string, opacity: number): string {
+ const rgbValues = rgb.match(/\d+/g);
+ if (!rgbValues || rgbValues.length !== 3) {
+ throw new Error("Invalid RGB color format");
+ }
+ const red = parseInt(rgbValues[0], 10);
+ const green = parseInt(rgbValues[1], 10);
+ const blue = parseInt(rgbValues[2], 10);
+ if (opacity < 0 || opacity > 1) {
+ throw new Error("Opacity must be a number between 0 and 1");
+ }
+ return `rgba(${red},${green},${blue},${opacity})`;
}
-function Button({
- label,
- labelClasses,
- className,
- variant,
- size,
- ...props
-}: ButtonProps) {
- return (
- <TouchableOpacity
- className={cn(buttonVariants({ variant, size, className }))}
- {...props}
- >
- <Text
- className={cn(
- buttonTextVariants({ variant, size, className: labelClasses }),
- )}
- >
- {label}
- </Text>
- </TouchableOpacity>
- );
+
+const ANDROID_RIPPLE = {
+ dark: {
+ primary: {
+ color: convertToRGBA(COLORS.dark.grey3, 0.4),
+ borderless: false,
+ },
+ secondary: {
+ color: convertToRGBA(COLORS.dark.grey5, 0.8),
+ borderless: false,
+ },
+ plain: { color: convertToRGBA(COLORS.dark.grey5, 0.8), borderless: false },
+ tonal: { color: convertToRGBA(COLORS.dark.grey5, 0.8), borderless: false },
+ destructive: {
+ color: convertToRGBA(COLORS.dark.destructive, 0.8),
+ borderless: false,
+ },
+ },
+ light: {
+ primary: {
+ color: convertToRGBA(COLORS.light.grey4, 0.4),
+ borderless: false,
+ },
+ secondary: {
+ color: convertToRGBA(COLORS.light.grey5, 0.4),
+ borderless: false,
+ },
+ plain: { color: convertToRGBA(COLORS.light.grey5, 0.4), borderless: false },
+ tonal: { color: convertToRGBA(COLORS.light.grey6, 0.4), borderless: false },
+ destructive: {
+ color: convertToRGBA(COLORS.light.destructive, 0.4),
+ borderless: false,
+ },
+ },
+};
+
+// Add as class when possible: https://github.com/marklawlor/nativewind/issues/522
+const BORDER_CURVE: ViewStyle = {
+ borderCurve: "continuous",
+};
+
+type ButtonVariantProps = Omit<
+ VariantProps<typeof buttonVariants>,
+ "variant"
+> & {
+ variant?: Exclude<VariantProps<typeof buttonVariants>["variant"], null>;
+};
+
+interface AndroidOnlyButtonProps {
+ /**
+ * ANDROID ONLY: The class name of root responsible for hidding the ripple overflow.
+ */
+ androidRootClassName?: string;
}
-export { Button, buttonVariants, buttonTextVariants };
+type ButtonProps = PressableProps & ButtonVariantProps & AndroidOnlyButtonProps;
+
+const Root = Platform.OS === "android" ? View : Slot.Pressable;
+
+const Button = React.forwardRef<
+ React.ElementRef<typeof Pressable>,
+ ButtonProps
+>(
+ (
+ {
+ className,
+ variant = "primary",
+ size,
+ style = BORDER_CURVE,
+ androidRootClassName,
+ ...props
+ },
+ ref,
+ ) => {
+ const { colorScheme } = useColorScheme();
+
+ return (
+ <TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
+ <Root
+ className={Platform.select({
+ ios: androidRootClassName,
+ default: androidRootVariants({
+ size,
+ className: androidRootClassName,
+ }),
+ })}
+ >
+ <Pressable
+ className={cn(
+ props.disabled && "opacity-50",
+ buttonVariants({ variant, size, className }),
+ )}
+ ref={ref}
+ style={style}
+ android_ripple={ANDROID_RIPPLE[colorScheme][variant]}
+ {...props}
+ />
+ </Root>
+ </TextClassContext.Provider>
+ );
+ },
+);
+
+Button.displayName = "Button";
+
+export { Button, buttonTextVariants, buttonVariants };
+export type { ButtonProps };
diff --git a/apps/mobile/components/ui/ChevronRight.tsx b/apps/mobile/components/ui/ChevronRight.tsx
new file mode 100644
index 00000000..5b9af6e1
--- /dev/null
+++ b/apps/mobile/components/ui/ChevronRight.tsx
@@ -0,0 +1,11 @@
+import { useColorScheme } from "@/lib/useColorScheme";
+import { ChevronRightIcon } from "lucide-react-native";
+
+export default function ChevronRight({
+ color,
+ ...props
+}: React.ComponentProps<typeof ChevronRightIcon>) {
+ const { colors } = useColorScheme();
+
+ return <ChevronRightIcon color={color ?? colors.grey} {...props} />;
+}
diff --git a/apps/mobile/components/ui/Divider.tsx b/apps/mobile/components/ui/Divider.tsx
index fbc5cf64..bcc6144f 100644
--- a/apps/mobile/components/ui/Divider.tsx
+++ b/apps/mobile/components/ui/Divider.tsx
@@ -12,7 +12,7 @@ function Divider({
return (
<View
className={cn(
- "bg-accent",
+ "bg-slate-400/20 dark:bg-border/50",
orientation === "horizontal" ? "h-0.5" : "w-0.5",
className,
)}
diff --git a/apps/mobile/components/ui/Input.tsx b/apps/mobile/components/ui/Input.tsx
index 2bd5e190..7f3a48e5 100644
--- a/apps/mobile/components/ui/Input.tsx
+++ b/apps/mobile/components/ui/Input.tsx
@@ -1,10 +1,9 @@
import type { TextInputProps } from "react-native";
import { forwardRef } from "react";
-import { ActivityIndicator, Text, TextInput, View } from "react-native";
+import { ActivityIndicator, TextInput, View } from "react-native";
+import { Text } from "@/components/ui/Text";
import { cn } from "@/lib/utils";
-import { TailwindResolver } from "../TailwindResolver";
-
export interface InputProps extends TextInputProps {
label?: string;
labelClasses?: string;
@@ -22,20 +21,14 @@ export const Input = forwardRef<TextInput, InputProps>(
{label && (
<Text className={cn("text-base", labelClasses)}>{label}</Text>
)}
- <TailwindResolver
- className="text-gray-400"
- comp={(styles) => (
- <TextInput
- ref={ref}
- placeholderTextColor={styles?.color?.toString()}
- className={cn(
- "bg-background text-foreground",
- inputClasses,
- "rounded-lg border border-input px-4 py-2.5",
- )}
- {...props}
- />
+ <TextInput
+ ref={ref}
+ className={cn(
+ "flex h-10 w-full min-w-0 flex-row items-center rounded-md border border-input text-base leading-5 text-foreground shadow-sm shadow-black/5 dark:bg-input/30 sm:h-9",
+ "rounded-lg border border-input px-4 py-2.5 placeholder:text-muted-foreground/50",
+ inputClasses,
)}
+ {...props}
/>
{loading && (
<ActivityIndicator className="absolute bottom-0 right-0 p-2" />
diff --git a/apps/mobile/components/ui/List.tsx b/apps/mobile/components/ui/List.tsx
new file mode 100644
index 00000000..52ff5779
--- /dev/null
+++ b/apps/mobile/components/ui/List.tsx
@@ -0,0 +1,469 @@
+import type {
+ FlashListProps,
+ ListRenderItem as FlashListRenderItem,
+ ListRenderItemInfo,
+} from "@shopify/flash-list";
+import * as React from "react";
+import {
+ Platform,
+ PressableProps,
+ StyleProp,
+ TextStyle,
+ View,
+ ViewProps,
+ ViewStyle,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Button } from "@/components/ui/Button";
+import { Text, TextClassContext } from "@/components/ui/Text";
+import { cn } from "@/lib/utils";
+import { FlashList } from "@shopify/flash-list";
+import { cva } from "class-variance-authority";
+import { cssInterop } from "nativewind";
+
+cssInterop(FlashList, {
+ className: "style",
+ contentContainerClassName: "contentContainerStyle",
+});
+
+type ListDataItem = string | { title: string; subTitle?: string };
+type ListVariant = "insets" | "full-width";
+
+type ListRef<T extends ListDataItem> = React.Ref<typeof FlashList<T>>;
+
+type ListRenderItemProps<T extends ListDataItem> = ListRenderItemInfo<T> & {
+ variant?: ListVariant;
+ isFirstInSection?: boolean;
+ isLastInSection?: boolean;
+ sectionHeaderAsGap?: boolean;
+};
+
+type ListProps<T extends ListDataItem> = Omit<
+ FlashListProps<T>,
+ "renderItem"
+> & {
+ renderItem?: ListRenderItem<T>;
+ variant?: ListVariant;
+ sectionHeaderAsGap?: boolean;
+ rootClassName?: string;
+ rootStyle?: StyleProp<ViewStyle>;
+};
+type ListRenderItem<T extends ListDataItem> = (
+ props: ListRenderItemProps<T>,
+) => ReturnType<FlashListRenderItem<T>>;
+
+const rootVariants = cva("min-h-2 flex-1", {
+ variants: {
+ variant: {
+ insets: "ios:px-4",
+ "full-width": "ios:bg-card ios:dark:bg-background",
+ },
+ sectionHeaderAsGap: {
+ true: "",
+ false: "",
+ },
+ },
+ compoundVariants: [
+ {
+ variant: "full-width",
+ sectionHeaderAsGap: true,
+ className: "bg-card dark:bg-background",
+ },
+ ],
+ defaultVariants: {
+ variant: "full-width",
+ sectionHeaderAsGap: false,
+ },
+});
+
+function ListComponent<T extends ListDataItem>({
+ variant = "full-width",
+ rootClassName,
+ rootStyle,
+ contentContainerClassName,
+ renderItem,
+ data,
+ sectionHeaderAsGap = false,
+ contentInsetAdjustmentBehavior = "automatic",
+ ...props
+}: ListProps<T>) {
+ const insets = useSafeAreaInsets();
+ return (
+ <View
+ className={cn(
+ rootVariants({
+ variant,
+ sectionHeaderAsGap,
+ }),
+ rootClassName,
+ )}
+ style={rootStyle}
+ >
+ <FlashList
+ data={data}
+ contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior}
+ renderItem={renderItemWithVariant(
+ renderItem,
+ variant,
+ data,
+ sectionHeaderAsGap,
+ )}
+ contentContainerClassName={cn(
+ variant === "insets" &&
+ (!data || (typeof data?.[0] !== "string" && "pt-4")),
+ contentContainerClassName,
+ )}
+ contentContainerStyle={{
+ paddingBottom: Platform.select({
+ ios:
+ !contentInsetAdjustmentBehavior ||
+ contentInsetAdjustmentBehavior === "never"
+ ? insets.bottom + 16
+ : 0,
+ default: insets.bottom,
+ }),
+ }}
+ getItemType={getItemType}
+ showsVerticalScrollIndicator={false}
+ {...props}
+ />
+ </View>
+ );
+}
+
+function getItemType<T>(item: T) {
+ return typeof item === "string" ? "sectioHeader" : "row";
+}
+
+function renderItemWithVariant<T extends ListDataItem>(
+ renderItem: ListRenderItem<T> | null | undefined,
+ variant: ListVariant,
+ data: readonly T[] | null | undefined,
+ sectionHeaderAsGap?: boolean,
+) {
+ return (args: ListRenderItemProps<T>) => {
+ const previousItem = data?.[args.index - 1];
+ const nextItem = data?.[args.index + 1];
+ return renderItem
+ ? renderItem({
+ ...args,
+ variant,
+ isFirstInSection: !previousItem || typeof previousItem === "string",
+ isLastInSection: !nextItem || typeof nextItem === "string",
+ sectionHeaderAsGap,
+ })
+ : null;
+ };
+}
+
+const List = React.forwardRef(ListComponent) as <T extends ListDataItem>(
+ props: ListProps<T> & { ref?: ListRef<T> },
+) => React.ReactElement;
+
+function isPressable(props: PressableProps) {
+ return (
+ ("onPress" in props && props.onPress) ||
+ ("onLongPress" in props && props.onLongPress) ||
+ ("onPressIn" in props && props.onPressIn) ||
+ ("onPressOut" in props && props.onPressOut) ||
+ ("onLongPress" in props && props.onLongPress)
+ );
+}
+
+type ListItemProps<T extends ListDataItem> = PressableProps &
+ ListRenderItemProps<T> & {
+ androidRootClassName?: string;
+ titleClassName?: string;
+ titleStyle?: StyleProp<TextStyle>;
+ textNumberOfLines?: number;
+ subTitleClassName?: string;
+ subTitleStyle?: StyleProp<TextStyle>;
+ subTitleNumberOfLines?: number;
+ textContentClassName?: string;
+ leftView?: React.ReactNode;
+ rightView?: React.ReactNode;
+ removeSeparator?: boolean;
+ };
+type ListItemRef = React.Ref<View>;
+
+const itemVariants = cva("ios:gap-0 flex-row gap-0 bg-card", {
+ variants: {
+ variant: {
+ insets: "ios:bg-card bg-card/70",
+ "full-width": "bg-card dark:bg-background",
+ },
+ sectionHeaderAsGap: {
+ true: "",
+ false: "",
+ },
+ isFirstItem: {
+ true: "",
+ false: "",
+ },
+ isFirstInSection: {
+ true: "",
+ false: "",
+ },
+ removeSeparator: {
+ true: "",
+ false: "",
+ },
+ isLastInSection: {
+ true: "",
+ false: "",
+ },
+ disabled: {
+ true: "opacity-70",
+ false: "opacity-100",
+ },
+ },
+ compoundVariants: [
+ {
+ variant: "insets",
+ sectionHeaderAsGap: true,
+ className: "ios:dark:bg-card dark:bg-card/70",
+ },
+ {
+ variant: "insets",
+ isFirstInSection: true,
+ className: "ios:rounded-t-[10px]",
+ },
+ {
+ variant: "insets",
+ isLastInSection: true,
+ className: "ios:rounded-b-[10px]",
+ },
+ {
+ removeSeparator: false,
+ isLastInSection: true,
+ className:
+ "ios:border-b-0 border-b border-border/25 dark:border-border/80",
+ },
+ {
+ variant: "insets",
+ isFirstItem: true,
+ className: "border-t border-border/40",
+ },
+ ],
+ defaultVariants: {
+ variant: "insets",
+ sectionHeaderAsGap: false,
+ isFirstInSection: false,
+ isLastInSection: false,
+ disabled: false,
+ },
+});
+
+function ListItemComponent<T extends ListDataItem>(
+ {
+ item,
+ isFirstInSection,
+ isLastInSection,
+ index: _index,
+ variant,
+ className,
+ androidRootClassName,
+ titleClassName,
+ titleStyle,
+ textNumberOfLines,
+ subTitleStyle,
+ subTitleClassName,
+ subTitleNumberOfLines,
+ textContentClassName,
+ sectionHeaderAsGap,
+ removeSeparator = false,
+ leftView,
+ rightView,
+ disabled,
+ ...props
+ }: ListItemProps<T>,
+ ref: ListItemRef,
+) {
+ if (typeof item === "string") {
+ console.log(
+ "List.tsx",
+ "ListItemComponent",
+ "Invalid item of type 'string' was provided. Use ListSectionHeader instead.",
+ );
+ return null;
+ }
+ return (
+ <>
+ <Button
+ disabled={disabled || !isPressable(props)}
+ variant="plain"
+ size="none"
+ unstable_pressDelay={100}
+ androidRootClassName={androidRootClassName}
+ className={itemVariants({
+ variant,
+ sectionHeaderAsGap,
+ isFirstInSection,
+ isLastInSection,
+ disabled,
+ className,
+ removeSeparator,
+ })}
+ {...props}
+ ref={ref}
+ >
+ <TextClassContext.Provider value="font-normal leading-5">
+ {!!leftView && <View>{leftView}</View>}
+ <View
+ className={cn(
+ "h-full flex-1 flex-row",
+ !item.subTitle ? "ios:py-3 py-[18px]" : "ios:py-2 py-2",
+ !leftView && "ml-4",
+ !rightView && "pr-4",
+ !removeSeparator &&
+ (!isLastInSection || variant === "full-width") &&
+ "ios:border-b ios:border-border/80",
+ !removeSeparator &&
+ isFirstInSection &&
+ variant === "full-width" &&
+ "ios:border-t ios:border-border/80",
+ )}
+ >
+ <View className={cn("flex-1", textContentClassName)}>
+ <Text
+ numberOfLines={textNumberOfLines}
+ style={titleStyle}
+ className={titleClassName}
+ >
+ {item.title}
+ </Text>
+ {!!item.subTitle && (
+ <Text
+ numberOfLines={subTitleNumberOfLines}
+ variant="subhead"
+ style={subTitleStyle}
+ className={cn("text-muted-foreground", subTitleClassName)}
+ >
+ {item.subTitle}
+ </Text>
+ )}
+ </View>
+ {!!rightView && <View>{rightView}</View>}
+ </View>
+ </TextClassContext.Provider>
+ </Button>
+ {!removeSeparator && Platform.OS !== "ios" && !isLastInSection && (
+ <View className={cn(variant === "insets" && "px-4")}>
+ <View className="h-px bg-border/25 dark:bg-border/80" />
+ </View>
+ )}
+ </>
+ );
+}
+
+const ListItem = React.forwardRef(ListItemComponent) as <
+ T extends ListDataItem,
+>(
+ props: ListItemProps<T> & { ref?: ListItemRef },
+) => React.ReactElement;
+
+type ListSectionHeaderProps<T extends ListDataItem> = ViewProps &
+ ListRenderItemProps<T> & {
+ textClassName?: string;
+ };
+type ListSectionHeaderRef = React.Ref<View>;
+
+function ListSectionHeaderComponent<T extends ListDataItem>(
+ {
+ item,
+ isFirstInSection: _isFirstInSection,
+ isLastInSection: _isLastInSection,
+ index: _index,
+ variant,
+ className,
+ textClassName,
+ sectionHeaderAsGap,
+ ...props
+ }: ListSectionHeaderProps<T>,
+ ref: ListSectionHeaderRef,
+) {
+ if (typeof item !== "string") {
+ console.log(
+ "List.tsx",
+ "ListSectionHeaderComponent",
+ "Invalid item provided. Expected type 'string'. Use ListItem instead.",
+ );
+ return null;
+ }
+
+ if (sectionHeaderAsGap) {
+ return (
+ <View
+ className={cn(
+ "bg-background",
+ Platform.OS !== "ios" &&
+ "border-b border-border/25 dark:border-border/80",
+ className,
+ )}
+ {...props}
+ ref={ref}
+ >
+ <View className="h-8" />
+ </View>
+ );
+ }
+ return (
+ <View
+ className={cn(
+ "ios:pb-1 pb-4 pl-4 pt-4",
+ Platform.OS !== "ios" &&
+ "border-b border-border/25 dark:border-border/80",
+ variant === "full-width"
+ ? "bg-card dark:bg-background"
+ : "bg-background",
+ className,
+ )}
+ {...props}
+ ref={ref}
+ >
+ <Text
+ variant={Platform.select({ ios: "footnote", default: "body" })}
+ className={cn("ios:uppercase ios:text-muted-foreground", textClassName)}
+ >
+ {item}
+ </Text>
+ </View>
+ );
+}
+
+const ListSectionHeader = React.forwardRef(ListSectionHeaderComponent) as <
+ T extends ListDataItem,
+>(
+ props: ListSectionHeaderProps<T> & { ref?: ListSectionHeaderRef },
+) => React.ReactElement;
+
+const ESTIMATED_ITEM_HEIGHT = {
+ titleOnly: Platform.select({ ios: 45, default: 57 }),
+ withSubTitle: 56,
+};
+
+function getStickyHeaderIndices<T extends ListDataItem>(data: T[]) {
+ if (!data) return [];
+ const indices: number[] = [];
+ for (let i = 0; i < data.length; i++) {
+ if (typeof data[i] === "string") {
+ indices.push(i);
+ }
+ }
+ return indices;
+}
+
+export {
+ ESTIMATED_ITEM_HEIGHT,
+ List,
+ ListItem,
+ ListSectionHeader,
+ getStickyHeaderIndices,
+};
+export type {
+ ListDataItem,
+ ListItemProps,
+ ListProps,
+ ListRenderItemInfo,
+ ListSectionHeaderProps,
+};
diff --git a/apps/mobile/components/ui/PageTitle.tsx b/apps/mobile/components/ui/PageTitle.tsx
index dc712379..28afa408 100644
--- a/apps/mobile/components/ui/PageTitle.tsx
+++ b/apps/mobile/components/ui/PageTitle.tsx
@@ -1,4 +1,4 @@
-import { Text } from "react-native";
+import { Text } from "@/components/ui/Text";
import { cx } from "class-variance-authority";
export default function PageTitle({
diff --git a/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx b/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx
new file mode 100644
index 00000000..969e48b2
--- /dev/null
+++ b/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx
@@ -0,0 +1,187 @@
+import type {
+ NativeSyntheticEvent,
+ TextInputFocusEventData,
+} from "react-native";
+import * as React from "react";
+import { Pressable, TextInput, View, ViewStyle } from "react-native";
+import Animated, {
+ measure,
+ useAnimatedRef,
+ useAnimatedStyle,
+ useDerivedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Text } from "@/components/ui/Text";
+import { useColorScheme } from "@/lib/useColorScheme";
+import { cn } from "@/lib/utils";
+import { useAugmentedRef, useControllableState } from "@rn-primitives/hooks";
+import { Icon } from "@roninoss/icons";
+
+import type { SearchInputProps } from "./types";
+
+// Add as class when possible: https://github.com/marklawlor/nativewind/issues/522
+const BORDER_CURVE: ViewStyle = {
+ borderCurve: "continuous",
+};
+
+const SearchInput = React.forwardRef<
+ React.ElementRef<typeof TextInput>,
+ SearchInputProps
+>(
+ (
+ {
+ value: valueProp,
+ onChangeText: onChangeTextProp,
+ onFocus: onFocusProp,
+ placeholder = "Search...",
+ cancelText = "Cancel",
+ containerClassName,
+ iconContainerClassName,
+ className,
+ iconColor,
+ onCancel,
+ ...props
+ },
+ ref,
+ ) => {
+ const { colors } = useColorScheme();
+ const inputRef = useAugmentedRef({ ref, methods: { focus, blur, clear } });
+ const [showCancel, setShowCancel] = React.useState(false);
+ const showCancelDerivedValue = useDerivedValue(
+ () => showCancel,
+ [showCancel],
+ );
+ const animatedRef = useAnimatedRef();
+
+ const [value = "", onChangeText] = useControllableState({
+ prop: valueProp,
+ defaultProp: valueProp ?? "",
+ onChange: onChangeTextProp,
+ });
+
+ const rootStyle = useAnimatedStyle(() => {
+ if (_WORKLET) {
+ // safely use measure
+ const measurement = measure(animatedRef);
+ return {
+ paddingRight: showCancelDerivedValue.value
+ ? withTiming(measurement?.width ?? cancelText.length * 11.2)
+ : withTiming(0),
+ };
+ }
+ return {
+ paddingRight: showCancelDerivedValue.value
+ ? withTiming(cancelText.length * 11.2)
+ : withTiming(0),
+ };
+ });
+ const buttonStyle3 = useAnimatedStyle(() => {
+ if (_WORKLET) {
+ // safely use measure
+ const measurement = measure(animatedRef);
+ return {
+ position: "absolute",
+ right: 0,
+ opacity: showCancelDerivedValue.value ? withTiming(1) : withTiming(0),
+ transform: [
+ {
+ translateX: showCancelDerivedValue.value
+ ? withTiming(0)
+ : measurement?.width
+ ? withTiming(measurement.width)
+ : cancelText.length * 11.2,
+ },
+ ],
+ };
+ }
+ return {
+ position: "absolute",
+ right: 0,
+ opacity: showCancelDerivedValue.value ? withTiming(1) : withTiming(0),
+ transform: [
+ {
+ translateX: showCancelDerivedValue.value
+ ? withTiming(0)
+ : withTiming(cancelText.length * 11.2),
+ },
+ ],
+ };
+ });
+
+ function focus() {
+ inputRef.current?.focus();
+ }
+
+ function blur() {
+ inputRef.current?.blur();
+ }
+
+ function clear() {
+ onChangeText("");
+ }
+
+ function onFocus(e: NativeSyntheticEvent<TextInputFocusEventData>) {
+ setShowCancel(true);
+ onFocusProp?.(e);
+ }
+
+ return (
+ <Animated.View className="flex-row items-center" style={rootStyle}>
+ <Animated.View
+ style={BORDER_CURVE}
+ className={cn(
+ "flex-1 flex-row rounded-lg bg-card",
+ containerClassName,
+ )}
+ >
+ <View
+ className={cn(
+ "absolute bottom-0 left-0 top-0 z-50 justify-center pl-1.5",
+ iconContainerClassName,
+ )}
+ >
+ <Icon color={iconColor ?? colors.grey3} name="magnify" size={22} />
+ </View>
+ <TextInput
+ ref={inputRef}
+ placeholder={placeholder}
+ className={cn(
+ !showCancel && "active:bg-muted/5 dark:active:bg-muted/20",
+ "flex-1 rounded-lg py-2 pl-8 pr-1 text-[17px] text-foreground",
+ className,
+ )}
+ value={value}
+ onChangeText={onChangeText}
+ onFocus={onFocus}
+ clearButtonMode="while-editing"
+ role="searchbox"
+ {...props}
+ />
+ </Animated.View>
+ <Animated.View
+ ref={animatedRef}
+ style={buttonStyle3}
+ pointerEvents={!showCancel ? "none" : "auto"}
+ >
+ <Pressable
+ onPress={() => {
+ onChangeText("");
+ inputRef.current?.blur();
+ setShowCancel(false);
+ onCancel?.();
+ }}
+ disabled={!showCancel}
+ pointerEvents={!showCancel ? "none" : "auto"}
+ className="flex-1 justify-center active:opacity-50"
+ >
+ <Text className="px-2 text-primary">{cancelText}</Text>
+ </Pressable>
+ </Animated.View>
+ </Animated.View>
+ );
+ },
+);
+
+SearchInput.displayName = "SearchInput";
+
+export { SearchInput };
diff --git a/apps/mobile/components/ui/SearchInput/SearchInput.tsx b/apps/mobile/components/ui/SearchInput/SearchInput.tsx
new file mode 100644
index 00000000..7e816ab6
--- /dev/null
+++ b/apps/mobile/components/ui/SearchInput/SearchInput.tsx
@@ -0,0 +1,114 @@
+import * as React from "react";
+import { Pressable, TextInput, View } from "react-native";
+import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
+import { TailwindResolver } from "@/components/TailwindResolver";
+import { Button } from "@/components/ui/Button";
+import { useColorScheme } from "@/lib/useColorScheme";
+import { cn } from "@/lib/utils";
+import { useAugmentedRef, useControllableState } from "@rn-primitives/hooks";
+import { Icon } from "@roninoss/icons";
+
+import type { SearchInputProps } from "./types";
+
+const SearchInput = React.forwardRef<
+ React.ElementRef<typeof TextInput>,
+ SearchInputProps
+>(
+ (
+ {
+ value: valueProp,
+ onChangeText: onChangeTextProp,
+ placeholder = "Search...",
+ containerClassName,
+ iconContainerClassName,
+ className,
+ onCancel,
+ ...props
+ },
+ ref,
+ ) => {
+ const { colors } = useColorScheme();
+ const inputRef = useAugmentedRef({ ref, methods: { focus, blur, clear } });
+ const [value = "", onChangeText] = useControllableState({
+ prop: valueProp,
+ defaultProp: valueProp ?? "",
+ onChange: onChangeTextProp,
+ });
+
+ function focus() {
+ inputRef.current?.focus();
+ }
+
+ function blur() {
+ inputRef.current?.blur();
+ }
+
+ function clear() {
+ onCancel?.();
+ onChangeText("");
+ }
+
+ return (
+ <Button
+ variant="plain"
+ className={cn(
+ "android:gap-0 android:h-14 flex-row items-center rounded-full bg-card px-2",
+ containerClassName,
+ )}
+ onPress={focus}
+ >
+ <View
+ className={cn("p-2", iconContainerClassName)}
+ pointerEvents="none"
+ >
+ <TailwindResolver
+ className="text-muted"
+ comp={(styles) => (
+ <Icon
+ color={styles?.color?.toString()}
+ name="magnify"
+ size={24}
+ />
+ )}
+ />
+ </View>
+
+ <View className="flex-1" pointerEvents="none">
+ <TextInput
+ ref={inputRef}
+ placeholder={placeholder}
+ className={cn(
+ "flex-1 rounded-r-full p-2 text-[17px] text-foreground placeholder:text-muted",
+ className,
+ )}
+ placeholderTextColor={colors.foreground}
+ value={value}
+ onChangeText={onChangeText}
+ role="searchbox"
+ {...props}
+ />
+ </View>
+ {!!value && (
+ <Animated.View entering={FadeIn} exiting={FadeOut.duration(150)}>
+ <Pressable className="p-2" onPress={clear}>
+ <TailwindResolver
+ className="text-muted"
+ comp={(styles) => (
+ <Icon
+ name="close"
+ size={24}
+ color={styles?.color?.toString()}
+ />
+ )}
+ />
+ </Pressable>
+ </Animated.View>
+ )}
+ </Button>
+ );
+ },
+);
+
+SearchInput.displayName = "SearchInput";
+
+export { SearchInput };
diff --git a/apps/mobile/components/ui/SearchInput/index.ts b/apps/mobile/components/ui/SearchInput/index.ts
new file mode 100644
index 00000000..e5150fe3
--- /dev/null
+++ b/apps/mobile/components/ui/SearchInput/index.ts
@@ -0,0 +1 @@
+export * from "./SearchInput";
diff --git a/apps/mobile/components/ui/SearchInput/types.ts b/apps/mobile/components/ui/SearchInput/types.ts
new file mode 100644
index 00000000..e0be8a2c
--- /dev/null
+++ b/apps/mobile/components/ui/SearchInput/types.ts
@@ -0,0 +1,13 @@
+import type { TextInput, TextInputProps } from "react-native";
+
+interface SearchInputProps extends TextInputProps {
+ containerClassName?: string;
+ iconContainerClassName?: string;
+ cancelText?: string;
+ iconColor?: string;
+ onCancel?: () => void;
+}
+
+type SearchInputRef = TextInput;
+
+export type { SearchInputProps, SearchInputRef };
diff --git a/apps/mobile/components/ui/Text.tsx b/apps/mobile/components/ui/Text.tsx
new file mode 100644
index 00000000..e5590c75
--- /dev/null
+++ b/apps/mobile/components/ui/Text.tsx
@@ -0,0 +1,52 @@
+import * as React from "react";
+import { Text as RNText } from "react-native";
+import { cn } from "@/lib/utils";
+import { cva, VariantProps } from "class-variance-authority";
+
+const textVariants = cva("text-foreground", {
+ variants: {
+ variant: {
+ largeTitle: "text-4xl",
+ title1: "text-2xl",
+ title2: "text-[22px] leading-7",
+ title3: "text-xl",
+ heading: "text-[17px] font-semibold leading-6",
+ body: "text-[17px] leading-6",
+ callout: "text-base",
+ subhead: "text-[15px] leading-6",
+ footnote: "text-[13px] leading-5",
+ caption1: "text-xs",
+ caption2: "text-[11px] leading-4",
+ },
+ color: {
+ primary: "",
+ secondary: "text-secondary-foreground/90",
+ tertiary: "text-muted-foreground/90",
+ quarternary: "text-muted-foreground/50",
+ },
+ },
+ defaultVariants: {
+ variant: "body",
+ color: "primary",
+ },
+});
+
+const TextClassContext = React.createContext<string | undefined>(undefined);
+
+function Text({
+ className,
+ variant,
+ color,
+ ...props
+}: React.ComponentPropsWithoutRef<typeof RNText> &
+ VariantProps<typeof textVariants>) {
+ const textClassName = React.useContext(TextClassContext);
+ return (
+ <RNText
+ className={cn(textVariants({ variant, color }), textClassName, className)}
+ {...props}
+ />
+ );
+}
+
+export { Text, TextClassContext, textVariants };
diff --git a/apps/mobile/components/ui/Toast.tsx b/apps/mobile/components/ui/Toast.tsx
index 7bd2e64d..fd122c25 100644
--- a/apps/mobile/components/ui/Toast.tsx
+++ b/apps/mobile/components/ui/Toast.tsx
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
-import { Animated, Text, View } from "react-native";
+import { Animated, View } from "react-native";
+import { Text } from "@/components/ui/Text";
import { cn } from "@/lib/utils";
const toastVariants = {