From ed86f7ef012fb558fe8a8974e1e162ce75cbfd15 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
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/Button.tsx | 239 ++++++++++++++++++++++++++---------
 1 file changed, 179 insertions(+), 60 deletions(-)

(limited to 'apps/mobile/components/ui/Button.tsx')

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 };
-- 
cgit v1.2.3-70-g09d2

