diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-12-29 10:23:36 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-29 08:23:36 +0000 |
| commit | 6ee48ffb9d628a04c487b73b222be76241ff3ec4 (patch) | |
| tree | 1cf65592f867a9c10d8961f195bcf6dfd438273e /apps/mobile | |
| parent | f7523a210b8929483d2436b2795329f81065e4b8 (diff) | |
| download | karakeep-6ee48ffb9d628a04c487b73b222be76241ff3ec4.tar.zst | |
feat(mobile): make the settings menu look more native (#2307)
* feat(mobile): make the settings menu look more native
* more fixes
* review comments
Diffstat (limited to 'apps/mobile')
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/settings.tsx | 180 | ||||
| -rw-r--r-- | apps/mobile/components/settings/UserProfileHeader.tsx | 27 | ||||
| -rw-r--r-- | apps/mobile/components/ui/Avatar.tsx | 88 | ||||
| -rw-r--r-- | apps/mobile/components/ui/CustomSafeAreaView.tsx | 1 | ||||
| -rw-r--r-- | apps/mobile/components/ui/List.tsx | 41 |
5 files changed, 234 insertions, 103 deletions
diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx index db19b6fe..106baec5 100644 --- a/apps/mobile/app/dashboard/(tabs)/settings.tsx +++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx @@ -4,11 +4,11 @@ import { Slider } from "react-native-awesome-slider"; import { useSharedValue } from "react-native-reanimated"; import Constants from "expo-constants"; import { Link } from "expo-router"; +import { UserProfileHeader } from "@/components/settings/UserProfileHeader"; 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 { useServerVersion } from "@/lib/hooks"; import { useSession } from "@/lib/session"; @@ -31,7 +31,7 @@ export default function Dashboard() { imageQuality.value = settings.imageQuality * 100; }, [settings]); - const { data, error, isLoading } = api.users.whoami.useQuery(); + const { data, error } = api.users.whoami.useQuery(); const { data: serverVersion, isLoading: isServerVersionLoading, @@ -44,103 +44,106 @@ export default function Dashboard() { return ( <CustomSafeAreaView> - <PageTitle title="Settings" /> + <UserProfileHeader + image={data?.image} + name={data?.name} + email={data?.email} + /> <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-card px-4 py-2"> - <Text>{isSettingsLoading ? "Loading ..." : settings.address}</Text> - <Divider orientation="horizontal" /> - <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-card px-4 py-2"> - <Link asChild href="/dashboard/settings/theme" className="flex-1"> - <Pressable className="flex flex-row justify-between"> - <Text>Theme</Text> - <View className="flex flex-row items-center gap-2"> - <Text className="text-muted-foreground"> - { - { light: "Light", dark: "Dark", system: "System" }[ - settings.theme - ] - } - </Text> - <ChevronRight /> - </View> - </Pressable> - </Link> - </View> - <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>Default Bookmark View</Text> - <View className="flex flex-row items-center gap-2"> - {isSettingsLoading ? ( - <ActivityIndicator size="small" /> - ) : ( + <View className="w-full rounded-xl bg-card py-2"> + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link asChild href="/dashboard/settings/theme" className="flex-1"> + <Pressable className="flex flex-row justify-between"> + <Text>Theme</Text> + <View className="flex flex-row items-center gap-2"> <Text className="text-muted-foreground"> - {settings.defaultBookmarkView === "reader" - ? "Reader" - : "Browser"} + { + { light: "Light", dark: "Dark", system: "System" }[ + settings.theme + ] + } </Text> - )} + <ChevronRight /> + </View> + </Pressable> + </Link> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link + asChild + href="/dashboard/settings/bookmark-default-view" + className="flex-1" + > + <Pressable className="flex flex-row justify-between"> + <Text>Default Bookmark View</Text> + <View className="flex flex-row items-center gap-2"> + {isSettingsLoading ? ( + <ActivityIndicator size="small" /> + ) : ( + <Text className="text-muted-foreground"> + {settings.defaultBookmarkView === "reader" + ? "Reader" + : "Browser"} + </Text> + )} + <ChevronRight /> + </View> + </Pressable> + </Link> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Link + asChild + href="/dashboard/settings/reader-settings" + className="flex-1" + > + <Pressable className="flex flex-row justify-between"> + <Text>Reader Text Settings</Text> <ChevronRight /> - </View> - </Pressable> - </Link> - </View> - <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/reader-settings" - className="flex-1" - > - <Pressable className="flex flex-row justify-between"> - <Text>Reader Text Settings</Text> - <ChevronRight /> - </Pressable> - </Link> - </View> - <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> - <Text>Show note preview in bookmark</Text> - <Switch - value={settings.showNotes} - onValueChange={(value) => - setSettings({ - ...settings, - showNotes: value, - }) - } - /> - </View> - <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-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)}% + </Pressable> + </Link> + </View> + <Divider orientation="horizontal" className="mx-6 my-1" /> + <View className="flex flex-row items-center justify-between gap-8 px-4 py-1"> + <Text className="flex-1" numberOfLines={1}> + Show notes in bookmark card </Text> - <Slider - onSlidingComplete={(value) => + <Switch + className="shrink-0" + value={settings.showNotes} + onValueChange={(value) => setSettings({ ...settings, - imageQuality: Math.round(value) / 100, + showNotes: value, }) } - progress={imageQuality} - minimumValue={imageQualityMin} - maximumValue={imageQualityMax} /> </View> </View> - <Divider orientation="horizontal" /> + + <View className="w-full rounded-xl bg-card py-2"> + <View className="flex w-full flex-row items-center justify-between gap-8 px-4 py-1"> + <Text>Upload 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)}% + </Text> + <Slider + onSlidingComplete={(value) => + setSettings({ + ...settings, + imageQuality: Math.round(value) / 100, + }) + } + progress={imageQuality} + minimumValue={imageQualityMin} + maximumValue={imageQualityMax} + /> + </View> + </View> + </View> <Button androidRootClassName="w-full" onPress={logout} @@ -150,6 +153,9 @@ export default function Dashboard() { </Button> <View className="mt-4 w-full gap-1"> <Text className="text-center text-sm text-muted-foreground"> + {isSettingsLoading ? "Loading..." : settings.address} + </Text> + <Text className="text-center text-sm text-muted-foreground"> App Version: {Constants.expoConfig?.version ?? "unknown"} </Text> <Text className="text-center text-sm text-muted-foreground"> diff --git a/apps/mobile/components/settings/UserProfileHeader.tsx b/apps/mobile/components/settings/UserProfileHeader.tsx new file mode 100644 index 00000000..6e389877 --- /dev/null +++ b/apps/mobile/components/settings/UserProfileHeader.tsx @@ -0,0 +1,27 @@ +import { View } from "react-native"; +import { Avatar } from "@/components/ui/Avatar"; +import { Text } from "@/components/ui/Text"; + +interface UserProfileHeaderProps { + image?: string | null; + name?: string | null; + email?: string | null; +} + +export function UserProfileHeader({ + image, + name, + email, +}: UserProfileHeaderProps) { + return ( + <View className="w-full items-center gap-2 py-6"> + <Avatar image={image} name={name} size={88} /> + <View className="items-center gap-1"> + <Text className="text-xl font-semibold">{name || "User"}</Text> + {email && ( + <Text className="text-sm text-muted-foreground">{email}</Text> + )} + </View> + </View> + ); +} diff --git a/apps/mobile/components/ui/Avatar.tsx b/apps/mobile/components/ui/Avatar.tsx new file mode 100644 index 00000000..923c634e --- /dev/null +++ b/apps/mobile/components/ui/Avatar.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; +import { Image, View } from "react-native"; +import { Text } from "@/components/ui/Text"; +import { useAssetUrl } from "@/lib/hooks"; +import { cn } from "@/lib/utils"; + +interface AvatarProps { + image?: string | null; + name?: string | null; + size?: number; + className?: string; + imageClassName?: string; + fallbackClassName?: string; +} + +function isExternalUrl(url: string) { + return url.startsWith("http://") || url.startsWith("https://"); +} + +export function Avatar({ + image, + name, + size = 40, + className, + imageClassName, + fallbackClassName, +}: AvatarProps) { + const [imageError, setImageError] = React.useState(false); + const assetUrl = useAssetUrl(image ?? ""); + + const imageUrl = React.useMemo(() => { + if (!image) return null; + return isExternalUrl(image) + ? { + uri: image, + } + : assetUrl; + }, [image]); + + React.useEffect(() => { + setImageError(false); + }, [image]); + + const initials = React.useMemo(() => { + if (!name) return "U"; + return name.charAt(0).toUpperCase(); + }, [name]); + + const showFallback = !imageUrl || imageError; + + return ( + <View + className={cn("overflow-hidden bg-black", className)} + style={{ + width: size, + height: size, + borderRadius: size / 2, + }} + > + {showFallback ? ( + <View + className={cn( + "flex h-full w-full items-center justify-center bg-black", + fallbackClassName, + )} + > + <Text + className="text-white" + style={{ + fontSize: size * 0.4, + lineHeight: size * 0.4, + textAlign: "center", + }} + > + {initials} + </Text> + </View> + ) : ( + <Image + source={imageUrl} + className={cn("h-full w-full", imageClassName)} + style={{ resizeMode: "cover" }} + onError={() => setImageError(true)} + /> + )} + </View> + ); +} diff --git a/apps/mobile/components/ui/CustomSafeAreaView.tsx b/apps/mobile/components/ui/CustomSafeAreaView.tsx index fdf6520d..840ea058 100644 --- a/apps/mobile/components/ui/CustomSafeAreaView.tsx +++ b/apps/mobile/components/ui/CustomSafeAreaView.tsx @@ -15,6 +15,7 @@ export default function CustomSafeAreaView({ return ( <SafeAreaView style={{ + flex: 1, paddingTop: // Some ugly hacks to make the app look the same on both android and ios Platform.OS == "android" && edges.includes("top") diff --git a/apps/mobile/components/ui/List.tsx b/apps/mobile/components/ui/List.tsx index 52ff5779..67f0a9af 100644 --- a/apps/mobile/components/ui/List.tsx +++ b/apps/mobile/components/ui/List.tsx @@ -29,7 +29,9 @@ cssInterop(FlashList, { type ListDataItem = string | { title: string; subTitle?: string }; type ListVariant = "insets" | "full-width"; -type ListRef<T extends ListDataItem> = React.Ref<typeof FlashList<T>>; +type ListRef<T extends ListDataItem> = React.Ref< + React.ComponentRef<typeof FlashList<T>> +>; type ListRenderItemProps<T extends ListDataItem> = ListRenderItemInfo<T> & { variant?: ListVariant; @@ -76,17 +78,20 @@ const rootVariants = cva("min-h-2 flex-1", { }, }); -function ListComponent<T extends ListDataItem>({ - variant = "full-width", - rootClassName, - rootStyle, - contentContainerClassName, - renderItem, - data, - sectionHeaderAsGap = false, - contentInsetAdjustmentBehavior = "automatic", - ...props -}: ListProps<T>) { +function ListComponent<T extends ListDataItem>( + { + variant = "full-width", + rootClassName, + rootStyle, + contentContainerClassName, + renderItem, + data, + sectionHeaderAsGap = false, + contentInsetAdjustmentBehavior = "automatic", + ...props + }: ListProps<T>, + ref: ListRef<T>, +) { const insets = useSafeAreaInsets(); return ( <View @@ -100,6 +105,7 @@ function ListComponent<T extends ListDataItem>({ style={rootStyle} > <FlashList + ref={ref} data={data} contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior} renderItem={renderItemWithVariant( @@ -311,10 +317,9 @@ function ListItemComponent<T extends ListDataItem>( {!!leftView && <View>{leftView}</View>} <View className={cn( - "h-full flex-1 flex-row", + "h-full flex-1 flex-row pr-4", !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", @@ -328,7 +333,7 @@ function ListItemComponent<T extends ListDataItem>( <Text numberOfLines={textNumberOfLines} style={titleStyle} - className={titleClassName} + className={cn("text-base", titleClassName)} > {item.title} </Text> @@ -343,7 +348,11 @@ function ListItemComponent<T extends ListDataItem>( </Text> )} </View> - {!!rightView && <View>{rightView}</View>} + {!!rightView && ( + <View className="flex items-center justify-center"> + {rightView} + </View> + )} </View> </TextClassContext.Provider> </Button> |
