sproutfolio source

App.tsx

import { useEffect, useState } from 'react';
import { StatusBar, useWindowDimensions } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { LineChart } from '@plocks/charts';
import { Input, ScrollArea, SafeArea, Block, Button, Dialog, Icon, IconButton, PlocksProvider, SegmentedControl, Tabs, Text } from '@plocks/ui';

const C = {
  ink: '#101616',
  muted: '#6F7C79',
  green: '#00B67A',
  darkGreen: '#007C56',
  line: '#E6ECE9',
  pale: '#E9F8F1',
  white: '#FFF',
  red: '#D45B5B',
};
const STOCKS = [
  { ticker: 'ASTR', name: 'Aster Devices', price: 192.45, change: 1.82, color: '#242424', initials: 'A' },
  { ticker: 'NIVA', name: 'Nivara Graphics', price: 128.74, change: 3.47, color: '#74B900', initials: 'N' },
  { ticker: 'MERA', name: 'Meridian Systems', price: 421.36, change: -0.64, color: '#2777BD', initials: 'M' },
  { ticker: 'VYLT', name: 'Vyltra Motors', price: 251.08, change: -1.25, color: '#DF3434', initials: 'V' },
  { ticker: 'NORT', name: 'Northway Commerce', price: 185.62, change: 0.93, color: '#F5A623', initials: 'N' },
  { ticker: 'BASK', name: 'Market Basket Fund', price: 548.21, change: 0.71, color: '#555C6C', initials: 'B' },
];
const SERIES: Record<string, number[]> = {
  '1D': [31, 32, 30, 34, 33, 36, 34, 39, 38, 42, 40, 41, 45, 44, 48, 47, 51, 50, 53, 55],
  '1W': [22, 25, 23, 29, 27, 33, 32, 30, 37, 42, 40, 39, 45, 48, 46, 51, 53, 50, 54, 56],
  '1M': [18, 22, 25, 21, 28, 32, 30, 36, 33, 38, 42, 40, 45, 43, 48, 52, 49, 54, 52, 57],
  '3M': [15, 18, 23, 20, 25, 27, 32, 30, 35, 39, 36, 41, 44, 47, 45, 49, 51, 50, 54, 57],
  '1Y': [10, 14, 18, 21, 19, 24, 27, 31, 29, 33, 36, 40, 38, 43, 45, 46, 49, 51, 53, 57],
  ALL: [7, 10, 13, 17, 15, 21, 24, 23, 30, 28, 34, 37, 35, 41, 44, 42, 48, 50, 53, 57],
};
type Tab = 'home' | 'search' | 'watchlist' | 'account';
type Holding = Record<string, number>;
type Activity = { id: string; side: 'Buy' | 'Sell'; ticker: string; shares: number; total: number };
const STORAGE = 'plocks-sproutfolio-example:v1';
const money = (value: number) =>
  '$' + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });

function TrendChart({ values, height = 180 }: { values: number[]; height?: number }) {
  const [width, setWidth] = useState(0);
  return (
    <Block gap={0} w="100%" h={height} onLayout={(event) => setWidth(event.nativeEvent.layout.width)}>
      {width > 0 && (
        <LineChart
          data={values.map((value, index) => ({ x: index, y: value }))}
          w={width}
          h={height}
          lineColor={C.green}
          lineThickness={3}
          fill
          fillColor={C.green}
          fillOpacity={0.22}
          showPoints={false}
          xAxis={{ show: false }}
          yAxis={{ show: false }}
          grid={{ show: false }}
          accessibilityLabel="Portfolio value chart"
        />
      )}
    </Block>
  );
}

function StockMark({ stock }: { stock: (typeof STOCKS)[number] }) {
  return (
    <Block gap={0} w={42} h={42} radius={21} bg={stock.color} align="center" justify="center">
      <Text c="#fff" fw="bold" size={17}>
        {stock.initials}
      </Text>
    </Block>
  );
}

function SproutfolioScreen() {
  const { width } = useWindowDimensions();
  const wide = width >= 860;
  const [tab, setTab] = useState<Tab>('home');
  const [range, setRange] = useState('1D');
  const [holdings, setHoldings] = useState<Holding>({ ASTR: 8, NIVA: 5, BASK: 3 });
  const [cash, setCash] = useState(2450);
  const [watchlist, setWatchlist] = useState<string[]>(['VYLT', 'NORT']);
  const [activity, setActivity] = useState<Activity[]>([]);
  const [selected, setSelected] = useState<string | null>(null);
  const [ticket, setTicket] = useState<'Buy' | 'Sell' | null>(null);
  const [quantity, setQuantity] = useState('1');
  const [query, setQuery] = useState('');
  const [ready, setReady] = useState(false);
  const stock = STOCKS.find((item) => item.ticker === selected);
  const shares = stock ? (holdings[stock.ticker] ?? 0) : 0;
  const count = Number(quantity);
  const total = stock ? count * stock.price : 0;
  const validOrder =
    !!stock && Number.isInteger(count) && count > 0 && (ticket === 'Buy' ? total <= cash : count <= shares);
  const equity = STOCKS.reduce((sum, item) => sum + (holdings[item.ticker] ?? 0) * item.price, 0);
  const portfolio = cash + equity;

  useEffect(() => {
    AsyncStorage.getItem(STORAGE)
      .then((raw) => {
        if (!raw) return;
        const data = JSON.parse(raw) as {
          holdings?: Holding;
          cash?: number;
          watchlist?: string[];
          activity?: Activity[];
        };
        if (data.holdings && typeof data.holdings === 'object') setHoldings(data.holdings);
        if (typeof data.cash === 'number') setCash(data.cash);
        if (Array.isArray(data.watchlist)) setWatchlist(data.watchlist);
        if (Array.isArray(data.activity)) setActivity(data.activity);
      })
      .catch(() => {})
      .finally(() => setReady(true));
  }, []);
  useEffect(() => {
    if (ready) AsyncStorage.setItem(STORAGE, JSON.stringify({ holdings, cash, watchlist, activity })).catch(() => {});
  }, [holdings, cash, watchlist, activity, ready]);

  function submitOrder() {
    if (!validOrder || !stock || !ticket) return;
    setHoldings((old) => ({ ...old, [stock.ticker]: (old[stock.ticker] ?? 0) + (ticket === 'Buy' ? count : -count) }));
    setCash((old) => old + (ticket === 'Buy' ? -total : total));
    setActivity((old) => [
      { id: `trade-${Date.now()}`, side: ticket, ticker: stock.ticker, shares: count, total },
      ...old,
    ]);
    setTicket(null);
    setQuantity('1');
  }
  const stockRow = (item: (typeof STOCKS)[number]) => (
    <Block
      key={item.ticker}
      onPress={() => setSelected(item.ticker)}
      accessibilityRole="button"
      accessibilityLabel={`View ${item.name}`}
      direction="row"
      align="center"
      gap={13}
      py={13}
      borderBottomWidth={1}
      borderBottomColor={C.line}
    >
      <StockMark stock={item} />
      <Block gap={0} flex={1}>
        <Text c={C.ink} fw="bold" size={15}>
          {item.ticker}
        </Text>
        <Text c={C.muted} size={12}>
          {item.name}
        </Text>
      </Block>
      <Block gap={0} align="flex-end">
        <Text c={C.ink} fw="semibold" size={14}>
          {money(item.price)}
        </Text>
        <Text c={item.change >= 0 ? C.darkGreen : C.red} size={12}>
          {item.change >= 0 ? '+' : ''}
          {item.change.toFixed(2)}%
        </Text>
      </Block>
    </Block>
  );
  const nav = (
    <Tabs
      navigationOnly
      orientation={wide ? 'vertical' : 'horizontal'}
      variant={wide ? 'chip' : 'line'}
      color={C.darkGreen}
      value={tab}
      onChange={(item) => {
        setTab(item as Tab);
        setSelected(null);
      }}
      items={(['home', 'search', 'watchlist', 'account'] as const).map((item) => ({
        key: item,
        label: item[0].toUpperCase() + item.slice(1),
        icon: (
          <Icon
            name={item === 'home' ? 'home' : item === 'search' ? 'search' : item === 'watchlist' ? 'bookmark' : 'avatar'}
            color={tab === item && wide ? C.white : tab === item ? C.darkGreen : C.muted}
            size={wide ? 21 : 20}
          />
        ),
        content: null,
      }))}
    />
  );

  return (
    <SafeArea gap={0} flex={1} bg={C.white}>
      <StatusBar barStyle="dark-content" />
      <Block gap={0} flex={1} direction={wide ? 'row' : 'column'} maw={1200} w="100%" alignSelf="center">
        {wide && (
          <Block w={218} p={18} borderRightWidth={1} borderRightColor={C.line} gap={26}>
            <Text c={C.green} fw="bold" size={28}>
              sprout ↗
            </Text>
            {nav}
            <Block gap={0} flex={1} />
            <Text c={C.muted} size={11}>
              Simulated prices and trades
            </Text>
          </Block>
        )}
        <Block gap={0} flex={1}>
          {!wide && (
            <Block
              gap={0}
              h={60}
              px={18}
              direction="row"
              justify="space-between"
              align="center"
              borderBottomWidth={1}
              borderBottomColor={C.line}
            >
              <Text c={C.green} fw="bold" size={25}>
                sprout ↗
              </Text>
              <Text c={C.muted} size={12}>
                Demo portfolio
              </Text>
            </Block>
          )}
          <ScrollArea flex={1} contentProps={{ align: 'center', px: 20, pb: 35 }}>
            <Block gap={0} w="100%" maw={820}>
              {tab === 'home' && (
                <>
                  <Block gap={0} pt={27}>
                    <Text c={C.muted} fw="semibold" size={13}>
                      Portfolio value
                    </Text>
                    <Text c={C.ink} fw="bold" size={38} mt={2}>
                      {money(portfolio)}
                    </Text>
                    <Text c={C.darkGreen} fw="semibold" size={13}>
                      ↗ $142.38 (1.42%) today
                    </Text>
                  </Block>
                  <Block gap={0} my={22}>
                    <TrendChart values={SERIES[range]} />
                  </Block>
                  <SegmentedControl
                    data={Object.keys(SERIES)}
                    value={range}
                    onChange={setRange}
                    color={C.darkGreen}
                    variant="ghost"
                    size="sm"
                    fullWidth
                    accessibilityLabel="Portfolio time range"
                  />
                  <Block gap={0} bg="#F7FAF8" radius={15} p={17} mt={20} direction="row" justify="space-between">
                    <Block gap={0}>
                      <Text c={C.muted} size={12}>
                        Buying power
                      </Text>
                      <Text c={C.ink} fw="bold" size={20}>
                        {money(cash)}
                      </Text>
                    </Block>
                    <Block gap={0} alignSelf="center">
                      <Icon name="chevronRight" color={C.muted} size={20} />
                    </Block>
                  </Block>
                  <Text c={C.ink} fw="bold" size={21} mt={30} mb={6}>
                    Your stocks
                  </Text>
                  {STOCKS.filter((item) => (holdings[item.ticker] ?? 0) > 0).map(stockRow)}
                  <Text c={C.ink} fw="bold" size={21} mt={30} mb={6}>
                    Watchlist
                  </Text>
                  {STOCKS.filter((item) => watchlist.includes(item.ticker)).map(stockRow)}
                </>
              )}
              {tab === 'search' && (
                <Block gap={0} pt={24}>
                  <Text c={C.ink} fw="bold" size={26}>
                    Search
                  </Text>
                  <Input
                    variant="filled"
                    value={query}
                    onChangeText={setQuery}
                    placeholder="Search stocks and ETFs"
                    mb={0}
                    radius={12}
                    my={18}
                    inputColor={C.ink}
                    inputFontSize={15}
                  />
                  <Text c={C.muted} fw="bold" size={12}>
                    {query ? 'RESULTS' : 'POPULAR TODAY'}
                  </Text>
                  {STOCKS.filter((item) =>
                    `${item.ticker} ${item.name}`.toLowerCase().includes(query.toLowerCase()),
                  ).map(stockRow)}
                </Block>
              )}
              {tab === 'watchlist' && (
                <Block gap={0} pt={24}>
                  <Text c={C.ink} fw="bold" size={26}>
                    Watchlist
                  </Text>
                  <Text c={C.muted} size={13} mt={5} mb={20}>
                    Keep an eye on what interests you.
                  </Text>
                  {watchlist.length ? (
                    STOCKS.filter((item) => watchlist.includes(item.ticker)).map(stockRow)
                  ) : (
                    <Text c={C.muted}>Open a stock and tap the bookmark to add it here.</Text>
                  )}
                </Block>
              )}
              {tab === 'account' && (
                <Block gap={0} pt={24}>
                  <Text c={C.ink} fw="bold" size={26}>
                    Account
                  </Text>
                  <Block bg="#F7FAF8" radius={16} p={18} my={20} gap={10}>
                    <Text c={C.muted} size={12}>
                      Demo account
                    </Text>
                    <Text c={C.ink} fw="bold" size={24}>
                      {money(portfolio)}
                    </Text>
                    <Text c={C.muted} size={13}>
                      Invested {money(equity)} · Cash {money(cash)}
                    </Text>
                  </Block>
                  <Text c={C.ink} fw="bold" size={19}>
                    Recent activity
                  </Text>
                  {activity.length ? (
                    activity.map((item) => (
                      <Block
                        gap={0}
                        key={item.id}
                        direction="row"
                        justify="space-between"
                        py={13}
                        borderBottomWidth={1}
                        borderBottomColor={C.line}
                      >
                        <Text c={C.ink} fw="semibold">
                          {item.side} {item.shares} {item.ticker}
                        </Text>
                        <Text c={C.muted}>{money(item.total)}</Text>
                      </Block>
                    ))
                  ) : (
                    <Text c={C.muted} mt={14}>
                      No trades yet. Explore a stock to try a simulated order.
                    </Text>
                  )}
                  <Text c={C.muted} size={12} mt={30}>
                    Educational UI example. Prices are fictional and no real orders are placed.
                  </Text>
                </Block>
              )}
            </Block>
          </ScrollArea>
          {!wide && (
            <Block gap={0} borderTopWidth={1} borderTopColor={C.line} bg={C.white}>
              {nav}
            </Block>
          )}
        </Block>
      </Block>
      {!!stock && (
        <Dialog opened accessibilityLabel={`${stock.name} details`} onClose={() => {
          setSelected(null);
          setTicket(null);
        }}>
          <Block gap={0}>
            <ScrollArea contentProps={{ p: 20 }}>
              <Block gap={0} direction="row" justify="space-between" align="center">
                <StockMark stock={stock} />
                <IconButton
                  icon="x"
                  variant="ghost"
                  iconColor={C.ink}
                  accessibilityLabel="Close stock details"
                  onPress={() => {
                    setSelected(null);
                    setTicket(null);
                  }}
                />
              </Block>
              <Text c={C.ink} fw="bold" size={27} mt={15}>
                {stock.name}
              </Text>
              <Text c={C.muted} size={13}>
                {stock.ticker}
              </Text>
              <Text c={C.ink} fw="bold" size={32} mt={17}>
                {money(stock.price)}
              </Text>
              <Text c={stock.change >= 0 ? C.darkGreen : C.red} size={13}>
                {stock.change >= 0 ? '+' : ''}
                {stock.change.toFixed(2)}% today
              </Text>
              <Block gap={0} my={15}>
                <TrendChart values={SERIES['1D']} height={140} />
              </Block>
              <Text c={C.muted} size={13}>
                Your position: {shares} {shares === 1 ? 'share' : 'shares'} · {money(shares * stock.price)}
              </Text>
              <Block
                onPress={() =>
                  setWatchlist((old) =>
                    old.includes(stock.ticker) ? old.filter((id) => id !== stock.ticker) : [...old, stock.ticker],
                  )
                }
                direction="row"
                align="center"
                gap={8}
                py={16}
              >
                <Icon
                  name="bookmark"
                  variant={watchlist.includes(stock.ticker) ? 'filled' : 'outlined'}
                  color={C.darkGreen}
                  size={20}
                />
                <Text c={C.darkGreen} fw="semibold">
                  {watchlist.includes(stock.ticker) ? 'Remove from watchlist' : 'Add to watchlist'}
                </Text>
              </Block>
              {ticket ? (
                <Block gap={11} pt={7} borderTopWidth={1} borderTopColor={C.line}>
                  <Text c={C.ink} fw="bold" size={18}>
                    {ticket} {stock.ticker}
                  </Text>
                  <Input
                    variant="outline"
                    value={quantity}
                    onChangeText={setQuantity}
                    keyboardType="number-pad"
                    placeholder="Number of shares"
                    mb={0}
                    radius={10}
                    inputColor={C.ink}
                  />
                  <Text c={C.muted} size={13}>
                    Estimated total: {Number.isFinite(total) ? money(total) : '—'}
                  </Text>
                  <Text c={C.muted} size={12}>
                    Available: {ticket === 'Buy' ? money(cash) : `${shares} shares`}
                  </Text>
                  <Button
                    title={`Confirm simulated ${ticket.toLowerCase()}`}
                    color={C.green}
                    disabled={!validOrder}
                    onPress={submitOrder}
                  />
                  <Button title="Cancel" variant="subtle" color={C.ink} onPress={() => setTicket(null)} />
                </Block>
              ) : (
                <Block direction="row" gap={10} mt={8}>
                  <Block gap={0} flex={1}>
                    <Button
                      title="Buy"
                      color={C.green}
                      fullWidth
                      onPress={() => {
                        setQuantity('1');
                        setTicket('Buy');
                      }}
                    />
                  </Block>
                  <Block gap={0} flex={1}>
                    <Button
                      title="Sell"
                      variant="outline"
                      color={C.darkGreen}
                      fullWidth
                      disabled={!shares}
                      onPress={() => {
                        setQuantity('1');
                        setTicket('Sell');
                      }}
                    />
                  </Block>
                </Block>
              )}
            </ScrollArea>
          </Block>
        </Dialog>
      )}
    </SafeArea>
  );
}
export default function App() {
  return (
    <SafeAreaProvider>
      <PlocksProvider theme={{ colorScheme: 'light' }}>
        <SproutfolioScreen />
      </PlocksProvider>
    </SafeAreaProvider>
  );
}

index.ts

import { registerRootComponent } from 'expo';
import App from './App';

registerRootComponent(App);