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"