diff --git a/react-native-expo/.storybook/main.ts b/react-native-expo/.storybook/main.ts index d3ec2ce..9f430ea 100644 --- a/react-native-expo/.storybook/main.ts +++ b/react-native-expo/.storybook/main.ts @@ -1,3 +1,4 @@ +import { lingui } from '@lingui/vite-plugin'; import path from 'path'; import react from '@vitejs/plugin-react'; import type { StorybookConfig } from '@storybook/react-vite'; @@ -19,8 +20,19 @@ const config: StorybookConfig = { }, }, plugins: [ - // Allows us to use JSX without `import React from 'react'` - react({ jsxRuntime: 'automatic' }), + // Required so that i18n works in Storybook + lingui(), + react({ + // Allows us to use JSX without `import React from 'react'` + jsxRuntime: 'automatic', + babel: { + plugins: [ + // Required so that Lingui `t()` calls and `` components + // will work. + '@lingui/babel-plugin-lingui-macro', + ], + }, + }), // Allows us to `import foo from './foo.svg'` in Storybook Web just like // in React Native. svgr({ svgrOptions: { exportType: 'default' } }), diff --git a/react-native-expo/.storybook/preview.tsx b/react-native-expo/.storybook/preview.tsx index 7834214..f935a8d 100644 --- a/react-native-expo/.storybook/preview.tsx +++ b/react-native-expo/.storybook/preview.tsx @@ -1,7 +1,9 @@ import { DripsyProvider } from 'dripsy'; import dripsyTheme from '../utils/dripsyTheme'; +import PraxI18nProvider from '../components/PraxI18nProvider'; import type { Preview } from '@storybook/react'; import React from 'react'; +import ReduxProvider from '../components/ReduxProvider'; /** * Ideally, we'd use `` in the root decorator to provide fonts * to Storybook. But that caused weird import issues. So for now, we'll just add @@ -12,9 +14,13 @@ import './fonts.css'; const preview: Preview = { decorators: [ Story => ( - - - + + + + + + + ), ], parameters: { diff --git a/react-native-expo/app/transactions.tsx b/react-native-expo/app/transactions.tsx new file mode 100644 index 0000000..b764d6a --- /dev/null +++ b/react-native-expo/app/transactions.tsx @@ -0,0 +1,5 @@ +import TransactionsScreen from '@/components/TransactionsScreen'; + +export default function TransactionsRoute() { + return ; +} diff --git a/react-native-expo/components/BackButtonHeader/index.tsx b/react-native-expo/components/BackButtonHeader/index.tsx new file mode 100644 index 0000000..99e984c --- /dev/null +++ b/react-native-expo/components/BackButtonHeader/index.tsx @@ -0,0 +1,19 @@ +import { Pressable } from 'dripsy'; +import Header from '../Header'; +import Icon from '../Icon'; +import { ArrowLeftCircle } from 'lucide-react-native'; +import { useRouter } from 'expo-router'; + +export default function BackButtonHeader() { + const router = useRouter(); + + return ( +
router.back()}> + + + } + /> + ); +} diff --git a/react-native-expo/components/BalanceAndActions/index.tsx b/react-native-expo/components/BalanceAndActions/index.tsx new file mode 100644 index 0000000..ff83856 --- /dev/null +++ b/react-native-expo/components/BalanceAndActions/index.tsx @@ -0,0 +1,71 @@ +import { Trans } from '@lingui/react/macro'; +import { Sx, Text, View } from 'dripsy'; +import Button from '../Button'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { close, open } from '@/store/depositFlow'; +import DepositFlow from '../DepositFlow'; + +/** + * Renders the user's current balance, as well as buttons for actions related to + * their balance (such as Send/Deposit/Request). + */ +export default function BalanceAndActions() { + const dispatch = useAppDispatch(); + const isOpen = useAppSelector(state => state.depositFlow.isOpen); + + return ( + <> + + + + Balance + + 0.00 USDC + + + + + + + + + dispatch(close())} /> + + ); +} + +const sx = { + balance: { + variant: 'text.h4', + }, + + balanceLabel: { + variant: 'text.small', + + color: 'neutralLight', + }, + + balanceWrapper: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flexGrow: 1, + }, + + buttons: { + flexDirection: 'row', + flexGrow: 0, + gap: '$2', + px: '$4', + pb: '$4', + }, + + root: { + flexGrow: 1, + flexDirection: 'column', + }, +} satisfies Record; diff --git a/react-native-expo/components/Header/index.tsx b/react-native-expo/components/Header/index.tsx index 7a83769..6c3cde1 100644 --- a/react-native-expo/components/Header/index.tsx +++ b/react-native-expo/components/Header/index.tsx @@ -1,18 +1,34 @@ -import { Image, Sx, View, SafeAreaView } from 'dripsy'; -import logo from './logo.png'; -import Avatar from '../Avatar'; +import { SafeAreaView, Sx, View } from 'dripsy'; +import { ReactNode } from 'react'; -export default function Header() { +export interface HeaderProps { + /** + * Content to render on the left side of the header, like a back button or + * avatar. + */ + left?: ReactNode; + /** + * Content to render in the left side of the header, like a title or logo. + */ + center?: ReactNode; + /** Content to render on the right side of the header. */ + right?: ReactNode; +} + +/** + * Render via a screen's `options.header` to show a header at the top of the + * screen. + * + * If you're looking for a header with a back button, use + * ``. + */ +export default function Header({ left, center, right }: HeaderProps) { return ( - - - - - - - + {left} + {center} + {right} ); @@ -31,12 +47,6 @@ const sx = { height: '$8', }, - logo: { - height: 16, - width: 76, - resizeMode: 'contain', - }, - right: { width: '$8', height: '$8', diff --git a/react-native-expo/components/HomeScreen/HomeScreenTransactionsList.tsx b/react-native-expo/components/HomeScreen/HomeScreenTransactionsList.tsx new file mode 100644 index 0000000..0fbfd2b --- /dev/null +++ b/react-native-expo/components/HomeScreen/HomeScreenTransactionsList.tsx @@ -0,0 +1,37 @@ +import { useAppSelector } from '@/store/hooks'; +import TransactionList from '../TransactionList'; +import { shallowEqual } from 'react-redux'; +import { Sx, Text } from 'dripsy'; +import { Trans } from '@lingui/react/macro'; +import { Link } from 'expo-router'; + +function SeeAllButton() { + return ( + + + See all + + + ); +} + +/** + * A preview of the latest few transactions a user has, with a button to view + * them all. + */ +export default function HomeScreenTransactionsList() { + const first5Transactions = useAppSelector( + state => state.transactions.transactions.slice(0, 5), + shallowEqual, + ); + + return ( + } showTitle /> + ); +} + +const sx = { + seeAllButtonLabel: { + textDecorationLine: 'underline', + }, +} satisfies Record; diff --git a/react-native-expo/components/HomeScreen/index.tsx b/react-native-expo/components/HomeScreen/index.tsx index 5be6d42..f041f4d 100644 --- a/react-native-expo/components/HomeScreen/index.tsx +++ b/react-native-expo/components/HomeScreen/index.tsx @@ -1,69 +1,21 @@ -import Button from '../Button'; -import { Sx, Text, View } from 'dripsy'; -import DepositFlow from '../DepositFlow'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { close, open } from '@/store/depositFlow'; -import { Trans } from '@lingui/react/macro'; - -export interface HomeScreenProps {} +import { Sx, View } from 'dripsy'; +import HomeScreenTransactionsList from './HomeScreenTransactionsList'; +import BalanceAndActions from '../BalanceAndActions'; export default function HomeScreen() { - const dispatch = useAppDispatch(); - const isOpen = useAppSelector(state => state.depositFlow.isOpen); - return ( - <> - - - - Balance - - 0.00 USDC - + + {/** @todo: Make this a `ScrollView`. */} + - - - - - - - dispatch(close())} /> - + + ); } const sx = { - balance: { - variant: 'text.h4', - }, - - balanceLabel: { - variant: 'text.small', - - color: 'neutralLight', - }, - - balanceWrapper: { - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - flexGrow: 1, - }, - - buttons: { - flexDirection: 'row', - flexGrow: 0, - gap: '$2', - px: '$4', - pb: '$4', - }, - root: { flexGrow: 1, - flexDirection: 'column', + px: 'screenHorizontalMargin', }, } satisfies Record; diff --git a/react-native-expo/components/Icon/index.tsx b/react-native-expo/components/Icon/index.tsx index 6aeb1b3..269b8fa 100644 --- a/react-native-expo/components/Icon/index.tsx +++ b/react-native-expo/components/Icon/index.tsx @@ -3,7 +3,7 @@ import { useSx } from 'dripsy'; import { LucideIcon } from 'lucide-react-native'; import { ComponentProps, FC } from 'react'; -export type IconSize = 'sm' | 'md' | 'lg'; +export type IconSize = 'xs' | 'sm' | 'md' | 'lg'; export interface IconProps { /** @@ -16,6 +16,7 @@ export interface IconProps { */ IconComponent: LucideIcon | FC; /** + * - `xs`: 10px square * - `sm`: 16px square * - `md`: 24px square * - `lg`: 32px square @@ -28,6 +29,10 @@ export interface IconProps { } const PROPS_BY_SIZE: Record> = { + xs: { + size: 10, + strokeWidth: 1, + }, sm: { size: 16, strokeWidth: 1, diff --git a/react-native-expo/components/List/index.stories.tsx b/react-native-expo/components/List/index.stories.tsx new file mode 100644 index 0000000..e2e9d77 --- /dev/null +++ b/react-native-expo/components/List/index.stories.tsx @@ -0,0 +1,32 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import List from '.'; +import ListItem from '../ListItem'; +import AssetIcon from '../AssetIcon'; +import { Text } from 'dripsy'; + +const meta: Meta = { + component: List, + tags: ['autodocs'], + argTypes: { + children: { control: false }, + primaryAction: { control: false }, + }, +}; + +export default meta; + +export const Basic: StoryObj = { + args: { + title: 'Assets', + children: ( + <> + } /> + } /> + } /> + } /> + + ), + primaryAction: See all, + }, +}; diff --git a/react-native-expo/components/List/index.tsx b/react-native-expo/components/List/index.tsx new file mode 100644 index 0000000..52defda --- /dev/null +++ b/react-native-expo/components/List/index.tsx @@ -0,0 +1,51 @@ +import { Sx, Text, View } from 'dripsy'; +import { ReactNode } from 'react'; + +export interface ListProps { + /** + * A series of ``s. + */ + children: ReactNode; + title?: string; + /** Button/etc. to render to the right of the title. */ + primaryAction?: ReactNode; +} + +/** + * Presents list items as a unit with a shaded background and an optional title. + * + * To simply display list items with a gap between each (but no background or + * title), use ``. + */ +export default function List({ children, title, primaryAction }: ListProps) { + return ( + + {!!title && ( + + {title} + + {primaryAction} + + )} + {children} + + ); +} + +const sx = { + root: { + backgroundColor: 'neutralContrast', + + borderRadius: 'lg', + + flexDirection: 'column', + }, + + title: { + p: '$4', + pb: '$2', + + flexDirection: 'row', + justifyContent: 'space-between', + }, +} satisfies Record; diff --git a/react-native-expo/components/ListItem/index.tsx b/react-native-expo/components/ListItem/index.tsx index 1cb4ea6..ad7fc81 100644 --- a/react-native-expo/components/ListItem/index.tsx +++ b/react-native-expo/components/ListItem/index.tsx @@ -40,12 +40,16 @@ export default function ListItem({ {avatar} - {primaryText} + {primaryText} - {!!secondaryText && {secondaryText}} + {!!secondaryText && ( + + {secondaryText} + + )} - {suffix} + {!!suffix && {suffix}} ); } @@ -62,16 +66,21 @@ const sx = { p: '$4', }, - secondary: { + secondaryText: { variant: 'text.small', color: 'neutralLight', }, + suffixWrapper: { + flexShrink: 0, + }, + textWrapper: { flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 0.5, flexGrow: 1, + flexShrink: 1, }, } satisfies Record; diff --git a/react-native-expo/components/ListItems/index.tsx b/react-native-expo/components/ListItems/index.tsx index c0ea993..a8b673e 100644 --- a/react-native-expo/components/ListItems/index.tsx +++ b/react-native-expo/components/ListItems/index.tsx @@ -8,6 +8,8 @@ export interface ListItemsProps { /** * Simple wrapper to use around multiple ``s to give them consistent * spacing throughout the app. + * + * For a styled list including a header and colored background, use ``. */ export default function ListItems({ children }: ListItemsProps) { return {children}; diff --git a/react-native-expo/components/PortfolioScreen/BalanceList/AssetActionSheet/index.tsx b/react-native-expo/components/PortfolioScreen/BalanceList/AssetActionSheet/index.tsx new file mode 100644 index 0000000..5b84ba9 --- /dev/null +++ b/react-native-expo/components/PortfolioScreen/BalanceList/AssetActionSheet/index.tsx @@ -0,0 +1,69 @@ +import ActionSheet from '@/components/ActionSheet'; +import AssetIcon from '@/components/AssetIcon'; +import TransactionList from '@/components/TransactionList'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { setSelectedAssetSymbol } from '@/store/portfolioScreen'; +import { Sx, Text, View } from 'dripsy'; +import useTransactionsForAsset from './useTransactionsForAsset'; + +/** + * An action sheet for a given asset, with a list of relevant transactions and + * buttons for actions related to the asset (such as sending). + */ +export default function AssetActionSheet() { + const selectedAssetSymbol = useAppSelector(state => state.portfolioScreen.selectedAssetSymbol); + const balance = useAppSelector(state => + state.balances.balances.find(balance => balance.assetSymbol === selectedAssetSymbol), + ); + const dispatch = useAppDispatch(); + const transactions = useTransactionsForAsset(selectedAssetSymbol); + + return ( + dispatch(setSelectedAssetSymbol(undefined))} + > + + + + + + + {balance?.amount} {balance?.assetSymbol} + + + {balance?.equivalentValue} USDC + + + + + ); +} + +const sx = { + assetIconWrapper: { + pb: '$2', + }, + + balance: { + variant: 'text.h4', + + textAlign: 'center', + }, + + equivalentValue: { + variant: 'text.small', + + color: 'neutralLight', + textAlign: 'center', + }, + + header: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flexGrow: 1, + gap: '$1', + mb: '$4', + }, +} satisfies Record; diff --git a/react-native-expo/components/PortfolioScreen/BalanceList/AssetActionSheet/useTransactionsForAsset.ts b/react-native-expo/components/PortfolioScreen/BalanceList/AssetActionSheet/useTransactionsForAsset.ts new file mode 100644 index 0000000..a46e1f2 --- /dev/null +++ b/react-native-expo/components/PortfolioScreen/BalanceList/AssetActionSheet/useTransactionsForAsset.ts @@ -0,0 +1,20 @@ +import { useAppSelector } from '@/store/hooks'; +import { useMemo } from 'react'; + +/** Returns a memoized array of the transactions for a given asset. */ +export default function useTransactionsForAsset( + /** The symbol of the asset that transactions should be filtered by. */ + assetSymbol?: string, +) { + const transactions = useAppSelector(state => state.transactions.transactions); + + const transactionsForAsset = useMemo( + () => + assetSymbol + ? transactions.filter(transaction => transaction.assetSymbol === assetSymbol) + : [], + [transactions, assetSymbol], + ); + + return transactionsForAsset; +} diff --git a/react-native-expo/components/PortfolioScreen/BalanceList/Balance/index.stories.ts b/react-native-expo/components/PortfolioScreen/BalanceList/Balance/index.stories.ts new file mode 100644 index 0000000..4fb39db --- /dev/null +++ b/react-native-expo/components/PortfolioScreen/BalanceList/Balance/index.stories.ts @@ -0,0 +1,20 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Balance from '.'; +import balanceFactory from '@/factories/balance'; + +const meta: Meta = { + component: Balance, + tags: ['autodocs'], + argTypes: { + balance: { control: false }, + }, +}; + +export default meta; + +export const Basic: StoryObj = { + args: { + balance: balanceFactory.build(), + }, +}; diff --git a/react-native-expo/components/PortfolioScreen/BalanceList/Balance/index.tsx b/react-native-expo/components/PortfolioScreen/BalanceList/Balance/index.tsx new file mode 100644 index 0000000..7739643 --- /dev/null +++ b/react-native-expo/components/PortfolioScreen/BalanceList/Balance/index.tsx @@ -0,0 +1,41 @@ +import AssetIcon from '@/components/AssetIcon'; +import ListItem from '@/components/ListItem'; +import { useAppDispatch } from '@/store/hooks'; +import { setSelectedAssetSymbol } from '@/store/portfolioScreen'; +import IBalance from '@/types/Balance'; +import { Sx, Text, View } from 'dripsy'; + +export interface BalanceProps { + balance: IBalance; +} + +export default function Balance({ balance }: BalanceProps) { + const dispatch = useAppDispatch(); + + return ( + } + primaryText={balance.assetSymbol} + secondaryText={balance.assetName} + suffix={ + + {balance.amount} + {balance.equivalentValue} USDC + + } + onPress={() => dispatch(setSelectedAssetSymbol(balance.assetSymbol))} + /> + ); +} + +const sx = { + equivalentValue: { + variant: 'text.detail', + color: 'neutralLight', + }, + + suffix: { + flexDirection: 'column', + alignItems: 'flex-end', + }, +} satisfies Record; diff --git a/react-native-expo/components/PortfolioScreen/BalanceList/index.tsx b/react-native-expo/components/PortfolioScreen/BalanceList/index.tsx new file mode 100644 index 0000000..fc06416 --- /dev/null +++ b/react-native-expo/components/PortfolioScreen/BalanceList/index.tsx @@ -0,0 +1,26 @@ +import List from '@/components/List'; +import IBalance from '@/types/Balance'; +import { useLingui } from '@lingui/react/macro'; +import Balance from './Balance'; +import AssetActionSheet from './AssetActionSheet'; + +export interface BalanceListProps { + balances: IBalance[]; +} + +/** Shows a list of the user's balances in every asset they hold. */ +export default function BalanceList({ balances }: BalanceListProps) { + const { t } = useLingui(); + + return ( + <> + + {balances.map(balance => ( + + ))} + + + + + ); +} diff --git a/react-native-expo/components/PortfolioScreen/index.tsx b/react-native-expo/components/PortfolioScreen/index.tsx index 98c10d2..eedd2aa 100644 --- a/react-native-expo/components/PortfolioScreen/index.tsx +++ b/react-native-expo/components/PortfolioScreen/index.tsx @@ -1,5 +1,24 @@ -import { Text } from 'dripsy'; +import { Sx, View } from 'dripsy'; +import BalanceAndActions from '../BalanceAndActions'; +import BalanceList from './BalanceList'; +import { useAppSelector } from '@/store/hooks'; export default function PortfolioScreen() { - return PortfolioScreen; + const balances = useAppSelector(state => state.balances.balances); + + return ( + + {/** @todo: Make this a `ScrollView`. */} + + + + + ); } + +const sx = { + root: { + flexGrow: 1, + px: 'screenHorizontalMargin', + }, +} satisfies Record; diff --git a/react-native-expo/components/PraxDripsyProvider/index.tsx b/react-native-expo/components/PraxDripsyProvider/index.tsx new file mode 100644 index 0000000..a9469d7 --- /dev/null +++ b/react-native-expo/components/PraxDripsyProvider/index.tsx @@ -0,0 +1,11 @@ +import dripsyTheme from '@/utils/dripsyTheme'; +import { DripsyProvider } from 'dripsy'; +import { ReactNode } from 'react'; + +export interface PraxDripsyProviderProps { + children: ReactNode; +} + +export default function PraxDripsyProvider({ children }: PraxDripsyProviderProps) { + return {children}; +} diff --git a/react-native-expo/components/PraxI18nProvider/index.tsx b/react-native-expo/components/PraxI18nProvider/index.tsx new file mode 100644 index 0000000..1e02068 --- /dev/null +++ b/react-native-expo/components/PraxI18nProvider/index.tsx @@ -0,0 +1,14 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { messages } from '@/locales/en/messages'; +import { ReactNode } from 'react'; + +i18n.loadAndActivate({ locale: 'en', messages }); + +export interface PraxI18nProviderProps { + children: ReactNode; +} + +export default function PraxI18nProvider({ children }: PraxI18nProviderProps) { + return {children}; +} diff --git a/react-native-expo/components/RootLayout/index.tsx b/react-native-expo/components/RootLayout/index.tsx index 289156c..8abf42f 100644 --- a/react-native-expo/components/RootLayout/index.tsx +++ b/react-native-expo/components/RootLayout/index.tsx @@ -1,15 +1,11 @@ import AppInitializationContext from '@/contexts/AppInitializationContext'; -import { DripsyProvider } from 'dripsy'; -import dripsyTheme from '@/utils/dripsyTheme'; import FontProvider from './FontProvider'; -import { i18n } from '@lingui/core'; -import { I18nProvider } from '@lingui/react'; -import { messages } from '@/locales/en/messages'; +import PraxDripsyProvider from '../PraxDripsyProvider'; +import PraxI18nProvider from '../PraxI18nProvider'; import ReduxProvider from '../ReduxProvider'; import { Stack } from 'expo-router'; import useInitializeApp from './useInitializeApp'; - -i18n.loadAndActivate({ locale: 'en', messages }); +import BackButtonHeader from '../BackButtonHeader'; const STACK_SCREEN_OPTIONS = { contentStyle: { backgroundColor: 'white' }, @@ -20,18 +16,22 @@ export default function RootLayout() { const appInitialization = useInitializeApp(); return ( - + - + + }} + /> - + - + ); } diff --git a/react-native-expo/components/TabScreenHeader/index.tsx b/react-native-expo/components/TabScreenHeader/index.tsx new file mode 100644 index 0000000..9ea0e3d --- /dev/null +++ b/react-native-expo/components/TabScreenHeader/index.tsx @@ -0,0 +1,17 @@ +import Avatar from '@/components/Avatar'; +import Header from '@/components/Header'; +import { Image, Sx } from 'dripsy'; +import logo from './logo.png'; + +/** The header used by the primary screens accessed via tabs. */ +export default function TabScreenHeader() { + return
} center={} />; +} + +const sx = { + logo: { + height: 16, + width: 76, + resizeMode: 'contain', + }, +} satisfies Record; diff --git a/react-native-expo/components/Header/logo.png b/react-native-expo/components/TabScreenHeader/logo.png similarity index 100% rename from react-native-expo/components/Header/logo.png rename to react-native-expo/components/TabScreenHeader/logo.png diff --git a/react-native-expo/components/TabsLayout/index.tsx b/react-native-expo/components/TabsLayout/index.tsx index e702572..fd776a9 100644 --- a/react-native-expo/components/TabsLayout/index.tsx +++ b/react-native-expo/components/TabsLayout/index.tsx @@ -6,6 +6,7 @@ import Icon from '../Icon'; import { ParamListBase, RouteProp } from '@react-navigation/native'; import { Text } from 'dripsy'; import { useLingui } from '@lingui/react/macro'; +import TabScreenHeader from '../TabScreenHeader'; const ICONS_BY_ROUTE: Record = { index: Home, @@ -45,19 +46,6 @@ const TABS_SCREEN_OPTIONS = ({ route }: { route: RouteProp { const color = focused ? 'neutralDark' : 'neutralLight'; - let IconComponent = Home; - switch (route.name) { - case 'trade': - IconComponent = ChartCandlestick; - break; - case 'portfolio': - IconComponent = Coins; - break; - default: - IconComponent = Home; - break; - } - return ; }, // @ts-expect-error - Types are wrong for `title` @@ -69,11 +57,11 @@ export default function TabsLayout() { return ( // @ts-expect-error - Types are wrong for `title` - + }} /> - + }} /> ); } diff --git a/react-native-expo/components/TextInput/index.tsx b/react-native-expo/components/TextInput/index.tsx new file mode 100644 index 0000000..71c038a --- /dev/null +++ b/react-native-expo/components/TextInput/index.tsx @@ -0,0 +1,28 @@ +import { TextInput as DripsyTextInput, Sx, View } from 'dripsy'; + +export interface TextInputProps { + value?: string; + onChangeText?: (value: string) => void; +} + +/** + * Wraps React Native's ``, while adding Prax styles. + */ +export default function TextInput({ value, onChangeText }: TextInputProps) { + return ( + + + + ); +} + +const sx = { + root: { + borderColor: 'actionNeutralFocusOutline', + borderWidth: 1, + borderRadius: 'lg', + + px: '$4', + py: '$2', + }, +} satisfies Record; diff --git a/react-native-expo/components/TransactionList/Transaction/TransactionAvatar/index.stories.ts b/react-native-expo/components/TransactionList/Transaction/TransactionAvatar/index.stories.ts new file mode 100644 index 0000000..1351c1b --- /dev/null +++ b/react-native-expo/components/TransactionList/Transaction/TransactionAvatar/index.stories.ts @@ -0,0 +1,17 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import TransactionAvatar from '.'; + +const meta: Meta = { + component: TransactionAvatar, + tags: ['autodocs'], +}; + +export default meta; + +export const Basic: StoryObj = { + args: { + direction: 'outgoing', + via: 'link', + }, +}; diff --git a/react-native-expo/components/TransactionList/Transaction/TransactionAvatar/index.tsx b/react-native-expo/components/TransactionList/Transaction/TransactionAvatar/index.tsx new file mode 100644 index 0000000..853c355 --- /dev/null +++ b/react-native-expo/components/TransactionList/Transaction/TransactionAvatar/index.tsx @@ -0,0 +1,61 @@ +import Icon from '@/components/Icon'; +import { Sx, View } from 'dripsy'; +import { ArrowDown, ArrowUp, Link } from 'lucide-react-native'; + +export interface TransactionAvatarProps { + direction: 'incoming' | 'outgoing'; + via?: 'link'; +} + +/** + * Renders an avatar representing a transaction. + * + * For now, just shows a link icon if the transaction was sent via link, as well + * as an up or down arrow representing a send or receive transaction, + * respectively. + * + * In the future, may be extended to show sender/recipient avatars and more. + */ +export default function TransactionAvatar({ direction, via }: TransactionAvatarProps) { + return ( + + {via === 'link' && } + + + + + + ); +} + +const sx = { + directionIndicator: { + backgroundColor: 'neutralLight', + borderWidth: 1, + borderColor: 'baseWhite', + borderRadius: '50%', + + size: '$4', + justifyContent: 'center', + alignItems: 'center', + + position: 'absolute', + bottom: 0, + right: 0, + }, + + root: { + backgroundColor: 'neutralLight', + borderRadius: '50%', + + size: '$10', + justifyContent: 'center', + alignItems: 'center', + + position: 'relative', + }, +} satisfies Record; diff --git a/react-native-expo/components/TransactionList/Transaction/index.stories.ts b/react-native-expo/components/TransactionList/Transaction/index.stories.ts new file mode 100644 index 0000000..92e08f8 --- /dev/null +++ b/react-native-expo/components/TransactionList/Transaction/index.stories.ts @@ -0,0 +1,55 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import Transaction from '.'; + +const MOCK_PENUMBRA_ADDRESS = + 'penumbra147mfall0zr6am5r45qkwht7xqqrdsp50czde7empv7yq2nk3z8yyfh9k9520ddgswkmzar22vhz9dwtuem7uxw0qytfpv7lk3q9dp8ccaw2fn5c838rfackazmgf3ahh09cxmz'; + +const BASE_TRANSACTION = { + id: 'abc123', + memo: 'This is a memo', + via: 'link' as const, +}; + +const TRANSACTION_WITH_SENDER_USERNAME = { + name: 'Receive transaction with sender username', + txn: { + ...BASE_TRANSACTION, + + type: 'receive' as const, + senderAddress: MOCK_PENUMBRA_ADDRESS, + senderUsername: 'henry', + }, +}; + +const TRANSACTION_WITHOUT_SENDER_USERNAME = { + name: 'Receive transaction without sender username', + txn: { + ...BASE_TRANSACTION, + + type: 'receive' as const, + senderAddress: MOCK_PENUMBRA_ADDRESS, + }, +}; + +const meta: Meta = { + component: Transaction, + tags: ['autodocs'], + argTypes: { + transaction: { + options: [TRANSACTION_WITH_SENDER_USERNAME.name, TRANSACTION_WITHOUT_SENDER_USERNAME.name], + mapping: { + [TRANSACTION_WITH_SENDER_USERNAME.name]: TRANSACTION_WITH_SENDER_USERNAME.txn, + [TRANSACTION_WITHOUT_SENDER_USERNAME.name]: TRANSACTION_WITHOUT_SENDER_USERNAME.txn, + }, + }, + }, +}; + +export default meta; + +export const Basic: StoryObj = { + args: { + transaction: TRANSACTION_WITH_SENDER_USERNAME.txn, + }, +}; diff --git a/react-native-expo/components/TransactionList/Transaction/index.test.tsx b/react-native-expo/components/TransactionList/Transaction/index.test.tsx new file mode 100644 index 0000000..62aca6b --- /dev/null +++ b/react-native-expo/components/TransactionList/Transaction/index.test.tsx @@ -0,0 +1,92 @@ +import { render } from '@testing-library/react-native'; +import Transaction from '.'; +import PraxDripsyProvider from '@/components/PraxDripsyProvider'; + +const MOCK_PENUMBRA_ADDRESS = + 'penumbra147mfall0zr6am5r45qkwht7xqqrdsp50czde7empv7yq2nk3z8yyfh9k9520ddgswkmzar22vhz9dwtuem7uxw0qytfpv7lk3q9dp8ccaw2fn5c838rfackazmgf3ahh09cxmz'; + +describe('', () => { + describe('when there is a memo', () => { + it('gets rendered', () => { + const { queryByText } = render( + , + { wrapper: PraxDripsyProvider }, + ); + + expect(queryByText('This is the memo')).toBeOnTheScreen(); + }); + }); + + describe('`receive` transaction', () => { + describe('when the transaction has no `senderUsername`', () => { + it("renders the sender's address", () => { + const { queryByText } = render( + , + { wrapper: PraxDripsyProvider }, + ); + + expect(queryByText(MOCK_PENUMBRA_ADDRESS)).toBeOnTheScreen(); + }); + }); + + describe('when the transaction has a `senderUsername`', () => { + it("renders the sender's username prefixed with `@`", () => { + const { queryByText } = render( + , + { wrapper: PraxDripsyProvider }, + ); + + expect(queryByText('@henry')).toBeOnTheScreen(); + }); + }); + }); + + describe('`send` transaction', () => { + describe('when the transaction has no `recipientUsername`', () => { + it("renders the recipient's address", () => { + const { queryByText } = render( + , + { wrapper: PraxDripsyProvider }, + ); + + expect(queryByText(MOCK_PENUMBRA_ADDRESS)).toBeOnTheScreen(); + }); + }); + + describe('when the transaction has a `recipientUsername`', () => { + it("renders the recipient's username prefixed with `@`", () => { + const { queryByText } = render( + , + { wrapper: PraxDripsyProvider }, + ); + + expect(queryByText('@henry')).toBeOnTheScreen(); + }); + }); + }); +}); diff --git a/react-native-expo/components/TransactionList/Transaction/index.tsx b/react-native-expo/components/TransactionList/Transaction/index.tsx new file mode 100644 index 0000000..d6a25d6 --- /dev/null +++ b/react-native-expo/components/TransactionList/Transaction/index.tsx @@ -0,0 +1,39 @@ +import ITransaction from '@/types/Transaction'; +import ListItem from '../../ListItem'; +import TransactionAvatar from './TransactionAvatar'; + +export interface TransactionProps { + transaction: ITransaction; +} + +/** Represents a transaction as a ``. */ +export default function Transaction({ transaction }: TransactionProps) { + switch (transaction.type) { + case 'receive': + return ( + } + primaryText={ + transaction.senderUsername + ? `@${transaction.senderUsername}` + : transaction.senderAddress + } + secondaryText={transaction.memo} + /> + ); + case 'send': + return ( + } + primaryText={ + transaction.recipientUsername + ? `@${transaction.recipientUsername}` + : transaction.recipientAddress + } + secondaryText={transaction.memo} + /> + ); + default: + return null; + } +} diff --git a/react-native-expo/components/TransactionList/index.stories.ts b/react-native-expo/components/TransactionList/index.stories.ts new file mode 100644 index 0000000..61d2ca6 --- /dev/null +++ b/react-native-expo/components/TransactionList/index.stories.ts @@ -0,0 +1,38 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import TransactionList from '.'; +import transactionFactory from '@/factories/transaction'; +import penumbraAddressFactory from '@/factories/penumbraAddress'; + +const meta: Meta = { + component: TransactionList, + tags: ['autodocs'], + argTypes: { + transactions: { control: false }, + primaryAction: { control: false }, + }, +}; + +export default meta; + +export const Basic: StoryObj = { + args: { + showTitle: true, + transactions: [ + transactionFactory.build({ + senderAddress: penumbraAddressFactory.build().value, + senderUsername: 'henry', + }), + transactionFactory.build({ + type: 'send', + recipientAddress: penumbraAddressFactory.build().value, + recipientUsername: 'cate', + via: undefined, + }), + transactionFactory.build({ + type: 'send', + recipientAddress: penumbraAddressFactory.build().value, + }), + ], + }, +}; diff --git a/react-native-expo/components/TransactionList/index.tsx b/react-native-expo/components/TransactionList/index.tsx new file mode 100644 index 0000000..861038d --- /dev/null +++ b/react-native-expo/components/TransactionList/index.tsx @@ -0,0 +1,28 @@ +import { useLingui } from '@lingui/react/macro'; +import List from '../List'; +import ITransaction from '@/types/Transaction'; +import Transaction from './Transaction'; +import { ReactNode } from 'react'; + +export interface TransactionListProps { + transactions: ITransaction[]; + /** Will be passed on as the ``'s `primaryAction` prop. */ + primaryAction?: ReactNode; + showTitle?: boolean; +} + +export default function TransactionList({ + transactions, + primaryAction, + showTitle, +}: TransactionListProps) { + const { t } = useLingui(); + + return ( + + {transactions.map(transaction => ( + + ))} + + ); +} diff --git a/react-native-expo/components/TransactionsScreen/index.tsx b/react-native-expo/components/TransactionsScreen/index.tsx new file mode 100644 index 0000000..439b0e4 --- /dev/null +++ b/react-native-expo/components/TransactionsScreen/index.tsx @@ -0,0 +1,30 @@ +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import TransactionList from '../TransactionList'; +import { ScrollView, Sx } from 'dripsy'; +import TextInput from '../TextInput'; +import { setSearchText } from '@/store/transactions'; +import useFilteredTransactions from './useFilteredTransactions'; + +export default function TransactionsScreen() { + const searchText = useAppSelector(state => state.transactions.searchText); + const dispatch = useAppDispatch(); + const filteredTransactions = useFilteredTransactions(); + + return ( + + dispatch(setSearchText(text))} /> + + + ); +} + +const sx = { + root: { + px: 'screenHorizontalMargin', + }, + + contentContainer: { + flexDirection: 'column', + gap: '$2', + }, +} satisfies Record; diff --git a/react-native-expo/components/TransactionsScreen/useFilteredTransactions/index.ts b/react-native-expo/components/TransactionsScreen/useFilteredTransactions/index.ts new file mode 100644 index 0000000..54e7187 --- /dev/null +++ b/react-native-expo/components/TransactionsScreen/useFilteredTransactions/index.ts @@ -0,0 +1,22 @@ +import { useAppSelector } from '@/store/hooks'; +import { useMemo } from 'react'; +import transactionSearchTextFilter from './transactionSearchTextFilter'; + +/** + * Returns the transactions from state, filtered by the search text from state. + * + * The filtered results are memoized for performance reasons. In the future, if + * typing search text becomes slow, consider adding throttling updates to + * `filteredTransactions`. + */ +export default function useFilteredTransactions() { + const transactions = useAppSelector(state => state.transactions.transactions); + const searchText = useAppSelector(state => state.transactions.searchText); + + const filteredTransactions = useMemo( + () => transactions.filter(transactionSearchTextFilter(searchText)), + [transactions, searchText], + ); + + return filteredTransactions; +} diff --git a/react-native-expo/components/TransactionsScreen/useFilteredTransactions/transactionSearchTextFilter.test.ts b/react-native-expo/components/TransactionsScreen/useFilteredTransactions/transactionSearchTextFilter.test.ts new file mode 100644 index 0000000..5cacf0e --- /dev/null +++ b/react-native-expo/components/TransactionsScreen/useFilteredTransactions/transactionSearchTextFilter.test.ts @@ -0,0 +1,54 @@ +import transactionFactory from '@/factories/transaction'; +import transactionSearchTextFilter from './transactionSearchTextFilter'; + +describe('transactionMatchesSearchText()', () => { + describe('`receive` transaction', () => { + const transaction = transactionFactory.build({ + type: 'receive', + senderUsername: 'henry', + senderAddress: 'abc123', + memo: 'This is the memo', + }); + + it('returns `true` when the sender username matches (case-insensitive)', () => { + expect(transactionSearchTextFilter('HeN')(transaction)).toBe(true); + }); + + it('returns `true` when the sender address matches (case-insensitive)', () => { + expect(transactionSearchTextFilter('ABC')(transaction)).toBe(true); + }); + + it('returns `true` when the memo matches (case-insensitive)', () => { + expect(transactionSearchTextFilter('tHe MeMo')(transaction)).toBe(true); + }); + + it('returns `false` otherwise', () => { + expect(transactionSearchTextFilter('invalid')(transaction)).toBe(false); + }); + }); + + describe('`send` transaction', () => { + const transaction = transactionFactory.build({ + type: 'send', + recipientUsername: 'henry', + recipientAddress: 'abc123', + memo: 'This is the memo', + }); + + it('returns `true` when the recipient username matches (case-insensitive)', () => { + expect(transactionSearchTextFilter('HeN')(transaction)).toBe(true); + }); + + it('returns `true` when the recipient address matches (case-insensitive)', () => { + expect(transactionSearchTextFilter('ABC')(transaction)).toBe(true); + }); + + it('returns `true` when the memo matches (case-insensitive)', () => { + expect(transactionSearchTextFilter('tHe MeMo')(transaction)).toBe(true); + }); + + it('returns `false` otherwise', () => { + expect(transactionSearchTextFilter('invalid')(transaction)).toBe(false); + }); + }); +}); diff --git a/react-native-expo/components/TransactionsScreen/useFilteredTransactions/transactionSearchTextFilter.ts b/react-native-expo/components/TransactionsScreen/useFilteredTransactions/transactionSearchTextFilter.ts new file mode 100644 index 0000000..996a435 --- /dev/null +++ b/react-native-expo/components/TransactionsScreen/useFilteredTransactions/transactionSearchTextFilter.ts @@ -0,0 +1,31 @@ +import Transaction from '@/types/Transaction'; + +/** + * Returns a function that can be passed to `transactions.filter()` to filter by + * address/username/memo (case-insensitive). + */ +export default function transactionSearchTextFilter(searchText: string) { + const searchTextLowerCase = searchText.toLocaleLowerCase(); + + return (transaction: Transaction): boolean => { + const memoLowerCase = transaction.memo?.toLocaleLowerCase(); + if (memoLowerCase?.includes(searchTextLowerCase)) return true; + + switch (transaction.type) { + case 'receive': + return !!( + transaction.senderAddress.toLocaleLowerCase().includes(searchTextLowerCase) || + transaction.senderUsername?.includes(searchTextLowerCase) + ); + + case 'send': + return !!( + transaction.recipientAddress.toLocaleLowerCase().includes(searchTextLowerCase) || + transaction.recipientUsername?.toLocaleLowerCase().includes(searchTextLowerCase) + ); + + default: + return false; + } + }; +} diff --git a/react-native-expo/factories/balance.ts b/react-native-expo/factories/balance.ts new file mode 100644 index 0000000..8f25b4d --- /dev/null +++ b/react-native-expo/factories/balance.ts @@ -0,0 +1,14 @@ +import Balance from '@/types/Balance'; +import * as Factory from 'factory.ts'; +import ASSETS from './mockData/assets'; + +const randomDecimalWith6DecimalPlaces = () => Math.round(Math.random() * 10 ** 9) / 10 ** 3; + +const balanceFactory = Factory.makeFactory({ + amount: Factory.each(randomDecimalWith6DecimalPlaces), + equivalentValue: Factory.each(randomDecimalWith6DecimalPlaces), + assetSymbol: Factory.each(i => ASSETS[i % ASSETS.length].symbol), + assetName: Factory.each(i => ASSETS[i % ASSETS.length].name), +}); + +export default balanceFactory; diff --git a/react-native-expo/factories/mockData/assets.ts b/react-native-expo/factories/mockData/assets.ts new file mode 100644 index 0000000..74f472e --- /dev/null +++ b/react-native-expo/factories/mockData/assets.ts @@ -0,0 +1,24 @@ +const ASSETS = [ + { + name: 'Ethereum', + symbol: 'ETH', + }, + { + name: 'Penumbra', + symbol: 'UM', + }, + { + name: 'Osmosis', + symbol: 'OSMO', + }, + { + name: 'Cosmos', + symbol: 'ATOM', + }, + { + name: 'USDC', + symbol: 'USD Coin', + }, +]; + +export default ASSETS; diff --git a/react-native-expo/factories/penumbraAddress.ts b/react-native-expo/factories/penumbraAddress.ts new file mode 100644 index 0000000..05cd79a --- /dev/null +++ b/react-native-expo/factories/penumbraAddress.ts @@ -0,0 +1,8 @@ +import { makeFactory } from 'factory.ts'; + +const penumbraAddressFactory = makeFactory<{ value: string }>({ + value: + 'penumbra147mfall0zr6am5r45qkwht7xqqrdsp50czde7empv7yq2nk3z8yyfh9k9520ddgswkmzar22vhz9dwtuem7uxw0qytfpv7lk3q9dp8ccaw2fn5c838rfackazmgf3ahh09cxmz', +}); + +export default penumbraAddressFactory; diff --git a/react-native-expo/factories/transaction.ts b/react-native-expo/factories/transaction.ts new file mode 100644 index 0000000..5305a09 --- /dev/null +++ b/react-native-expo/factories/transaction.ts @@ -0,0 +1,14 @@ +import Transaction from '@/types/Transaction'; +import * as Factory from 'factory.ts'; +import ASSETS from './mockData/assets'; + +const transactionFactory = Factory.makeFactory({ + id: Factory.each(i => `id-${i}`), + type: 'receive', + assetSymbol: Factory.each(i => ASSETS[i % ASSETS.length].symbol), + assetName: Factory.each(i => ASSETS[i % ASSETS.length].name), + memo: Factory.each(i => `Memo for transaction ${i}`), + via: 'link', +}); + +export default transactionFactory; diff --git a/react-native-expo/jest-setup.ts b/react-native-expo/jest-setup.ts new file mode 100644 index 0000000..1d3ff30 --- /dev/null +++ b/react-native-expo/jest-setup.ts @@ -0,0 +1 @@ +import '@testing-library/react-native/extend-expect'; diff --git a/react-native-expo/package.json b/react-native-expo/package.json index 407ea89..9437aa6 100644 --- a/react-native-expo/package.json +++ b/react-native-expo/package.json @@ -16,7 +16,13 @@ "compile": "lingui compile --typescript" }, "jest": { - "preset": "jest-expo" + "preset": "jest-expo", + "transformIgnorePatterns": [ + "/node_modules/(?!(dripsy|\\@?react-native|\\@?expo|expo-modules-core))" + ], + "setupFilesAfterEnv": [ + "/jest-setup.ts" + ] }, "dependencies": { "@expo/vector-icons": "^14.0.2", @@ -58,6 +64,7 @@ "@chromatic-com/storybook": "^3.2.2", "@lingui/babel-plugin-lingui-macro": "^5.1.2", "@lingui/cli": "^5.1.2", + "@lingui/vite-plugin": "^5.1.2", "@react-native/babel-preset": "^0.76.5", "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", @@ -66,6 +73,7 @@ "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", + "@testing-library/react-native": "^12.9.0", "@types/jest": "^29.5.12", "@types/react": "~18.3.12", "@types/react-test-renderer": "^18.3.0", @@ -76,6 +84,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-storybook": "^0.11.1", + "factory.ts": "^1.4.2", "jest": "^29.2.1", "jest-expo": "~52.0.2", "prettier": "^3.4.2", diff --git a/react-native-expo/store/balances.ts b/react-native-expo/store/balances.ts new file mode 100644 index 0000000..81e2358 --- /dev/null +++ b/react-native-expo/store/balances.ts @@ -0,0 +1,22 @@ +import balanceFactory from '@/factories/balance'; +import Balance from '@/types/Balance'; +import { createSlice } from '@reduxjs/toolkit'; + +export interface BalancesState { + balances: Balance[]; +} + +const initialState: BalancesState = { + /** @todo: Populate with real data */ + balances: balanceFactory.buildList(5), +}; + +export const balancesSlice = createSlice({ + name: 'balances', + initialState, + reducers: {}, +}); + +export const {} = balancesSlice.actions; + +export default balancesSlice.reducer; diff --git a/react-native-expo/store/index.ts b/react-native-expo/store/index.ts index fa04825..441387c 100644 --- a/react-native-expo/store/index.ts +++ b/react-native-expo/store/index.ts @@ -1,9 +1,16 @@ import { configureStore } from '@reduxjs/toolkit'; + +import balances from './balances'; import depositFlow from './depositFlow'; +import portfolioScreen from './portfolioScreen'; +import transactions from './transactions'; const store = configureStore({ reducer: { + balances, depositFlow, + portfolioScreen, + transactions, }, }); diff --git a/react-native-expo/store/portfolioScreen.ts b/react-native-expo/store/portfolioScreen.ts new file mode 100644 index 0000000..8bdcb21 --- /dev/null +++ b/react-native-expo/store/portfolioScreen.ts @@ -0,0 +1,26 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +/** State specific to `PortfolioScreen`. */ +export interface PortfolioScreenState { + /** + * The symbol of the currently selected asset (for viewing tarnsactions, + * sending, etc.). + */ + selectedAssetSymbol?: string; +} + +const initialState: PortfolioScreenState = {}; + +export const portfolioScreenSlice = createSlice({ + name: 'portfolioScreen', + initialState, + reducers: { + setSelectedAssetSymbol(state, action: PayloadAction) { + state.selectedAssetSymbol = action.payload; + }, + }, +}); + +export const { setSelectedAssetSymbol } = portfolioScreenSlice.actions; + +export default portfolioScreenSlice.reducer; diff --git a/react-native-expo/store/transactions.ts b/react-native-expo/store/transactions.ts new file mode 100644 index 0000000..20ec4af --- /dev/null +++ b/react-native-expo/store/transactions.ts @@ -0,0 +1,44 @@ +import penumbraAddressFactory from '@/factories/penumbraAddress'; +import transactionFactory from '@/factories/transaction'; +import Transaction from '@/types/Transaction'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface TransactionsState { + transactions: Transaction[]; + searchText: string; +} + +const initialState: TransactionsState = { + /** @todo: Populate with real data */ + transactions: [ + transactionFactory.build({ + senderAddress: penumbraAddressFactory.build().value, + senderUsername: 'henry', + }), + transactionFactory.build({ + type: 'send', + recipientAddress: penumbraAddressFactory.build().value, + recipientUsername: 'cate', + via: undefined, + }), + transactionFactory.build({ + type: 'send', + recipientAddress: penumbraAddressFactory.build().value, + }), + ], + searchText: '', +}; + +export const transactionsSlice = createSlice({ + name: 'transactions', + initialState, + reducers: { + setSearchText: (state, action: PayloadAction) => { + state.searchText = action.payload; + }, + }, +}); + +export const { setSearchText } = transactionsSlice.actions; + +export default transactionsSlice.reducer; diff --git a/react-native-expo/tsconfig.json b/react-native-expo/tsconfig.json index ac69e9c..8346ef8 100644 --- a/react-native-expo/tsconfig.json +++ b/react-native-expo/tsconfig.json @@ -10,6 +10,7 @@ } }, "include": [ + "./jest-setup.ts", "**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", diff --git a/react-native-expo/types/Balance.ts b/react-native-expo/types/Balance.ts new file mode 100644 index 0000000..46db6fc --- /dev/null +++ b/react-native-expo/types/Balance.ts @@ -0,0 +1,12 @@ +/** A user's holding of a given asset. */ +export default interface Balance { + assetSymbol: string; + assetName: string; + /** The amount represented as a decimal number of the display denom. */ + amount: number; + /** + * The equivalent value represented as a decimal number of the display denom + * of the asset selected by the user as their default asset. + */ + equivalentValue: number; +} diff --git a/react-native-expo/types/Transaction.ts b/react-native-expo/types/Transaction.ts new file mode 100644 index 0000000..109e8b2 --- /dev/null +++ b/react-native-expo/types/Transaction.ts @@ -0,0 +1,28 @@ +interface TransactionBase { + /** bech32m-encoded Transaction ID */ + id: string; + type: string; + memo?: string; + /** @todo: add more options as they become available */ + via?: 'link'; + /** The symbol of the asset in this transaction. */ + assetSymbol: string; + /** The name of the asset in this transaction. */ + assetName: string; +} + +interface ReceiveTransaction extends TransactionBase { + type: 'receive'; + senderAddress: string; + senderUsername?: string; +} + +interface SendTransaction extends TransactionBase { + type: 'send'; + recipientAddress: string; + recipientUsername?: string; +} + +type Transaction = ReceiveTransaction | SendTransaction; + +export default Transaction; diff --git a/react-native-expo/utils/dripsyTheme.ts b/react-native-expo/utils/dripsyTheme.ts index f81582d..ab311bd 100644 --- a/react-native-expo/utils/dripsyTheme.ts +++ b/react-native-expo/utils/dripsyTheme.ts @@ -156,6 +156,11 @@ const dripsyTheme = makeTheme({ $8: 32, $9: 36, $10: 40, + /** + * Use for views that wrap entire screens, to give them a consistent + * horizontal margin throughout the app. + */ + screenHorizontalMargin: 16, }, text: { diff --git a/react-native-expo/yarn.lock b/react-native-expo/yarn.lock index 314004c..2b3e666 100644 --- a/react-native-expo/yarn.lock +++ b/react-native-expo/yarn.lock @@ -1849,6 +1849,14 @@ "@babel/runtime" "^7.20.13" "@lingui/core" "^5.1.2" +"@lingui/vite-plugin@^5.1.2": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@lingui/vite-plugin/-/vite-plugin-5.1.2.tgz#2064aace693b6534e638052a0ef3c2b28d3ec202" + integrity sha512-1KDoj03+ie9Td/YYOTjncucwAMVU16RfFpnm1uHvrGGEoRgWfwox4pwTwUbIeksSuecGv757vLeyugThwdyfpA== + dependencies: + "@lingui/cli" "^5.1.2" + "@lingui/conf" "^5.1.2" + "@mdx-js/react@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-3.1.0.tgz#c4522e335b3897b9a845db1dbdd2f966ae8fb0ed" @@ -2773,6 +2781,15 @@ lodash "^4.17.21" redent "^3.0.0" +"@testing-library/react-native@^12.9.0": + version "12.9.0" + resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-12.9.0.tgz#9c727d9ffec91024be3288ed9376df3673154784" + integrity sha512-wIn/lB1FjV2N4Q7i9PWVRck3Ehwq5pkhAef5X5/bmQ78J/NoOsGbVY2/DG5Y9Lxw+RfE+GvSEh/fe5Tz6sKSvw== + dependencies: + jest-matcher-utils "^29.7.0" + pretty-format "^29.7.0" + redent "^3.0.0" + "@testing-library/user-event@14.5.2": version "14.5.2" resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" @@ -5690,6 +5707,14 @@ external-editor@^3.0.3: iconv-lite "^0.4.24" tmp "^0.0.33" +factory.ts@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/factory.ts/-/factory.ts-1.4.2.tgz#b8836b40034a2e072d5964bf17a0c2e615dbbd0d" + integrity sha512-8x2hqK1+EGkja4Ah8H3nkP7rDUJsBK1N3iFDqzqsaOV114o2IphSdVkFIw9nDHHr37gFFy2NXeN6n10ieqHzZg== + dependencies: + clone-deep "^4.0.1" + source-map-support "^0.5.21" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"