aboutsummaryrefslogtreecommitdiffstats
path: root/apps/mobile
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile')
-rw-r--r--apps/mobile/app/_layout.tsx101
-rw-r--r--apps/mobile/app/dashboard/(tabs)/_layout.tsx3
-rw-r--r--apps/mobile/app/dashboard/(tabs)/index.tsx9
-rw-r--r--apps/mobile/app/dashboard/(tabs)/lists.tsx34
-rw-r--r--apps/mobile/app/dashboard/(tabs)/settings.tsx45
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx11
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx287
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx7
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx26
-rw-r--r--apps/mobile/app/dashboard/bookmarks/new.tsx8
-rw-r--r--apps/mobile/app/dashboard/lists/new.tsx9
-rw-r--r--apps/mobile/app/dashboard/search.tsx52
-rw-r--r--apps/mobile/app/dashboard/settings/bookmark-default-view.tsx9
-rw-r--r--apps/mobile/app/dashboard/settings/theme.tsx9
-rw-r--r--apps/mobile/app/error.tsx5
-rw-r--r--apps/mobile/app/sharing.tsx14
-rw-r--r--apps/mobile/app/signin.tsx28
-rw-r--r--apps/mobile/app/test-connection.tsx14
-rw-r--r--apps/mobile/babel.config.js3
-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
-rw-r--r--apps/mobile/globals.css132
-rw-r--r--apps/mobile/lib/useColorScheme.tsx58
-rw-r--r--apps/mobile/metro.config.js3
-rw-r--r--apps/mobile/package.json7
-rw-r--r--apps/mobile/tailwind.config.js66
-rw-r--r--apps/mobile/tailwind.config.ts11
-rw-r--r--apps/mobile/theme/colors.ts109
-rw-r--r--apps/mobile/theme/index.ts30
45 files changed, 1835 insertions, 417 deletions
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index ca3da0cb..1e6128c7 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -3,21 +3,23 @@ import "expo-dev-client";
import { useEffect } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { KeyboardProvider } from "react-native-keyboard-controller";
import { useRouter } from "expo-router";
import { Stack } from "expo-router/stack";
import { ShareIntentProvider, useShareIntent } from "expo-share-intent";
import { StatusBar } from "expo-status-bar";
import { StyledStack } from "@/components/navigation/stack";
import { Providers } from "@/lib/providers";
-import useAppSettings from "@/lib/settings";
+import { useColorScheme, useInitialAndroidBarSync } from "@/lib/useColorScheme";
import { cn } from "@/lib/utils";
-import { useColorScheme } from "nativewind";
+import { NAV_THEME } from "@/theme";
+import { ThemeProvider as NavThemeProvider } from "@react-navigation/native";
export default function RootLayout() {
+ useInitialAndroidBarSync();
const router = useRouter();
const { hasShareIntent } = useShareIntent();
- const { colorScheme, setColorScheme } = useColorScheme();
- const { settings } = useAppSettings();
+ const { colorScheme, isDarkColorScheme } = useColorScheme();
useEffect(() => {
if (hasShareIntent) {
@@ -27,52 +29,55 @@ export default function RootLayout() {
}
}, [hasShareIntent]);
- useEffect(() => {
- setColorScheme(settings.theme);
- }, [settings.theme]);
-
return (
<>
- <StyledStack
- layout={(props) => {
- return (
- <GestureHandlerRootView style={{ flex: 1 }}>
- <ShareIntentProvider>
- <Providers>{props.children}</Providers>
- </ShareIntentProvider>
- </GestureHandlerRootView>
- );
- }}
- contentClassName={cn(
- "w-full flex-1 bg-gray-100 text-foreground dark:bg-background",
- colorScheme == "dark" ? "dark" : "light",
- )}
- screenOptions={{
- headerTitle: "",
- headerTransparent: true,
- }}
- >
- <Stack.Screen name="index" />
- <Stack.Screen
- name="signin"
- options={{
- headerShown: true,
- headerBackVisible: true,
- headerBackTitle: "Back",
- title: "",
- }}
- />
- <Stack.Screen name="sharing" />
- <Stack.Screen
- name="test-connection"
- options={{
- title: "Test Connection",
- headerShown: true,
- presentation: "modal",
- }}
- />
- </StyledStack>
- <StatusBar style="auto" />
+ <KeyboardProvider statusBarTranslucent navigationBarTranslucent>
+ <NavThemeProvider value={NAV_THEME[colorScheme]}>
+ <StyledStack
+ layout={(props) => {
+ return (
+ <GestureHandlerRootView style={{ flex: 1 }}>
+ <ShareIntentProvider>
+ <Providers>{props.children}</Providers>
+ </ShareIntentProvider>
+ </GestureHandlerRootView>
+ );
+ }}
+ contentClassName={cn(
+ "w-full flex-1 bg-gray-100 text-foreground dark:bg-background",
+ colorScheme == "dark" ? "dark" : "light",
+ )}
+ screenOptions={{
+ headerTitle: "",
+ headerTransparent: true,
+ }}
+ >
+ <Stack.Screen name="index" />
+ <Stack.Screen
+ name="signin"
+ options={{
+ headerShown: true,
+ headerBackVisible: true,
+ headerBackTitle: "Back",
+ title: "",
+ }}
+ />
+ <Stack.Screen name="sharing" />
+ <Stack.Screen
+ name="test-connection"
+ options={{
+ title: "Test Connection",
+ headerShown: true,
+ presentation: "modal",
+ }}
+ />
+ </StyledStack>
+ </NavThemeProvider>
+ </KeyboardProvider>
+ <StatusBar
+ key={`root-status-bar-${isDarkColorScheme ? "light" : "dark"}`}
+ style={isDarkColorScheme ? "light" : "dark"}
+ />
</>
);
}
diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
index f1d90ee4..7419c348 100644
--- a/apps/mobile/app/dashboard/(tabs)/_layout.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
@@ -1,9 +1,11 @@
import React, { useLayoutEffect } from "react";
import { Tabs, useNavigation } from "expo-router";
import { StyledTabs } from "@/components/navigation/tabs";
+import { useColorScheme } from "@/lib/useColorScheme";
import { ClipboardList, Home, Settings } from "lucide-react-native";
export default function TabLayout() {
+ const { colors } = useColorScheme();
const navigation = useNavigation();
// Hide the header on the parent screen
useLayoutEffect(() => {
@@ -18,6 +20,7 @@ export default function TabLayout() {
sceneClassName="bg-gray-100 dark:bg-background"
screenOptions={{
headerShown: false,
+ tabBarActiveTintColor: colors.foreground,
}}
>
<Tabs.Screen
diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/index.tsx
index f70474a9..0a51b817 100644
--- a/apps/mobile/app/dashboard/(tabs)/index.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/index.tsx
@@ -1,4 +1,4 @@
-import { Platform, Pressable, Text, View } from "react-native";
+import { Platform, Pressable, View } from "react-native";
import * as Haptics from "expo-haptics";
import * as ImagePicker from "expo-image-picker";
import { router } from "expo-router";
@@ -6,6 +6,7 @@ import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList";
import { TailwindResolver } from "@/components/TailwindResolver";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import PageTitle from "@/components/ui/PageTitle";
+import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
import useAppSettings from "@/lib/settings";
import { useUploadAsset } from "@/lib/upload";
@@ -89,16 +90,16 @@ export default function Home() {
/>
</View>
<Pressable
- className="flex flex-row items-center gap-1 rounded-lg border border-input bg-background px-4 py-2.5"
+ className="flex flex-row items-center gap-1 rounded-lg border border-input bg-card px-4 py-1"
onPress={() => router.push("/dashboard/search")}
>
<TailwindResolver
- className="text-muted-foreground"
+ className="text-muted"
comp={(styles) => (
<Search size={16} color={styles?.color?.toString()} />
)}
/>
- <Text className="text-muted-foreground">Search</Text>
+ <Text className="text-muted">Search</Text>
</Pressable>
</View>
}
diff --git a/apps/mobile/app/dashboard/(tabs)/lists.tsx b/apps/mobile/app/dashboard/(tabs)/lists.tsx
index 218c1de4..a2301c36 100644
--- a/apps/mobile/app/dashboard/(tabs)/lists.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/lists.tsx
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react";
-import { FlatList, Pressable, Text, View } from "react-native";
+import { FlatList, Pressable, View } from "react-native";
import * as Haptics from "expo-haptics";
import { Link, router } from "expo-router";
import FullPageError from "@/components/FullPageError";
-import { TailwindResolver } from "@/components/TailwindResolver";
+import ChevronRight from "@/components/ui/ChevronRight";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
import PageTitle from "@/components/ui/PageTitle";
+import { Text } from "@/components/ui/Text";
import { api } from "@/lib/trpc";
+import { useColorScheme } from "@/lib/useColorScheme";
import { condProps } from "@/lib/utils";
-import { ChevronRight, Plus } from "lucide-react-native";
+import { Plus } from "lucide-react-native";
import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils";
@@ -65,6 +67,7 @@ function traverseTree(
}
export default function Lists() {
+ const { colors } = useColorScheme();
const [refreshing, setRefreshing] = useState(false);
const { data: lists, isPending, error, refetch } = useBookmarkLists();
const [showChildrenOf, setShowChildrenOf] = useState<Record<string, boolean>>(
@@ -130,7 +133,7 @@ export default function Lists() {
}}
renderItem={(l) => (
<View
- className="mx-2 flex flex-row items-center rounded-xl border border-input bg-white px-4 py-2 dark:bg-accent"
+ className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2"
style={condProps({
condition: l.item.level > 0,
props: { marginLeft: l.item.level * 20 },
@@ -146,28 +149,23 @@ export default function Lists() {
}));
}}
>
- <TailwindResolver
- className="text-foreground"
- comp={(style) => (
- <ChevronRight
- color={style?.color?.toString()}
- style={{
- transform: [
- { rotate: l.item.collapsed ? "0deg" : "90deg" },
- ],
- }}
- />
- )}
+ <ChevronRight
+ color={colors.foreground}
+ style={{
+ transform: [
+ { rotate: l.item.collapsed ? "0deg" : "90deg" },
+ ],
+ }}
/>
</Pressable>
)}
<Link asChild key={l.item.id} href={l.item.href} className="flex-1">
<Pressable className="flex flex-row justify-between">
- <Text className="text-lg text-accent-foreground">
+ <Text>
{l.item.logo} {l.item.name}
</Text>
- <ChevronRight color="rgb(0, 122, 255)" />
+ <ChevronRight />
</Pressable>
</Link>
</View>
diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx
index 7b3dab4f..6d76308d 100644
--- a/apps/mobile/app/dashboard/(tabs)/settings.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx
@@ -1,16 +1,17 @@
import { useEffect } from "react";
-import { ActivityIndicator, Pressable, Text, View } from "react-native";
+import { ActivityIndicator, Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { useSharedValue } from "react-native-reanimated";
import { Link } from "expo-router";
import { Button } from "@/components/ui/Button";
+import ChevronRight from "@/components/ui/ChevronRight";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import { Divider } from "@/components/ui/Divider";
import PageTitle from "@/components/ui/PageTitle";
+import { Text } from "@/components/ui/Text";
import { useSession } from "@/lib/session";
import useAppSettings from "@/lib/settings";
import { api } from "@/lib/trpc";
-import { ChevronRight } from "lucide-react-native";
export default function Dashboard() {
const { logout } = useSession();
@@ -38,56 +39,50 @@ export default function Dashboard() {
<CustomSafeAreaView>
<PageTitle title="Settings" />
<View className="flex h-full w-full items-center gap-3 px-4 py-2">
- <View className="flex w-full gap-3 rounded-lg bg-white px-4 py-2 dark:bg-accent">
- <Text className="text-lg text-accent-foreground">
- {isSettingsLoading ? "Loading ..." : settings.address}
- </Text>
+ <View className="flex w-full gap-3 rounded-lg bg-card px-4 py-2">
+ <Text>{isSettingsLoading ? "Loading ..." : settings.address}</Text>
<Divider orientation="horizontal" />
- <Text className="text-lg text-accent-foreground">
- {isLoading ? "Loading ..." : data?.email}
- </Text>
+ <Text>{isLoading ? "Loading ..." : data?.email}</Text>
</View>
<Text className="w-full p-1 text-2xl font-bold text-foreground">
App Settings
</Text>
- <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-white px-4 py-2 dark:bg-accent">
+ <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2">
<Link asChild href="/dashboard/settings/theme" className="flex-1">
<Pressable className="flex flex-row justify-between">
- <Text className="text-lg text-accent-foreground">Theme</Text>
+ <Text>Theme</Text>
<View className="flex flex-row items-center gap-2">
- <Text className="text-lg text-muted-foreground">
+ <Text className="text-muted-foreground">
{
{ light: "Light", dark: "Dark", system: "System" }[
settings.theme
]
}
</Text>
- <ChevronRight color="rgb(0, 122, 255)" />
+ <ChevronRight />
</View>
</Pressable>
</Link>
</View>
- <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-white px-4 py-2 dark:bg-accent">
+ <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2">
<Link
asChild
href="/dashboard/settings/bookmark-default-view"
className="flex-1"
>
<Pressable className="flex flex-row justify-between">
- <Text className="text-lg text-accent-foreground">
- Default Bookmark View
- </Text>
+ <Text>Default Bookmark View</Text>
<View className="flex flex-row items-center gap-2">
{isSettingsLoading ? (
<ActivityIndicator size="small" />
) : (
- <Text className="text-lg text-muted-foreground">
+ <Text className="text-muted-foreground">
{settings.defaultBookmarkView === "reader"
? "Reader"
: "Browser"}
</Text>
)}
- <ChevronRight color="rgb(0, 122, 255)" />
+ <ChevronRight />
</View>
</Pressable>
</Link>
@@ -95,8 +90,8 @@ export default function Dashboard() {
<Text className="w-full p-1 text-2xl font-bold text-foreground">
Upload Settings
</Text>
- <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-white px-4 py-2 dark:bg-accent">
- <Text className="text-lg text-accent-foreground">Image Quality</Text>
+ <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2">
+ <Text>Image Quality</Text>
<View className="flex flex-1 flex-row items-center justify-center gap-2">
<Text className="text-foreground">
{Math.round(settings.imageQuality * 100)}%
@@ -115,7 +110,13 @@ export default function Dashboard() {
</View>
</View>
<Divider orientation="horizontal" />
- <Button className="w-full" label="Log Out" onPress={logout} />
+ <Button
+ androidRootClassName="w-full"
+ onPress={logout}
+ variant="destructive"
+ >
+ <Text>Log Out</Text>
+ </Button>
</View>
</CustomSafeAreaView>
);
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx
index eafcfc19..3b1300ca 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx
@@ -25,6 +25,7 @@ import { Button } from "@/components/ui/Button";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
import { Input } from "@/components/ui/Input";
+import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
import { useAssetUrl } from "@/lib/hooks";
import useAppSettings from "@/lib/settings";
@@ -296,15 +297,17 @@ function BookmarkTextView({ bookmark }: { bookmark: ZBookmark }) {
<View className="flex-1">
{isEditing && (
<View className="absolute right-0 top-0 z-10 m-4 flex flex-row gap-1">
- <Button label="Save" variant="default" onPress={Keyboard.dismiss} />
+ <Button onPress={Keyboard.dismiss}>
+ <Text>Save</Text>
+ </Button>
<Button
- label="Discard"
- variant="destructive"
onPress={() => {
setContent(initialText);
setIsEditing(false);
}}
- />
+ >
+ <Text>Discard</Text>
+ </Button>
</View>
)}
<ScrollView className="flex bg-background p-2">
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
index af124160..1781ec74 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
@@ -1,26 +1,22 @@
import React from "react";
+import { Alert, Pressable, View } from "react-native";
import {
- Alert,
- Keyboard,
- Pressable,
- Text,
- TouchableWithoutFeedback,
- View,
-} from "react-native";
-import Animated, {
- useAnimatedKeyboard,
- useAnimatedStyle,
-} from "react-native-reanimated";
+ KeyboardAwareScrollView,
+ KeyboardGestureArea,
+} from "react-native-keyboard-controller";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
import { router, Stack, useLocalSearchParams } from "expo-router";
import TagPill from "@/components/bookmarks/TagPill";
import FullPageError from "@/components/FullPageError";
import { Button } from "@/components/ui/Button";
+import ChevronRight from "@/components/ui/ChevronRight";
import { Divider } from "@/components/ui/Divider";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
import { Input } from "@/components/ui/Input";
import { Skeleton } from "@/components/ui/Skeleton";
+import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
-import { ChevronRight } from "lucide-react-native";
+import { cn } from "@/lib/utils";
import {
useAutoRefreshingBookmarkQuery,
@@ -30,9 +26,21 @@ import {
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils";
+function InfoSection({
+ className,
+ ...props
+}: React.ComponentProps<typeof View>) {
+ return (
+ <View
+ className={cn("flex gap-2 rounded-lg bg-card p-3", className)}
+ {...props}
+ />
+ );
+}
+
function TagList({ bookmark }: { bookmark: ZBookmark }) {
return (
- <View className="flex gap-2 rounded-lg bg-white py-3 dark:bg-accent">
+ <InfoSection>
{isBookmarkStillTagging(bookmark) ? (
<View className="flex gap-4 pb-3">
<Skeleton className="h-4 w-full" />
@@ -41,7 +49,7 @@ function TagList({ bookmark }: { bookmark: ZBookmark }) {
) : (
bookmark.tags.length > 0 && (
<>
- <View className="flex flex-row flex-wrap gap-2 rounded-lg bg-background p-2">
+ <View className="flex flex-row flex-wrap gap-2 rounded-lg p-2">
{bookmark.tags.map((t) => (
<TagPill key={t.id} tag={t} />
))}
@@ -50,94 +58,104 @@ function TagList({ bookmark }: { bookmark: ZBookmark }) {
</>
)
)}
- <Pressable
- onPress={() =>
- router.push(`/dashboard/bookmarks/${bookmark.id}/manage_tags`)
- }
- className="flex w-full flex-row justify-between gap-3 px-4"
- >
- <Text className="text-lg text-accent-foreground">Manage Tags</Text>
- <ChevronRight color="rgb(0, 122, 255)" />
- </Pressable>
- </View>
+ <View>
+ <Pressable
+ onPress={() =>
+ router.push(`/dashboard/bookmarks/${bookmark.id}/manage_tags`)
+ }
+ className="flex w-full flex-row justify-between gap-3"
+ >
+ <Text>Manage Tags</Text>
+ <ChevronRight />
+ </Pressable>
+ </View>
+ </InfoSection>
);
}
function ManageLists({ bookmark }: { bookmark: ZBookmark }) {
return (
- <View className="flex gap-4">
- <Pressable
- onPress={() =>
- router.push(`/dashboard/bookmarks/${bookmark.id}/manage_lists`)
- }
- className="flex w-full flex-row justify-between gap-3 rounded-lg bg-white px-4 py-2 dark:bg-accent"
- >
- <Text className="text-lg text-accent-foreground">Manage Lists</Text>
- <ChevronRight color="rgb(0, 122, 255)" />
- </Pressable>
- </View>
+ <InfoSection>
+ <View>
+ <Pressable
+ onPress={() =>
+ router.push(`/dashboard/bookmarks/${bookmark.id}/manage_lists`)
+ }
+ className="flex w-full flex-row justify-between gap-3 rounded-lg"
+ >
+ <Text>Manage Lists</Text>
+ <ChevronRight />
+ </Pressable>
+ </View>
+ </InfoSection>
);
}
function TitleEditor({
- bookmarkId,
title,
+ setTitle,
+ isPending,
}: {
- bookmarkId: string;
- title: string;
+ title: string | null | undefined;
+ setTitle: (title: string | null) => void;
+ isPending: boolean;
}) {
- const { mutate, isPending } = useUpdateBookmark();
return (
- <View className="flex gap-4">
+ <InfoSection>
<Input
editable={!isPending}
- multiline={true}
+ multiline={false}
numberOfLines={1}
- loading={isPending}
placeholder="Title"
- textAlignVertical="top"
- onEndEditing={(ev) =>
- mutate({
- bookmarkId,
- title: ev.nativeEvent.text ? ev.nativeEvent.text : null,
- })
- }
+ onChangeText={(text) => setTitle(text)}
defaultValue={title ?? ""}
/>
- </View>
+ </InfoSection>
);
}
-function NotesEditor({ bookmark }: { bookmark: ZBookmark }) {
- const { mutate, isPending } = useUpdateBookmark();
+function NotesEditor({
+ notes,
+ setNotes,
+ isPending,
+}: {
+ notes: string | null | undefined;
+ setNotes: (title: string | null) => void;
+ isPending: boolean;
+}) {
return (
- <View className="flex gap-4">
+ <InfoSection>
<Input
editable={!isPending}
multiline={true}
- numberOfLines={3}
- loading={isPending}
placeholder="Notes"
+ inputClasses="h-24"
+ onChangeText={(text) => setNotes(text)}
textAlignVertical="top"
- onEndEditing={(ev) =>
- mutate({
- bookmarkId: bookmark.id,
- note: ev.nativeEvent.text,
- })
- }
- defaultValue={bookmark.note ?? ""}
+ defaultValue={notes ?? ""}
/>
- </View>
+ </InfoSection>
);
}
const ViewBookmarkPage = () => {
+ const insets = useSafeAreaInsets();
const { slug } = useLocalSearchParams();
const { toast } = useToast();
if (typeof slug !== "string") {
throw new Error("Unexpected param type");
}
+ const { mutate: editBookmark, isPending: isEditPending } = useUpdateBookmark({
+ onSuccess: () => {
+ toast({
+ message: "The bookmark has been updated!",
+ showProgress: false,
+ });
+ setEditedBookmark({});
+ },
+ });
+
const { mutate: deleteBookmark, isPending: isDeletionPending } =
useDeleteBookmark({
onSuccess: () => {
@@ -149,12 +167,6 @@ const ViewBookmarkPage = () => {
},
});
- const keyboard = useAnimatedKeyboard();
-
- const animatedStyles = useAnimatedStyle(() => ({
- marginBottom: keyboard.height.value,
- }));
-
const {
data: bookmark,
isPending,
@@ -163,6 +175,11 @@ const ViewBookmarkPage = () => {
bookmarkId: slug,
});
+ const [editedBookmark, setEditedBookmark] = React.useState<{
+ title?: string | null;
+ note?: string;
+ }>({});
+
if (isPending) {
return <FullPageSpinner />;
}
@@ -188,6 +205,27 @@ const ViewBookmarkPage = () => {
);
};
+ const onDone = () => {
+ const doDone = () => {
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace("dashboard");
+ }
+ };
+ if (Object.keys(editedBookmark).length === 0) {
+ doDone();
+ return;
+ }
+ Alert.alert("You have unsaved changes", "Do you still want to leave?", [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Leave",
+ onPress: doDone,
+ },
+ ]);
+ };
+
let title = null;
switch (bookmark.content.type) {
case BookmarkTypes.LINK:
@@ -201,56 +239,77 @@ const ViewBookmarkPage = () => {
break;
}
return (
- <View>
- <Stack.Screen
- options={{
- headerShown: true,
- headerTransparent: false,
- headerTitle: title ?? "Untitled",
- headerRight: () => (
- <Pressable
- onPress={() => {
- if (router.canGoBack()) {
- router.back();
- } else {
- router.replace("dashboard");
- }
- }}
+ <KeyboardGestureArea interpolator="ios">
+ <KeyboardAwareScrollView
+ className="p-4"
+ bottomOffset={8}
+ keyboardDismissMode="interactive"
+ contentContainerStyle={{ paddingBottom: insets.bottom }}
+ >
+ <Stack.Screen
+ options={{
+ headerShown: true,
+ headerTransparent: false,
+ headerTitle: title ?? "Untitled",
+ headerRight: () => (
+ <Pressable onPress={onDone}>
+ <Text>Done</Text>
+ </Pressable>
+ ),
+ }}
+ />
+ <View className="gap-4">
+ <TitleEditor
+ title={title}
+ setTitle={(title) =>
+ setEditedBookmark((prev) => ({ ...prev, title }))
+ }
+ isPending={isEditPending}
+ />
+ <TagList bookmark={bookmark} />
+ <ManageLists bookmark={bookmark} />
+ <NotesEditor
+ notes={bookmark.note}
+ setNotes={(note) =>
+ setEditedBookmark((prev) => ({ ...prev, note: note ?? "" }))
+ }
+ isPending={isEditPending}
+ />
+ <View className="flex justify-between gap-3">
+ <Button
+ onPress={() =>
+ editBookmark({
+ bookmarkId: bookmark.id,
+ ...editedBookmark,
+ })
+ }
+ disabled={isEditPending}
>
- <Text className="text-foreground">Done</Text>
- </Pressable>
- ),
- }}
- />
- <Animated.ScrollView className="p-4" style={[animatedStyles]}>
- <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
- <View className="h-screen gap-8 px-2">
- <TitleEditor bookmarkId={bookmark.id} title={title ?? ""} />
- <TagList bookmark={bookmark} />
- <ManageLists bookmark={bookmark} />
- <NotesEditor bookmark={bookmark} />
+ <Text>Save</Text>
+ </Button>
<Button
- onPress={handleDeleteBookmark}
variant="destructive"
+ onPress={handleDeleteBookmark}
disabled={isDeletionPending}
- label="Delete"
- />
- <View className="gap-2">
- <Text className="items-center text-center">
- Created {bookmark.createdAt.toLocaleString()}
- </Text>
- {bookmark.modifiedAt &&
- bookmark.modifiedAt.getTime() !==
- bookmark.createdAt.getTime() && (
- <Text className="items-center text-center">
- Modified {bookmark.modifiedAt.toLocaleString()}
- </Text>
- )}
- </View>
+ >
+ <Text>Delete</Text>
+ </Button>
+ </View>
+ <View className="gap-2">
+ <Text className="items-center text-center">
+ Created {bookmark.createdAt.toLocaleString()}
+ </Text>
+ {bookmark.modifiedAt &&
+ bookmark.modifiedAt.getTime() !==
+ bookmark.createdAt.getTime() && (
+ <Text className="items-center text-center">
+ Modified {bookmark.modifiedAt.toLocaleString()}
+ </Text>
+ )}
</View>
- </TouchableWithoutFeedback>
- </Animated.ScrollView>
- </View>
+ </View>
+ </KeyboardAwareScrollView>
+ </KeyboardGestureArea>
);
};
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx
index 9f2149ae..7250d06b 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx
@@ -1,8 +1,9 @@
import React from "react";
-import { FlatList, Pressable, Text, View } from "react-native";
+import { FlatList, Pressable, View } from "react-native";
import Checkbox from "expo-checkbox";
import { useLocalSearchParams } from "expo-router";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
+import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
import {
@@ -75,13 +76,13 @@ const ListPickerPage = () => {
gap: 5,
}}
renderItem={(l) => (
- <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-white px-4 py-2 dark:bg-accent">
+ <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2">
<Pressable
key={l.item[l.item.length - 1].id}
onPress={() => toggleList(l.item[l.item.length - 1].id)}
className="flex w-full flex-row justify-between"
>
- <Text className="text-lg text-accent-foreground">
+ <Text>
{l.item.map((item) => `${item.icon} ${item.name}`).join(" / ")}
</Text>
<Checkbox
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
index 38296626..ea6c2f4d 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
@@ -1,16 +1,11 @@
import React, { useMemo } from "react";
-import {
- Pressable,
- SectionList,
- Text,
- TouchableOpacity,
- View,
-} from "react-native";
+import { Pressable, SectionList, TouchableOpacity, View } from "react-native";
import { Stack, useLocalSearchParams } from "expo-router";
-import { TailwindResolver } from "@/components/TailwindResolver";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
+import { useColorScheme } from "@/lib/useColorScheme";
import { Check, Plus } from "lucide-react-native";
import {
@@ -22,6 +17,7 @@ import { api } from "@karakeep/shared-react/trpc";
const NEW_TAG_ID = "new-tag";
const ListPickerPage = () => {
+ const { colors } = useColorScheme();
const { slug: bookmarkId } = useLocalSearchParams();
const [search, setSearch] = React.useState("");
@@ -211,20 +207,14 @@ const ListPickerPage = () => {
})
}
>
- <View className="mx-2 flex flex-row items-center gap-2 rounded-xl border border-input bg-white px-4 py-2 dark:bg-accent">
+ <View className="mx-2 flex flex-row items-center gap-2 rounded-xl border border-input bg-card px-4 py-2">
{t.section.title == "Existing Tags" && (
- <TailwindResolver
- className="text-accent-foreground"
- comp={(s) => <Check color={s?.color} />}
- />
+ <Check color={colors.foreground} />
)}
{t.section.title == "All Tags" && t.item.id == NEW_TAG_ID && (
- <TailwindResolver
- className="text-accent-foreground"
- comp={(s) => <Plus color={s?.color} />}
- />
+ <Plus color={colors.foreground} />
)}
- <Text className="text-center text-lg text-accent-foreground">
+ <Text>
{t.item.id == NEW_TAG_ID
? `Create new tag '${t.item.name}'`
: t.item.name}
diff --git a/apps/mobile/app/dashboard/bookmarks/new.tsx b/apps/mobile/app/dashboard/bookmarks/new.tsx
index d24c1597..50f8f2a7 100644
--- a/apps/mobile/app/dashboard/bookmarks/new.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/new.tsx
@@ -1,9 +1,10 @@
import React, { useState } from "react";
-import { Text, View } from "react-native";
+import { View } from "react-native";
import { router } from "expo-router";
import { Button } from "@/components/ui/Button";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import { Input } from "@/components/ui/Input";
+import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
import { useCreateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
@@ -61,13 +62,16 @@ const NoteEditorPage = () => {
)}
<Input
onChangeText={setText}
+ className="bg-card"
multiline
placeholder="What's on your mind?"
autoFocus
autoCapitalize={"none"}
textAlignVertical="top"
/>
- <Button onPress={onSubmit} label="Save" />
+ <Button onPress={onSubmit}>
+ <Text>Save</Text>
+ </Button>
</View>
</CustomSafeAreaView>
);
diff --git a/apps/mobile/app/dashboard/lists/new.tsx b/apps/mobile/app/dashboard/lists/new.tsx
index 2cd690f5..55315e70 100644
--- a/apps/mobile/app/dashboard/lists/new.tsx
+++ b/apps/mobile/app/dashboard/lists/new.tsx
@@ -1,9 +1,10 @@
import React, { useState } from "react";
-import { Text, View } from "react-native";
+import { View } from "react-native";
import { router } from "expo-router";
import { Button } from "@/components/ui/Button";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import { Input } from "@/components/ui/Input";
+import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
import { useCreateBookmarkList } from "@karakeep/shared-react/hooks/lists";
@@ -40,14 +41,16 @@ const NewListPage = () => {
<View className="flex flex-row items-center gap-1">
<Text className="shrink p-2">🚀</Text>
<Input
- className="flex-1"
+ className="flex-1 bg-card"
onChangeText={setText}
placeholder="List Name"
autoFocus
autoCapitalize={"none"}
/>
</View>
- <Button disabled={isPending} onPress={onSubmit} label="Save" />
+ <Button disabled={isPending} onPress={onSubmit}>
+ <Text>Save</Text>
+ </Button>
</View>
</CustomSafeAreaView>
);
diff --git a/apps/mobile/app/dashboard/search.tsx b/apps/mobile/app/dashboard/search.tsx
index 5cc97575..66423870 100644
--- a/apps/mobile/app/dashboard/search.tsx
+++ b/apps/mobile/app/dashboard/search.tsx
@@ -1,18 +1,12 @@
import { useMemo, useRef, useState } from "react";
-import {
- FlatList,
- Keyboard,
- Pressable,
- Text,
- TextInput,
- View,
-} from "react-native";
-import { router } from "expo-router";
+import { FlatList, Keyboard, Pressable, TextInput, View } from "react-native";
+import { router, Stack } from "expo-router";
import BookmarkList from "@/components/bookmarks/BookmarkList";
import FullPageError from "@/components/FullPageError";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import { Input } from "@/components/ui/Input";
+import { SearchInput } from "@/components/ui/SearchInput";
+import { Text } from "@/components/ui/Text";
import { api } from "@/lib/trpc";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { keepPreviousData } from "@tanstack/react-query";
@@ -102,24 +96,26 @@ export default function Search() {
return (
<CustomSafeAreaView>
- <View className="flex flex-row items-center gap-3 p-3">
- <Input
- ref={inputRef}
- placeholder="Search"
- className="flex-1"
- value={search}
- onChangeText={setSearch}
- onFocus={handleOnFocus}
- onBlur={handleOnBlur}
- onSubmitEditing={() => handleSearchSubmit(search)}
- returnKeyType="search"
- autoFocus
- autoCapitalize="none"
- />
- <Pressable onPress={() => router.back()}>
- <Text className="text-foreground">Cancel</Text>
- </Pressable>
- </View>
+ <Stack.Screen
+ options={{
+ headerShown: true,
+ }}
+ />
+ <SearchInput
+ containerClassName="m-3"
+ ref={inputRef}
+ placeholder="Search"
+ className="flex-1"
+ value={search}
+ onChangeText={setSearch}
+ onFocus={handleOnFocus}
+ onBlur={handleOnBlur}
+ onSubmitEditing={() => handleSearchSubmit(search)}
+ returnKeyType="search"
+ autoFocus
+ autoCapitalize="none"
+ onCancel={router.back}
+ />
{isInputFocused ? (
<FlatList
diff --git a/apps/mobile/app/dashboard/settings/bookmark-default-view.tsx b/apps/mobile/app/dashboard/settings/bookmark-default-view.tsx
index c8c522cf..5f4463ae 100644
--- a/apps/mobile/app/dashboard/settings/bookmark-default-view.tsx
+++ b/apps/mobile/app/dashboard/settings/bookmark-default-view.tsx
@@ -1,7 +1,8 @@
-import { Pressable, Text, View } from "react-native";
+import { Pressable, View } from "react-native";
import { useRouter } from "expo-router";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import { Divider } from "@/components/ui/Divider";
+import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
import useAppSettings from "@/lib/settings";
import { Check } from "lucide-react-native";
@@ -41,9 +42,7 @@ export default function BookmarkDefaultViewSettings() {
className="flex flex-row justify-between"
key={mode}
>
- <Text className="text-lg text-accent-foreground">
- {{ browser: "Browser", reader: "Reader" }[mode]}
- </Text>
+ <Text>{{ browser: "Browser", reader: "Reader" }[mode]}</Text>
{isChecked && <Check color="rgb(0, 122, 255)" />}
</Pressable>,
<Divider
@@ -59,7 +58,7 @@ export default function BookmarkDefaultViewSettings() {
return (
<CustomSafeAreaView>
<View className="flex h-full w-full items-center px-4 py-2">
- <View className="w-full rounded-lg bg-white px-4 py-2 dark:bg-accent">
+ <View className="w-full rounded-lg bg-card bg-card px-4 py-2">
{options}
</View>
</View>
diff --git a/apps/mobile/app/dashboard/settings/theme.tsx b/apps/mobile/app/dashboard/settings/theme.tsx
index f7feacdb..a4f0494a 100644
--- a/apps/mobile/app/dashboard/settings/theme.tsx
+++ b/apps/mobile/app/dashboard/settings/theme.tsx
@@ -1,6 +1,7 @@
-import { Pressable, Text, View } from "react-native";
+import { Pressable, View } from "react-native";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import { Divider } from "@/components/ui/Divider";
+import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
import { Check } from "lucide-react-native";
@@ -16,7 +17,7 @@ export default function ThemePage() {
className="flex flex-row justify-between"
key={theme}
>
- <Text className="text-lg text-accent-foreground">
+ <Text>
{
{ light: "Light Mode", dark: "Dark Mode", system: "System" }[
theme
@@ -38,9 +39,7 @@ export default function ThemePage() {
return (
<CustomSafeAreaView>
<View className="flex h-full w-full items-center px-4 py-2">
- <View className="w-full rounded-lg bg-white px-4 py-2 dark:bg-accent">
- {options}
- </View>
+ <View className="w-full rounded-lg bg-card px-4 py-2">{options}</View>
</View>
</CustomSafeAreaView>
);
diff --git a/apps/mobile/app/error.tsx b/apps/mobile/app/error.tsx
index d0e4a7df..6e975306 100644
--- a/apps/mobile/app/error.tsx
+++ b/apps/mobile/app/error.tsx
@@ -1,9 +1,10 @@
-import { Text, View } from "react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/Text";
export default function ErrorPage() {
return (
<View className="flex-1 items-center justify-center gap-4">
- <Text className="text-4xl">Error!</Text>
+ <Text variant="largeTitle">Error!</Text>
</View>
);
}
diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx
index 506b5100..1e5df4b8 100644
--- a/apps/mobile/app/sharing.tsx
+++ b/apps/mobile/app/sharing.tsx
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from "react";
-import { ActivityIndicator, Pressable, Text, View } from "react-native";
+import { ActivityIndicator, Pressable, View } from "react-native";
import { useRouter } from "expo-router";
import { useShareIntentContext } from "expo-share-intent";
import { Button } from "@/components/ui/Button";
+import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
import { api } from "@/lib/trpc";
import { useUploadAsset } from "@/lib/upload";
@@ -73,7 +74,7 @@ function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) {
return (
<View className="flex flex-row gap-3">
- <Text className="text-4xl text-foreground">Hoarding</Text>
+ <Text variant="largeTitle">Hoarding</Text>
<ActivityIndicator />
</View>
);
@@ -95,18 +96,19 @@ export default function Sharing() {
case "success": {
comp = (
<View className="items-center gap-4">
- <Text className="text-4xl text-foreground">
+ <Text variant="largeTitle">
{mode.type === "alreadyExists" ? "Already Hoarded!" : "Hoarded!"}
</Text>
<Button
- label="Manage"
onPress={() => {
router.replace(`/dashboard/bookmarks/${mode.bookmarkId}/info`);
if (autoCloseTimeoutId.current) {
clearTimeout(autoCloseTimeoutId.current);
}
}}
- />
+ >
+ <Text>Manage</Text>
+ </Button>
<Pressable onPress={() => router.replace("dashboard")}>
<Text className="text-muted-foreground">Dismiss</Text>
</Pressable>
@@ -115,7 +117,7 @@ export default function Sharing() {
break;
}
case "error": {
- comp = <Text className="text-4xl text-foreground">Error!</Text>;
+ comp = <Text variant="largeTitle">Error!</Text>;
break;
}
}
diff --git a/apps/mobile/app/signin.tsx b/apps/mobile/app/signin.tsx
index 0d160398..215b6a67 100644
--- a/apps/mobile/app/signin.tsx
+++ b/apps/mobile/app/signin.tsx
@@ -4,18 +4,17 @@ import {
KeyboardAvoidingView,
Platform,
Pressable,
- Text,
TouchableWithoutFeedback,
View,
} from "react-native";
import { Redirect, useRouter } from "expo-router";
import Logo from "@/components/Logo";
import { TailwindResolver } from "@/components/TailwindResolver";
-import { Button, buttonVariants } from "@/components/ui/Button";
+import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
+import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
import { api } from "@/lib/trpc";
-import { cn } from "@/lib/utils";
import { Bug } from "lucide-react-native";
enum LoginType {
@@ -134,6 +133,7 @@ export default function Signin() {
<Text className="font-bold">Server Address</Text>
<Input
className="w-full"
+ inputClasses="bg-card"
placeholder="Server Address"
value={formState.serverAddress}
autoCapitalize="none"
@@ -150,6 +150,7 @@ export default function Signin() {
<Text className="font-bold">Email</Text>
<Input
className="w-full"
+ inputClasses="bg-card"
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
@@ -163,6 +164,7 @@ export default function Signin() {
<Text className="font-bold">Password</Text>
<Input
className="w-full"
+ inputClasses="bg-card"
placeholder="Password"
secureTextEntry
value={formState.password}
@@ -181,6 +183,7 @@ export default function Signin() {
<Text className="font-bold">API Key</Text>
<Input
className="w-full"
+ inputClasses="bg-card"
placeholder="API Key"
secureTextEntry
value={formState.apiKey}
@@ -193,18 +196,17 @@ export default function Signin() {
<View className="flex flex-row items-center justify-between gap-2">
<Button
- className="flex-1"
- label="Sign In"
+ size="lg"
+ androidRootClassName="flex-1"
onPress={onSignin}
disabled={
userNamePasswordRequestIsPending || apiKeyValueRequestIsPending
}
- />
- <Pressable
- className={cn(
- buttonVariants({ variant: "default" }),
- !settings.address && "bg-gray-500",
- )}
+ >
+ <Text>Sign In</Text>
+ </Button>
+ <Button
+ size="icon"
onPress={() => router.push("/test-connection")}
disabled={!settings.address}
>
@@ -212,9 +214,9 @@ export default function Signin() {
comp={(styles) => (
<Bug size={20} color={styles?.color?.toString()} />
)}
- className="text-background"
+ className="text-white"
/>
- </Pressable>
+ </Button>
</View>
<Pressable onPress={toggleLoginType}>
<Text className="mt-2 text-center text-gray-500">
diff --git a/apps/mobile/app/test-connection.tsx b/apps/mobile/app/test-connection.tsx
index 5639c6bd..a9ec6e5e 100644
--- a/apps/mobile/app/test-connection.tsx
+++ b/apps/mobile/app/test-connection.tsx
@@ -1,9 +1,10 @@
import React from "react";
-import { Platform, Text, View } from "react-native";
+import { Platform, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { Button } from "@/components/ui/Button";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import { Input } from "@/components/ui/Input";
+import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
import { cn } from "@/lib/utils";
import { z } from "zod";
@@ -79,19 +80,22 @@ export default function TestConnection() {
<View className="m-4 flex flex-col gap-2 p-2">
<Button
className="w-full"
- label="Copy Diagnostics Result"
onPress={async () => {
await Clipboard.setStringAsync(text);
}}
- />
+ >
+ <Text>Copy Diagnostics Result</Text>
+ </Button>
<Button
className="w-full"
- label="Retry"
+ variant="secondary"
onPress={() => {
setText("");
setRandomId(Math.random());
}}
- />
+ >
+ <Text>Retry</Text>
+ </Button>
<View
className={cn(
"w-full rounded-md p-2",
diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js
index f3c649bb..c8d46f96 100644
--- a/apps/mobile/babel.config.js
+++ b/apps/mobile/babel.config.js
@@ -1,9 +1,12 @@
module.exports = function (api) {
api.cache(true);
+ const plugins = [];
+ plugins.push("react-native-reanimated/plugin");
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
+ plugins,
};
};
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 = {
diff --git a/apps/mobile/globals.css b/apps/mobile/globals.css
index bf0da7e1..992b92cd 100644
--- a/apps/mobile/globals.css
+++ b/apps/mobile/globals.css
@@ -1 +1,131 @@
-@import "@karakeep/tailwind-config/globals";
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 242 242 247;
+ --foreground: 0 0 0;
+ --card: 255 255 255;
+ --card-foreground: 0 0 0;
+ --popover: 230 230 235;
+ --popover-foreground: 0 0 0;
+ --primary: 0 123 255;
+ --primary-foreground: 255 255 255;
+ --secondary: 45 185 227;
+ --secondary-foreground: 255 255 255;
+ --muted: 176 176 181;
+ --muted-foreground: 102 102 102;
+ --accent: 255 40 84;
+ --accent-foreground: 255 255 255;
+ --destructive: 255 56 43;
+ --destructive-foreground: 255 255 255;
+ --border: 230 230 235;
+ --input: 210 210 215;
+ --ring: 230 230 235;
+
+ --android-background: 250 252 255;
+ --android-foreground: 27 28 29;
+ --android-card: 255 255 255;
+ --android-card-foreground: 24 28 35;
+ --android-popover: 215 217 228;
+ --android-popover-foreground: 0 0 0;
+ --android-primary: 0 112 233;
+ --android-primary-foreground: 255 255 255;
+ --android-secondary: 176 201 255;
+ --android-secondary-foreground: 28 60 114;
+ --android-muted: 176 176 181;
+ --android-muted-foreground: 102 102 102;
+ --android-accent: 169 73 204;
+ --android-accent-foreground: 255 255 255;
+ --android-destructive: 186 26 26;
+ --android-destructive-foreground: 255 255 255;
+ --android-border: 118 122 127;
+ --android-input: 197 201 206;
+ --android-ring: 118 122 127;
+
+ --web-background: 250 252 255;
+ --web-foreground: 27 28 29;
+ --web-card: 255 255 255;
+ --web-card-foreground: 24 28 35;
+ --web-popover: 215 217 228;
+ --web-popover-foreground: 0 0 0;
+ --web-primary: 0 112 233;
+ --web-primary-foreground: 255 255 255;
+ --web-secondary: 176 201 255;
+ --web-secondary-foreground: 28 60 114;
+ --web-muted: 216 226 255;
+ --web-muted-foreground: 0 26 65;
+ --web-accent: 169 73 204;
+ --web-accent-foreground: 255 255 255;
+ --web-destructive: 186 26 26;
+ --web-destructive-foreground: 255 255 255;
+ --web-border: 118 122 127;
+ --web-input: 197 201 206;
+ --web-ring: 118 122 127;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --background: 0 0 0;
+ --foreground: 255 255 255;
+ --card: 21 21 24;
+ --card-foreground: 255 255 255;
+ --popover: 40 40 40;
+ --popover-foreground: 255 255 255;
+ --primary: 3 133 255;
+ --primary-foreground: 255 255 255;
+ --secondary: 100 211 254;
+ --secondary-foreground: 255 255 255;
+ --muted: 112 112 115;
+ --muted-foreground: 226 226 231;
+ --accent: 255 52 95;
+ --accent-foreground: 255 255 255;
+ --destructive: 254 67 54;
+ --destructive-foreground: 255 255 255;
+ --border: 40 40 40;
+ --input: 51 51 51;
+ --ring: 40 40 40;
+
+ --android-background: 24 28 32;
+ --android-foreground: 221 227 233;
+ --android-card: 36 40 44;
+ --android-card-foreground: 197 201 206;
+ --android-popover: 70 74 78;
+ --android-popover-foreground: 197 201 206;
+ --android-primary: 0 69 148;
+ --android-primary-foreground: 214 224 255;
+ --android-secondary: 28 60 114;
+ --android-secondary-foreground: 255 255 255;
+ --android-muted: 112 112 115;
+ --android-muted-foreground: 226 226 231;
+ --android-accent: 83 0 111;
+ --android-accent-foreground: 255 255 255;
+ --android-destructive: 147 0 10;
+ --android-destructive-foreground: 255 255 255;
+ --android-border: 143 148 153;
+ --android-input: 70 74 78;
+ --android-ring: 143 148 153;
+
+ --web-background: 24 28 32;
+ --web-foreground: 221 227 233;
+ --web-card: 70 74 78;
+ --web-card-foreground: 197 201 206;
+ --web-popover: 70 74 78;
+ --web-popover-foreground: 197 201 206;
+ --web-primary: 0 69 148;
+ --web-primary-foreground: 214 224 255;
+ --web-secondary: 28 60 114;
+ --web-secondary-foreground: 255 255 255;
+ --web-muted: 29 27 29;
+ --web-muted-foreground: 230 224 228;
+ --web-accent: 83 0 111;
+ --web-accent-foreground: 255 255 255;
+ --web-destructive: 147 0 10;
+ --web-destructive-foreground: 255 255 255;
+ --web-border: 143 148 153;
+ --web-input: 70 74 78;
+ --web-ring: 143 148 153;
+ }
+ }
+}
diff --git a/apps/mobile/lib/useColorScheme.tsx b/apps/mobile/lib/useColorScheme.tsx
new file mode 100644
index 00000000..a00a445d
--- /dev/null
+++ b/apps/mobile/lib/useColorScheme.tsx
@@ -0,0 +1,58 @@
+import * as React from "react";
+import { Platform } from "react-native";
+import * as NavigationBar from "expo-navigation-bar";
+import useAppSettings from "@/lib/settings";
+import { COLORS } from "@/theme/colors";
+import { useColorScheme as useNativewindColorScheme } from "nativewind";
+
+function useColorScheme() {
+ const { settings, isLoading } = useAppSettings();
+ const { colorScheme, setColorScheme: setNativewindColorScheme } =
+ useNativewindColorScheme();
+
+ // Sync user settings with native color scheme
+ React.useEffect(() => {
+ setNativewindColorScheme(settings.theme);
+ }, [settings.theme, isLoading]);
+
+ React.useEffect(() => {
+ if (Platform.OS === "android") {
+ setNavigationBar(colorScheme ?? "light").catch((error) => {
+ console.error('useColorScheme.tsx", "setColorScheme', error);
+ });
+ }
+ }, [colorScheme]);
+
+ return {
+ colorScheme: colorScheme ?? "light",
+ isDarkColorScheme: colorScheme === "dark",
+ colors: COLORS[colorScheme ?? "light"],
+ };
+}
+
+/**
+ * Set the Android navigation bar color based on the color scheme.
+ */
+function useInitialAndroidBarSync() {
+ const { colorScheme } = useColorScheme();
+ React.useEffect(() => {
+ if (Platform.OS !== "android") return;
+ setNavigationBar(colorScheme).catch((error) => {
+ console.error('useColorScheme.tsx", "useInitialColorScheme', error);
+ });
+ }, []);
+}
+
+export { useColorScheme, useInitialAndroidBarSync };
+
+function setNavigationBar(colorScheme: "light" | "dark") {
+ return Promise.all([
+ NavigationBar.setButtonStyleAsync(
+ colorScheme === "dark" ? "light" : "dark",
+ ),
+ NavigationBar.setPositionAsync("absolute"),
+ NavigationBar.setBackgroundColorAsync(
+ colorScheme === "dark" ? "#00000030" : "#ffffff80",
+ ),
+ ]);
+}
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
index f9679cb2..78f79c62 100644
--- a/apps/mobile/metro.config.js
+++ b/apps/mobile/metro.config.js
@@ -9,7 +9,8 @@ module.exports = withTurborepoManagedCache(
// eslint-disable-next-line no-undef
withNativeWind(getDefaultConfig(__dirname), {
input: "./globals.css",
- configPath: "./tailwind.config.ts",
+ configPath: "./tailwind.config.js",
+ inlineRem: 16,
}),
),
);
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 0ed5668c..61c6ec6e 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -20,6 +20,10 @@
"@karakeep/trpc": "workspace:^0.1.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-menu/menu": "^1.2.4",
+ "@rn-primitives/hooks": "^1.3.0",
+ "@rn-primitives/slot": "^1.2.0",
+ "@roninoss/icons": "^0.0.4",
+ "@shopify/flash-list": "^2.0.3",
"@tanstack/react-query": "^5.80.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -29,7 +33,7 @@
"expo-clipboard": "^7.1.4",
"expo-constants": "~17.1.6",
"expo-dev-client": "^5.2.0",
- "expo-file-system": "~18.0.12",
+ "expo-file-system": "~18.1.11",
"expo-haptics": "^14.1.4",
"expo-image": "^2.2.0",
"expo-image-picker": "^16.1.4",
@@ -50,6 +54,7 @@
"react-native-blob-util": "^0.21.2",
"react-native-gesture-handler": "~2.24.0",
"react-native-image-viewing": "^0.2.2",
+ "react-native-keyboard-controller": "^1.18.5",
"react-native-markdown-display": "^7.0.2",
"react-native-pdf": "^6.7.7",
"react-native-reanimated": "^3.17.5",
diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js
new file mode 100644
index 00000000..74a9f30a
--- /dev/null
+++ b/apps/mobile/tailwind.config.js
@@ -0,0 +1,66 @@
+const { hairlineWidth, platformSelect } = require("nativewind/theme");
+
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ // NOTE: Update this to include the paths to all of your component files.
+ content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
+ presets: [require("nativewind/preset")],
+ theme: {
+ extend: {
+ colors: {
+ border: withOpacity("border"),
+ input: withOpacity("input"),
+ ring: withOpacity("ring"),
+ background: withOpacity("background"),
+ foreground: withOpacity("foreground"),
+ primary: {
+ DEFAULT: withOpacity("primary"),
+ foreground: withOpacity("primary-foreground"),
+ },
+ secondary: {
+ DEFAULT: withOpacity("secondary"),
+ foreground: withOpacity("secondary-foreground"),
+ },
+ destructive: {
+ DEFAULT: withOpacity("destructive"),
+ foreground: withOpacity("destructive-foreground"),
+ },
+ muted: {
+ DEFAULT: withOpacity("muted"),
+ foreground: withOpacity("muted-foreground"),
+ },
+ accent: {
+ DEFAULT: withOpacity("accent"),
+ foreground: withOpacity("accent-foreground"),
+ },
+ popover: {
+ DEFAULT: withOpacity("popover"),
+ foreground: withOpacity("popover-foreground"),
+ },
+ card: {
+ DEFAULT: withOpacity("card"),
+ foreground: withOpacity("card-foreground"),
+ },
+ },
+ borderWidth: {
+ hairline: hairlineWidth(),
+ },
+ },
+ },
+ plugins: [],
+};
+
+function withOpacity(variableName) {
+ return ({ opacityValue }) => {
+ if (opacityValue !== undefined) {
+ return platformSelect({
+ ios: `rgb(var(--${variableName}) / ${opacityValue})`,
+ android: `rgb(var(--android-${variableName}) / ${opacityValue})`,
+ });
+ }
+ return platformSelect({
+ ios: `rgb(var(--${variableName}))`,
+ android: `rgb(var(--android-${variableName}))`,
+ });
+ };
+}
diff --git a/apps/mobile/tailwind.config.ts b/apps/mobile/tailwind.config.ts
deleted file mode 100644
index 03712ec4..00000000
--- a/apps/mobile/tailwind.config.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { Config } from "tailwindcss";
-
-import base from "@karakeep/tailwind-config/native";
-
-const config = {
- content: base.content,
- plugins: [],
- presets: [base, require("nativewind/preset")],
-} satisfies Config;
-
-export default config;
diff --git a/apps/mobile/theme/colors.ts b/apps/mobile/theme/colors.ts
new file mode 100644
index 00000000..626bcb99
--- /dev/null
+++ b/apps/mobile/theme/colors.ts
@@ -0,0 +1,109 @@
+import { Platform } from "react-native";
+
+const IOS_SYSTEM_COLORS = {
+ white: "rgb(255, 255, 255)",
+ black: "rgb(0, 0, 0)",
+ light: {
+ grey6: "rgb(242, 242, 247)",
+ grey5: "rgb(230, 230, 235)",
+ grey4: "rgb(210, 210, 215)",
+ grey3: "rgb(199, 199, 204)",
+ grey2: "rgb(176, 176, 181)",
+ grey: "rgb(153, 153, 158)",
+ background: "rgb(242, 242, 247)",
+ foreground: "rgb(0, 0, 0)",
+ root: "rgb(242, 242, 247)",
+ card: "rgb(242, 242, 247)",
+ destructive: "rgb(255, 56, 43)",
+ primary: "rgb(0, 123, 255)",
+ },
+ dark: {
+ grey6: "rgb(21, 21, 24)",
+ grey5: "rgb(40, 40, 40)",
+ grey4: "rgb(51, 51, 51)",
+ grey3: "rgb(70, 70, 70)",
+ grey2: "rgb(99, 99, 99)",
+ grey: "rgb(158, 158, 158)",
+ background: "rgb(0, 0, 0)",
+ foreground: "rgb(255, 255, 255)",
+ root: "rgb(0, 0, 0)",
+ card: "rgb(0, 0, 0)",
+ destructive: "rgb(254, 67, 54)",
+ primary: "rgb(3, 133, 255)",
+ },
+} as const;
+
+const ANDROID_COLORS = {
+ white: "rgb(255, 255, 255)",
+ black: "rgb(0, 0, 0)",
+ light: {
+ grey6: "rgb(242, 242, 247)",
+ grey5: "rgb(230, 230, 235)",
+ grey4: "rgb(210, 210, 215)",
+ grey3: "rgb(199, 199, 204)",
+ grey2: "rgb(176, 176, 181)",
+ grey: "rgb(153, 153, 158)",
+ background: "rgb(250, 252, 255)",
+ foreground: "rgb(27, 28, 29)",
+ root: "rgb(250, 252, 255)",
+ card: "rgb(250, 252, 255)",
+ destructive: "rgb(186, 26, 26)",
+ primary: "rgb(0, 112, 233)",
+ },
+ dark: {
+ grey6: "rgb(21, 21, 24)",
+ grey5: "rgb(40, 40, 40)",
+ grey4: "rgb(51, 51, 51)",
+ grey3: "rgb(70, 70, 70)",
+ grey2: "rgb(99, 99, 99)",
+ grey: "rgb(158, 158, 158)",
+ background: "rgb(24, 28, 32)",
+ foreground: "rgb(221, 227, 233)",
+ root: "rgb(24, 28, 32)",
+ card: "rgb(24, 28, 32)",
+ destructive: "rgb(147, 0, 10)",
+ primary: "rgb(0, 69, 148)",
+ },
+} as const;
+
+const WEB_COLORS = {
+ white: "rgb(255, 255, 255)",
+ black: "rgb(0, 0, 0)",
+ light: {
+ grey6: "rgb(250, 252, 255)",
+ grey5: "rgb(243, 247, 251)",
+ grey4: "rgb(236, 242, 248)",
+ grey3: "rgb(233, 239, 247)",
+ grey2: "rgb(229, 237, 245)",
+ grey: "rgb(226, 234, 243)",
+ background: "rgb(250, 252, 255)",
+ foreground: "rgb(27, 28, 29)",
+ root: "rgb(250, 252, 255)",
+ card: "rgb(250, 252, 255)",
+ destructive: "rgb(186, 26, 26)",
+ primary: "rgb(0, 112, 233)",
+ },
+ dark: {
+ grey6: "rgb(25, 30, 36)",
+ grey5: "rgb(31, 38, 45)",
+ grey4: "rgb(35, 43, 52)",
+ grey3: "rgb(38, 48, 59)",
+ grey2: "rgb(40, 51, 62)",
+ grey: "rgb(44, 56, 68)",
+ background: "rgb(24, 28, 32)",
+ foreground: "rgb(221, 227, 233)",
+ root: "rgb(24, 28, 32)",
+ card: "rgb(24, 28, 32)",
+ destructive: "rgb(147, 0, 10)",
+ primary: "rgb(0, 69, 148)",
+ },
+} as const;
+
+const COLORS =
+ Platform.OS === "ios"
+ ? IOS_SYSTEM_COLORS
+ : Platform.OS === "android"
+ ? ANDROID_COLORS
+ : WEB_COLORS;
+
+export { COLORS };
diff --git a/apps/mobile/theme/index.ts b/apps/mobile/theme/index.ts
new file mode 100644
index 00000000..05b42bf7
--- /dev/null
+++ b/apps/mobile/theme/index.ts
@@ -0,0 +1,30 @@
+import { DarkTheme, DefaultTheme } from "@react-navigation/native";
+
+import { COLORS } from "./colors";
+
+const NAV_THEME = {
+ light: {
+ ...DefaultTheme,
+ colors: {
+ background: COLORS.light.background,
+ border: COLORS.light.grey5,
+ card: COLORS.light.card,
+ notification: COLORS.light.destructive,
+ primary: COLORS.light.primary,
+ text: COLORS.black,
+ },
+ },
+ dark: {
+ ...DarkTheme,
+ colors: {
+ background: COLORS.dark.background,
+ border: COLORS.dark.grey5,
+ card: COLORS.dark.grey6,
+ notification: COLORS.dark.destructive,
+ primary: COLORS.dark.primary,
+ text: COLORS.white,
+ },
+ },
+};
+
+export { NAV_THEME };