From ed86f7ef012fb558fe8a8974e1e162ce75cbfd15 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 26 Aug 2025 15:47:05 +0300 Subject: feat(mobile): Retheme the mobile app (#1872) * Add nativewindui * migrate to nativewindui text * Replace buttons with nativewindui buttons * Use nativewindui search input * fix the divider color * More changes * fix manage tag icon * fix styling of bookmark card * fix ios compilation * fix search clear * fix tag pill border color * Store theme setting in app settings * fix setting color appearance * fix coloring of search input * fix following system theme * add a save button to info * fix the grey colors on android * fix icon active tint color * drop the use of TextField --- apps/mobile/components/ui/List.tsx | 469 +++++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 apps/mobile/components/ui/List.tsx (limited to 'apps/mobile/components/ui/List.tsx') 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 = React.Ref>; + +type ListRenderItemProps = ListRenderItemInfo & { + variant?: ListVariant; + isFirstInSection?: boolean; + isLastInSection?: boolean; + sectionHeaderAsGap?: boolean; +}; + +type ListProps = Omit< + FlashListProps, + "renderItem" +> & { + renderItem?: ListRenderItem; + variant?: ListVariant; + sectionHeaderAsGap?: boolean; + rootClassName?: string; + rootStyle?: StyleProp; +}; +type ListRenderItem = ( + props: ListRenderItemProps, +) => ReturnType>; + +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({ + variant = "full-width", + rootClassName, + rootStyle, + contentContainerClassName, + renderItem, + data, + sectionHeaderAsGap = false, + contentInsetAdjustmentBehavior = "automatic", + ...props +}: ListProps) { + const insets = useSafeAreaInsets(); + return ( + + + + ); +} + +function getItemType(item: T) { + return typeof item === "string" ? "sectioHeader" : "row"; +} + +function renderItemWithVariant( + renderItem: ListRenderItem | null | undefined, + variant: ListVariant, + data: readonly T[] | null | undefined, + sectionHeaderAsGap?: boolean, +) { + return (args: ListRenderItemProps) => { + 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 ( + props: ListProps & { ref?: ListRef }, +) => 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 = PressableProps & + ListRenderItemProps & { + androidRootClassName?: string; + titleClassName?: string; + titleStyle?: StyleProp; + textNumberOfLines?: number; + subTitleClassName?: string; + subTitleStyle?: StyleProp; + subTitleNumberOfLines?: number; + textContentClassName?: string; + leftView?: React.ReactNode; + rightView?: React.ReactNode; + removeSeparator?: boolean; + }; +type ListItemRef = React.Ref; + +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( + { + item, + isFirstInSection, + isLastInSection, + index: _index, + variant, + className, + androidRootClassName, + titleClassName, + titleStyle, + textNumberOfLines, + subTitleStyle, + subTitleClassName, + subTitleNumberOfLines, + textContentClassName, + sectionHeaderAsGap, + removeSeparator = false, + leftView, + rightView, + disabled, + ...props + }: ListItemProps, + ref: ListItemRef, +) { + if (typeof item === "string") { + console.log( + "List.tsx", + "ListItemComponent", + "Invalid item of type 'string' was provided. Use ListSectionHeader instead.", + ); + return null; + } + return ( + <> + + {!removeSeparator && Platform.OS !== "ios" && !isLastInSection && ( + + + + )} + + ); +} + +const ListItem = React.forwardRef(ListItemComponent) as < + T extends ListDataItem, +>( + props: ListItemProps & { ref?: ListItemRef }, +) => React.ReactElement; + +type ListSectionHeaderProps = ViewProps & + ListRenderItemProps & { + textClassName?: string; + }; +type ListSectionHeaderRef = React.Ref; + +function ListSectionHeaderComponent( + { + item, + isFirstInSection: _isFirstInSection, + isLastInSection: _isLastInSection, + index: _index, + variant, + className, + textClassName, + sectionHeaderAsGap, + ...props + }: ListSectionHeaderProps, + 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 ( + + + + ); + } + return ( + + + {item} + + + ); +} + +const ListSectionHeader = React.forwardRef(ListSectionHeaderComponent) as < + T extends ListDataItem, +>( + props: ListSectionHeaderProps & { ref?: ListSectionHeaderRef }, +) => React.ReactElement; + +const ESTIMATED_ITEM_HEIGHT = { + titleOnly: Platform.select({ ios: 45, default: 57 }), + withSubTitle: 56, +}; + +function getStickyHeaderIndices(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, +}; -- cgit v1.2.3-70-g09d2