diff --git a/components/TransactionPreview/TransactionResult.tsx b/components/TransactionPreview/TransactionResult.tsx index 200ab93f3..a6b08ad8c 100644 --- a/components/TransactionPreview/TransactionResult.tsx +++ b/components/TransactionPreview/TransactionResult.tsx @@ -32,7 +32,7 @@ const $failure: ThemedStyle = ({ spacing, borderRadius }) => ({ marginBottom: spacing.md, padding: spacing.sm, backgroundColor: "#FFF5F5", - borderRadius: borderRadius.xs, + borderRadius: borderRadius.xxs, }); const styles = StyleSheet.create({ diff --git a/components/TransactionPreview/TransactionSimulationFailure.tsx b/components/TransactionPreview/TransactionSimulationFailure.tsx index d6e73370d..734f75cb1 100644 --- a/components/TransactionPreview/TransactionSimulationFailure.tsx +++ b/components/TransactionPreview/TransactionSimulationFailure.tsx @@ -27,5 +27,5 @@ const $failure: ThemedStyle = ({ spacing, borderRadius }) => ({ marginBottom: spacing.md, padding: spacing.sm, backgroundColor: "#FFF5F5", - borderRadius: borderRadius.xs, + borderRadius: borderRadius.xxs, }); diff --git a/design-system/Button/Button.props.ts b/design-system/Button/Button.props.ts index 1c2db1ded..a22acfb9a 100644 --- a/design-system/Button/Button.props.ts +++ b/design-system/Button/Button.props.ts @@ -14,6 +14,7 @@ export type IButtonVariant = | "outline" | "fill" | "link" + | "link.bare" /** @deprecated */ | "secondary" | "secondary-danger" diff --git a/design-system/Button/Button.styles.ts b/design-system/Button/Button.styles.ts index 2f2ca092e..26ff62567 100644 --- a/design-system/Button/Button.styles.ts +++ b/design-system/Button/Button.styles.ts @@ -53,6 +53,16 @@ export const getButtonViewStyle = size === "md" || size === "sm" ? spacing.xs : spacing.sm, }; + // Special case for bare link text buttons - no padding or other decorations + if (variant === "link.bare") { + return { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.xxs, + }; + } + if (action === "primary") { switch (variant) { case "fill": diff --git a/design-system/Icon/Icon.android.tsx b/design-system/Icon/Icon.android.tsx index 614f13388..db4e93815 100644 --- a/design-system/Icon/Icon.android.tsx +++ b/design-system/Icon/Icon.android.tsx @@ -18,6 +18,7 @@ export const iconRegistry: Record< link: "link", paperplane: "send", account_circle: "account-circle", + pencil: "edit", "square.and.pencil": "edit", qrcode: "qr-code", "message.circle.fill": "chat", @@ -58,6 +59,7 @@ export const iconRegistry: Record< "arrowshape.turn.up.left": "reply", "arrowshape.turn.up.left.fill": "reply", "person.crop.circle.badge.plus": "group-add", + "person.crop.circle.badge.xmark": "person-remove", tray: "inbox", cloud: "cloud", "exclamationmark.triangle": "warning", @@ -89,8 +91,8 @@ export function Icon(props: IIconProps) { const iconName = icon ? iconRegistry[icon] : picto - ? iconRegistry[picto] - : null; + ? iconRegistry[picto] + : null; if (!iconName) { logger.warn(`Invalid icon name ${icon || picto}`); diff --git a/design-system/Icon/Icon.tsx b/design-system/Icon/Icon.tsx index d75cfd0d7..4753617da 100644 --- a/design-system/Icon/Icon.tsx +++ b/design-system/Icon/Icon.tsx @@ -15,6 +15,7 @@ export const iconRegistry: Record = { link: "link", paperplane: "paperplane", account_circle: "person.circle", + pencil: "pencil", "square.and.pencil": "square.and.pencil", qrcode: "qrcode", "message.circle.fill": "message.circle.fill", @@ -55,6 +56,7 @@ export const iconRegistry: Record = { "arrowshape.turn.up.left": "arrowshape.turn.up.left", "arrowshape.turn.up.left.fill": "arrowshape.turn.up.left.fill", "person.crop.circle.badge.plus": "person.crop.circle.badge.plus", + "person.crop.circle.badge.xmark": "person.crop.circle.badge.xmark", tray: "tray", cloud: "cloud", "exclamationmark.triangle": "exclamationmark.triangle", @@ -98,8 +100,8 @@ export function Icon(props: IIconProps) { const iconName = icon ? iconRegistry[icon] : picto - ? iconRegistry[picto] - : null; + ? iconRegistry[picto] + : null; if (!iconName) { logger.warn( diff --git a/design-system/Icon/Icon.types.ts b/design-system/Icon/Icon.types.ts index 321779ebd..cfcdb0185 100644 --- a/design-system/Icon/Icon.types.ts +++ b/design-system/Icon/Icon.types.ts @@ -12,6 +12,7 @@ export type IIconName = | "link" | "paperplane" | "account_circle" + | "pencil" | "square.and.pencil" | "qrcode" | "message.circle.fill" @@ -52,6 +53,7 @@ export type IIconName = | "arrowshape.turn.up.left" | "arrowshape.turn.up.left.fill" | "person.crop.circle.badge.plus" + | "person.crop.circle.badge.xmark" | "tray" | "cloud" | "exclamationmark.triangle" diff --git a/design-system/TextField/TextField.tsx b/design-system/TextField/TextField.tsx index bdb6dc3ca..0b4a88e4f 100644 --- a/design-system/TextField/TextField.tsx +++ b/design-system/TextField/TextField.tsx @@ -179,7 +179,7 @@ const $inputWrapperStyle: ThemedStyle = ({ spacing, }) => ({ borderWidth: borderWidth.sm, - borderRadius: borderRadius.xs, + borderRadius: borderRadius.xxs, backgroundColor: colors.background.surface, borderColor: colors.border.subtle, overflow: "hidden", diff --git a/design-system/chip.tsx b/design-system/chip.tsx index 3fec7e3c4..ac53f7ca3 100644 --- a/design-system/chip.tsx +++ b/design-system/chip.tsx @@ -55,7 +55,10 @@ export function Chip({ {avatarUrl && ( )} - + {text} @@ -73,9 +76,10 @@ const $chip: ThemedStyle = ({ borderWidth: borderWidth.sm, borderColor: colors.border.subtle, paddingVertical: spacing.xxs, - paddingHorizontal: spacing.xs, - minHeight: 36, - // ...debugBorder("orange"), + paddingHorizontal: spacing.sm, + minHeight: 36, // from Figma + justifyContent: "center", + backgroundColor: colors.background.surface, }); const $chipActive: ThemedStyle = ({ colors }) => ({ diff --git a/design-system/settings-list/settings-list-row.tsx b/design-system/settings-list/settings-list-row.tsx new file mode 100644 index 000000000..35ecb8e86 --- /dev/null +++ b/design-system/settings-list/settings-list-row.tsx @@ -0,0 +1,80 @@ +import React, { memo } from "react"; +import { View, ViewStyle, Switch, TouchableOpacity } from "react-native"; +import { Text } from "@/design-system/Text"; +import { useAppTheme, ThemedStyle } from "@/theme/useAppTheme"; +import { HStack } from "@/design-system/HStack"; +import { VStack } from "@/design-system/VStack"; +import { Icon } from "@/design-system/Icon/Icon"; +import { ISettingsListRow } from "./settings-list.types"; + +type ISettingsListRowProps = { + row: ISettingsListRow; + editMode?: boolean; +}; + +export const SettingsListRow = memo(function SettingsListRow({ + row, + editMode, +}: ISettingsListRowProps) { + const { theme, themed } = useAppTheme(); + + const content = ( + + + {row.label} + {row.value && ( + + {row.value} + + )} + + + {row.isSwitch ? ( + + ) : ( + + )} + + + ); + + if (row.onPress) { + return ( + + {content} + + ); + } + + return {content}; +}); + +const $rowContainer: ThemedStyle = ({ spacing }) => ({ + paddingVertical: spacing.md, + width: "100%", + flexDirection: "row", + alignItems: "center", +}); + +const $innerRowContainer: ThemedStyle = () => ({ + width: "100%", + justifyContent: "space-between", + alignItems: "center", +}); + +const $rightContentContainer: ThemedStyle = () => ({ + marginLeft: "auto", + alignItems: "flex-end", + justifyContent: "center", +}); diff --git a/design-system/settings-list/settings-list.tsx b/design-system/settings-list/settings-list.tsx new file mode 100644 index 000000000..d61e51d4b --- /dev/null +++ b/design-system/settings-list/settings-list.tsx @@ -0,0 +1,33 @@ +import React, { memo } from "react"; +import { View, ViewStyle } from "react-native"; +import { useAppTheme, ThemedStyle } from "@/theme/useAppTheme"; +import { SettingsListRow } from "./settings-list-row"; +import { ISettingsListRow } from "./settings-list.types"; + +type ISettingsListProps = { + rows: ISettingsListRow[]; + editMode?: boolean; +}; + +export const SettingsList = memo(function SettingsList({ + rows, + editMode, +}: ISettingsListProps) { + const { themed } = useAppTheme(); + + return ( + + {rows.map((row, index) => ( + + ))} + + ); +}); + +const $container: ThemedStyle = () => ({ + width: "100%", +}); diff --git a/design-system/settings-list/settings-list.types.ts b/design-system/settings-list/settings-list.types.ts new file mode 100644 index 000000000..5f7ba18d6 --- /dev/null +++ b/design-system/settings-list/settings-list.types.ts @@ -0,0 +1,9 @@ +export type ISettingsListRow = { + label: string; + value?: string | boolean; + onPress?: () => void; + onValueChange?: (value: boolean) => void; + isWarning?: boolean; + isSwitch?: boolean; + disabled?: boolean; +}; diff --git a/features/ExternalWalletPicker/ExternalWalletPicker.tsx b/features/ExternalWalletPicker/ExternalWalletPicker.tsx index 3dc3818bd..94bdbeeff 100644 --- a/features/ExternalWalletPicker/ExternalWalletPicker.tsx +++ b/features/ExternalWalletPicker/ExternalWalletPicker.tsx @@ -102,7 +102,7 @@ export function ExternalWalletPicker(props: IExternalWalletPickerProps) { style={{ width: theme.avatarSize.md, height: theme.avatarSize.md, - borderRadius: theme.borderRadius.xs, + borderRadius: theme.borderRadius.xxs, }} /> diff --git a/features/conversation-list/conversation-list.screen-header.tsx b/features/conversation-list/conversation-list.screen-header.tsx index ae8103d48..f0e620732 100644 --- a/features/conversation-list/conversation-list.screen-header.tsx +++ b/features/conversation-list/conversation-list.screen-header.tsx @@ -134,7 +134,7 @@ export function useHeaderWrapper() { { displayInline: true, id: "app-settings", - title: translate("App settings"), + title: translate("app_settings"), image: iconRegistry["settings"], }, ]} diff --git a/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx b/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx index 518c7bc44..2329da3e1 100644 --- a/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx +++ b/features/conversation/conversation-composer/conversation-composer-reply-preview.tsx @@ -190,7 +190,7 @@ const ReplyPreviewEndContent = memo(function ReplyPreviewEndContent(props: { style: { height: theme.avatarSize.md, width: theme.avatarSize.md, - borderRadius: theme.borderRadius.xs, + borderRadius: theme.borderRadius.xxs, }, }} /> @@ -215,7 +215,7 @@ const ReplyPreviewEndContent = memo(function ReplyPreviewEndContent(props: { style: { height: theme.avatarSize.md, width: theme.avatarSize.md, - borderRadius: theme.borderRadius.xs, + borderRadius: theme.borderRadius.xxs, }, }} /> diff --git a/features/conversation/conversation.nav.tsx b/features/conversation/conversation.nav.tsx index 345c9773c..03bd69aff 100644 --- a/features/conversation/conversation.nav.tsx +++ b/features/conversation/conversation.nav.tsx @@ -1,6 +1,12 @@ import { ConversationScreen } from "@/features/conversation/conversation.screen"; import { NativeStack } from "@/screens/Navigation/Navigation"; -import type { ConversationTopic } from "@xmtp/react-native-sdk"; +import { translate } from "@/i18n"; +import { useAppTheme } from "@/theme/useAppTheme"; +import { HStack } from "@design-system/HStack"; +import { HeaderAction } from "@/design-system/Header/HeaderAction"; +import { useNavigation } from "@react-navigation/native"; +import { Platform } from "react-native"; +import { ConversationTopic } from "@xmtp/react-native-sdk"; export type ConversationNavParams = { topic?: ConversationTopic; @@ -19,11 +25,14 @@ export const ConversationScreenConfig = { }; export function ConversationNav() { + const { theme } = useAppTheme(); + const navigation = useNavigation(); + return ( Promise; + setNotificationsSettings: (settings: { + showNotificationScreen: boolean; + }) => void; +}; + +export function useNotificationsPermission(): UseNotificationsPermissionReturn { + const notificationsPermissionStatus = useAppStore( + (s) => s.notificationsPermissionStatus + ); + const setNotificationsPermissionStatus = useAppStore( + (s) => s.setNotificationsPermissionStatus + ); + const setNotificationsSettings = useSettingsStore( + (s) => s.setNotificationsSettings + ); + + const requestPermission = async () => { + if (notificationsPermissionStatus === "denied") { + if (Platform.OS === "android") { + // Android 13 is always denied first so let's try to show + const newStatus = await requestPushNotificationsPermissions(); + if (newStatus === "denied") { + Linking.openSettings(); + } else if (newStatus) { + setNotificationsPermissionStatus(newStatus); + } + } else { + Linking.openSettings(); + } + } else if (notificationsPermissionStatus === "undetermined") { + // Open popup + const newStatus = await requestPushNotificationsPermissions(); + if (!newStatus) return; + setNotificationsPermissionStatus(newStatus); + } + }; + + return { + notificationsPermissionStatus, + requestPermission, + setNotificationsSettings, + }; +} diff --git a/features/profiles/components/contact-card.tsx b/features/profiles/components/contact-card.tsx new file mode 100644 index 000000000..8495d167e --- /dev/null +++ b/features/profiles/components/contact-card.tsx @@ -0,0 +1,146 @@ +import React, { memo } from "react"; +import { View, Dimensions, Alert } from "react-native"; +import { Avatar } from "@/components/Avatar"; +import { Text } from "@/design-system/Text"; +import { VStack } from "@/design-system/VStack"; +import { Button } from "@/design-system/Button/Button"; +import { useAppTheme } from "@/theme/useAppTheme"; +import Animated, { + useAnimatedStyle, + withSpring, + useSharedValue, +} from "react-native-reanimated"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; + +type IContactCardProps = { + displayName: string; + userName?: string; + avatarUri?: string; + isMyProfile?: boolean; + editMode?: boolean; + onToggleEdit?: () => void; +}; + +/** + * ContactCard Component + * + * A card component that displays contact information with a 3D tilt effect. + * Includes display name, username and avatar with interactive animations + */ +export const ContactCard = memo(function ContactCard({ + displayName, + userName, + avatarUri, + isMyProfile, + editMode, + onToggleEdit, +}: IContactCardProps) { + const { theme } = useAppTheme(); + const { width: screenWidth } = Dimensions.get("window"); + + const rotateX = useSharedValue(0); + const rotateY = useSharedValue(0); + const shadowOffsetX = useSharedValue(0); + const shadowOffsetY = useSharedValue(6); + + const baseStyle = { + backgroundColor: theme.colors.fill.primary, + borderRadius: theme.borderRadius.xxs, + padding: theme.spacing.xl, + marginTop: theme.spacing.xs, + marginBottom: theme.spacing.lg, + shadowColor: theme.colors.fill.primary, + shadowOpacity: 0.25, + shadowRadius: 12, + elevation: 5, + // Maintains credit card aspect ratio + height: (screenWidth - 2 * theme.spacing.lg) * 0.628, + }; + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { perspective: 800 }, + { rotateX: `${rotateX.value}deg` }, + { rotateY: `${rotateY.value}deg` }, + ], + shadowOffset: { + width: shadowOffsetX.value, + height: shadowOffsetY.value, + }, + ...baseStyle, + })); + + const panGesture = Gesture.Pan() + .onBegin(() => { + rotateX.value = withSpring(0); + rotateY.value = withSpring(0); + shadowOffsetX.value = withSpring(0); + shadowOffsetY.value = withSpring(0); + }) + .onUpdate((event) => { + rotateX.value = event.translationY / 10; + rotateY.value = event.translationX / 10; + shadowOffsetX.value = -event.translationX / 20; + shadowOffsetY.value = event.translationY / 20; + }) + .onEnd(() => { + rotateX.value = withSpring(0); + rotateY.value = withSpring(0); + shadowOffsetX.value = withSpring(0); + shadowOffsetY.value = withSpring(0); + }); + + return ( + + + + {/* Top row with Avatar and Edit button */} + + + {/* TODO: "Names" menu to pick from when SCW will be done */} + {/* {isMyProfile && ( +