diff --git a/react-native-expo/.storybook/preview.tsx b/react-native-expo/.storybook/preview.tsx index 1c1c1a4..f935a8d 100644 --- a/react-native-expo/.storybook/preview.tsx +++ b/react-native-expo/.storybook/preview.tsx @@ -3,6 +3,7 @@ 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 @@ -13,11 +14,13 @@ import './fonts.css'; const preview: Preview = { decorators: [ Story => ( - - - - - + + + + + + + ), ], parameters: { 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/HomeScreen/index.tsx b/react-native-expo/components/HomeScreen/index.tsx index 29d1fb1..f041f4d 100644 --- a/react-native-expo/components/HomeScreen/index.tsx +++ b/react-native-expo/components/HomeScreen/index.tsx @@ -1,72 +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'; +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 ( - <> + {/** @todo: Make this a `ScrollView`. */} - - - - 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', px: 'screenHorizontalMargin', }, } satisfies Record; 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/HomeScreen/HomeHeader/index.tsx b/react-native-expo/components/TabScreenHeader/index.tsx similarity index 76% rename from react-native-expo/components/HomeScreen/HomeHeader/index.tsx rename to react-native-expo/components/TabScreenHeader/index.tsx index f45b659..9ea0e3d 100644 --- a/react-native-expo/components/HomeScreen/HomeHeader/index.tsx +++ b/react-native-expo/components/TabScreenHeader/index.tsx @@ -3,7 +3,8 @@ import Header from '@/components/Header'; import { Image, Sx } from 'dripsy'; import logo from './logo.png'; -export default function HomeHeader() { +/** The header used by the primary screens accessed via tabs. */ +export default function TabScreenHeader() { return
} center={} />; } diff --git a/react-native-expo/components/HomeScreen/HomeHeader/logo.png b/react-native-expo/components/TabScreenHeader/logo.png similarity index 100% rename from react-native-expo/components/HomeScreen/HomeHeader/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 4f38f14..fd776a9 100644 --- a/react-native-expo/components/TabsLayout/index.tsx +++ b/react-native-expo/components/TabsLayout/index.tsx @@ -6,7 +6,7 @@ import Icon from '../Icon'; import { ParamListBase, RouteProp } from '@react-navigation/native'; import { Text } from 'dripsy'; import { useLingui } from '@lingui/react/macro'; -import HomeHeader from '../HomeScreen/HomeHeader'; +import TabScreenHeader from '../TabScreenHeader'; const ICONS_BY_ROUTE: Record = { index: Home, @@ -57,11 +57,11 @@ export default function TabsLayout() { return ( // @ts-expect-error - Types are wrong for `title` - }} /> + }} /> - + }} /> ); } 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/transaction.ts b/react-native-expo/factories/transaction.ts index 7ce31df..5305a09 100644 --- a/react-native-expo/factories/transaction.ts +++ b/react-native-expo/factories/transaction.ts @@ -1,9 +1,12 @@ 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', }); 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 f576e0b..441387c 100644 --- a/react-native-expo/store/index.ts +++ b/react-native-expo/store/index.ts @@ -1,11 +1,15 @@ 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/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 index d8ba73e..109e8b2 100644 --- a/react-native-expo/types/Transaction.ts +++ b/react-native-expo/types/Transaction.ts @@ -5,6 +5,10 @@ interface TransactionBase { 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 {