Skip to content

Commit

Permalink
feat: reactive price screen (GaloyMoney#3271)
Browse files Browse the repository at this point in the history
  • Loading branch information
UncleSamtoshi authored May 16, 2024
1 parent 650bceb commit 4021d45
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 92 deletions.
223 changes: 139 additions & 84 deletions app/components/price-history/price-history.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import { TextStyle, ViewStyle } from "node_modules/@types/react-native/index"
import * as React from "react"
import { ActivityIndicator, StyleProp, View } from "react-native"
import {
ActivityIndicator,
StyleProp,
View,
TextInput,
type TextInputProps,
} from "react-native"
import { CartesianChart, Line, useChartPressState } from "victory-native"
import type { SharedValue } from "react-native-reanimated"
import Reanimated, {
useAnimatedProps,
useDerivedValue,
type SharedValue,
} from "react-native-reanimated"

import { gql } from "@apollo/client"
import { PricePoint, useBtcPriceListQuery } from "@app/graphql/generated"
import { PricePoint, WalletCurrency, useBtcPriceListQuery } from "@app/graphql/generated"
import { useI18nContext } from "@app/i18n/i18n-react"
import { testProps } from "@app/utils/testProps"
import { Button } from "@rneui/base"
import { Text, makeStyles, useTheme } from "@rneui/themed"
import { Circle } from "@shopify/react-native-skia"

const multiple = (currentUnit: string) => {
switch (currentUnit) {
case "USDCENT":
return 10 ** -5
default:
return 1
}
}
import { GaloyErrorBox } from "../atomic/galoy-error-box"
import { useDisplayCurrency } from "@app/hooks/use-display-currency"

const GraphRange = {
ONE_DAY: "ONE_DAY",
Expand Down Expand Up @@ -49,7 +52,6 @@ export const PriceHistory = () => {
const {
theme: { colors },
} = useTheme()

const { LL } = useI18nContext()
const [graphRange, setGraphRange] = React.useState<GraphRangeType>(GraphRange.ONE_DAY)

Expand All @@ -61,55 +63,59 @@ export const PriceHistory = () => {

const { state, isActive } = useChartPressState({ x: 0, y: { y: 0 } })

function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return <Circle cx={x} cy={y} r={8} color={colors.secondary} />
}

if (error) {
return <Text>{`${error}`}</Text>
}
const { formatMoneyAmount } = useDisplayCurrency()

if (loading || data === null || data?.btcPriceList === null) {
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return (
<View style={styles.verticalAlignment}>
<ActivityIndicator animating size="large" color={colors.primary} />
</View>
<>
<Circle cx={x} cy={y} r={8} color={colors.primary} />
</>
)
}

const ranges = GraphRange[graphRange]
const rangeTimestamps = {
ONE_DAY: 300,
ONE_WEEK: 1800,
ONE_MONTH: 86400,
ONE_YEAR: 86400,
FIVE_YEARS: 86400,
}

const lastPrice = priceList && priceList[priceList.length - 1]
if (!loading && lastPrice) {
const timeDiff = Date.now() / 1000 - lastPrice.timestamp
if (timeDiff > rangeTimestamps[ranges]) {
setGraphRange(ranges)
}
}

const prices = priceList
.filter((price) => price !== null)
.map((price) => price as PricePoint)
// FIXME: backend should be updated so that PricePoint is non-nullable
.map((index) => {
const amount = Math.floor(index.price.base / 10 ** index.price.offset)

const currentPriceData = prices[prices.length - 1].price
const startPriceData = prices[0].price
const price =
(currentPriceData.base / 10 ** currentPriceData.offset) *
multiple(currentPriceData.currencyUnit)
const delta = currentPriceData.base / startPriceData.base - 1
return {
y: amount,
formattedAmount: formatMoneyAmount({
moneyAmount: {
amount,
currency: WalletCurrency.Usd,
currencyCode: "USDCENT",
},
}),
timestamp: index.timestamp,
currencyUnit: index.price.currencyUnit,
}
})

const currentPriceData = prices[prices.length - 1]?.y
const startPriceData = prices[0]?.y
const delta =
currentPriceData && startPriceData
? (currentPriceData - startPriceData) / startPriceData
: 0
const color = delta > 0 ? { color: colors._green } : { color: colors.red }

const prices2 = prices.map((index) => ({
y: (index.price.base / 10 ** index.price.offset) * multiple(index.price.currencyUnit),
}))
const activePrice = useDerivedValue(() => {
const price = isActive
? prices.find((price) => price.y === state.y.y.value.value)
: prices[prices.length - 1]

return price?.formattedAmount ?? ""
})

const activeTimestamp = useDerivedValue(() => {
const timestamp = isActive
? prices.find((price) => price.y === state.y.y.value.value)?.timestamp
: undefined

return `${timestamp ? new Date(timestamp * 1000).toLocaleString(undefined, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : ""}`
})

const label = () => {
switch (graphRange) {
Expand All @@ -136,36 +142,38 @@ export const PriceHistory = () => {
const titleStyleForRange = (titleGraphRange: GraphRangeType): StyleProp<TextStyle> => {
return graphRange === titleGraphRange ? null : styles.titleStyleTime
}

return (
<View style={styles.verticalAlignment}>
<View>
<View {...testProps(LL.PriceHistoryScreen.satPrice())} style={styles.textView}>
<Text type="p1">{LL.PriceHistoryScreen.satPrice()}</Text>
<Text type="p1" bold>
${price.toFixed(2)}
</Text>
</View>
<View style={styles.textView}>
<Text type="p1" style={[styles.delta, color]}>
{(delta * 100).toFixed(2)}%{" "}
</Text>
<Text type="p1" {...testProps("range")}>
{label()}
</Text>
<View style={styles.screen}>
<View {...testProps(LL.PriceHistoryScreen.satPrice())} style={styles.textView}>
<AnimText
// @ts-ignore-next-line
text={activePrice}
style={styles.priceText}
color={colors.black}
/>
<View style={styles.subtextContainer}>
{!isActive && !loading ? (
<Text type="p1" {...testProps("range")}>
<Text type="p1" style={[styles.delta, color]}>
{(delta * 100).toFixed(2)}%{" "}
</Text>
{label()}
</Text>
) : (
<AnimText
// @ts-ignore-next-line
text={activeTimestamp}
style={styles.subtext}
color={colors.black}
/>
)}
</View>
</View>
<View style={styles.chart}>
{
{!loading && data ? (
/* eslint @typescript-eslint/ban-ts-comment: "off" */
// @ts-ignore-next-line no-implicit-any error
<CartesianChart
data={prices2}
yKeys={["y"]}
chartPressState={state}
padding={{ left: 20, right: 20 }}
domainPadding={{ bottom: 10, top: 10 }}
>
<CartesianChart data={prices} yKeys={["y"]} chartPressState={state}>
{({ points }) => (
<>
<Line
Expand All @@ -174,11 +182,20 @@ export const PriceHistory = () => {
strokeWidth={2}
curveType="natural"
/>
{isActive && <ToolTip x={state.x.position} y={state.y.y.position} />}

{isActive && (
<>
<ToolTip x={state.x.position} y={state.y.y.position} />
</>
)}
</>
)}
</CartesianChart>
}
) : (
<View style={styles.verticalAlignment}>
<ActivityIndicator animating size="large" color={colors.primary} />
</View>
)}
</View>
<View style={styles.pricesContainer}>
<Button
Expand Down Expand Up @@ -228,18 +245,54 @@ export const PriceHistory = () => {
onPress={() => setGraphRange(GraphRange.FIVE_YEARS)}
/>
</View>
{error && <GaloyErrorBox errorMessage={error.message} />}
</View>
)
}

const AnimText = Reanimated.createAnimatedComponent(TextInput)
Reanimated.addWhitelistedNativeProps({ text: true })

type AnimatedTextProps = Omit<TextInputProps, "editable" | "value"> & {
text: SharedValue<string>
style?: React.ComponentProps<typeof AnimText>["style"]
}

export function AnimatedText({ text, ...rest }: AnimatedTextProps) {
const animProps = useAnimatedProps(() => {
return {
text: text.value,
}
})

return (
<AnimText
{...rest}
value={text.value}
// @ts-ignore
animatedProps={animProps}
editable={false}
/>
)
}

const useStyles = makeStyles(({ colors }) => ({
buttonStyleTime: {
backgroundColor: colors.transparent,
borderRadius: 40,
width: 48,
height: 48,
},

subtextContainer: {
height: 24,
},
priceText: {
fontSize: 32,
},
subtext: {
fontSize: 18,
lineHeight: 24,
},
buttonStyleTimeActive: {
backgroundColor: colors.primary,
borderRadius: 40,
Expand All @@ -248,7 +301,7 @@ const useStyles = makeStyles(({ colors }) => ({
},

chart: {
height: "70%",
height: "60%",
},

delta: {
Expand All @@ -262,16 +315,18 @@ const useStyles = makeStyles(({ colors }) => ({
},

textView: {
alignSelf: "center",
flexDirection: "row",
paddingHorizontal: 16,
marginBottom: 16,
},

titleStyleTime: {
color: colors.grey3,
},

screen: {
paddingVertical: 16,
},
verticalAlignment: {
flex: 1,
justifyContent: "space-between",
alignItems: "center",
justifyContent: "center",
},
}))
1 change: 1 addition & 0 deletions app/graphql/generated.gql
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ mutation userUpdateUsername($input: UserUpdateUsernameInput!) {

query Bulletins($first: Int!, $after: String) {
me {
id
unacknowledgedStatefulNotificationsWithBulletinEnabled(
first: $first
after: $after
Expand Down
3 changes: 2 additions & 1 deletion app/graphql/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2690,7 +2690,7 @@ export type BulletinsQueryVariables = Exact<{
}>;


export type BulletinsQuery = { readonly __typename: 'Query', readonly me?: { readonly __typename: 'User', readonly unacknowledgedStatefulNotificationsWithBulletinEnabled: { readonly __typename: 'StatefulNotificationConnection', readonly pageInfo: { readonly __typename: 'PageInfo', readonly endCursor?: string | null, readonly hasNextPage: boolean, readonly hasPreviousPage: boolean, readonly startCursor?: string | null }, readonly edges: ReadonlyArray<{ readonly __typename: 'StatefulNotificationEdge', readonly cursor: string, readonly node: { readonly __typename: 'StatefulNotification', readonly id: string, readonly title: string, readonly body: string, readonly createdAt: number, readonly acknowledgedAt?: number | null, readonly bulletinEnabled: boolean, readonly icon?: Icon | null, readonly action?: { readonly __typename: 'OpenDeepLinkAction', readonly deepLink: string } | { readonly __typename: 'OpenExternalLinkAction', readonly url: string } | null } }> } } | null };
export type BulletinsQuery = { readonly __typename: 'Query', readonly me?: { readonly __typename: 'User', readonly id: string, readonly unacknowledgedStatefulNotificationsWithBulletinEnabled: { readonly __typename: 'StatefulNotificationConnection', readonly pageInfo: { readonly __typename: 'PageInfo', readonly endCursor?: string | null, readonly hasNextPage: boolean, readonly hasPreviousPage: boolean, readonly startCursor?: string | null }, readonly edges: ReadonlyArray<{ readonly __typename: 'StatefulNotificationEdge', readonly cursor: string, readonly node: { readonly __typename: 'StatefulNotification', readonly id: string, readonly title: string, readonly body: string, readonly createdAt: number, readonly acknowledgedAt?: number | null, readonly bulletinEnabled: boolean, readonly icon?: Icon | null, readonly action?: { readonly __typename: 'OpenDeepLinkAction', readonly deepLink: string } | { readonly __typename: 'OpenExternalLinkAction', readonly url: string } | null } }> } } | null };

export type BusinessMapMarkersQueryVariables = Exact<{ [key: string]: never; }>;

Expand Down Expand Up @@ -4779,6 +4779,7 @@ export type HomeUnauthedQueryResult = Apollo.QueryResult<HomeUnauthedQuery, Home
export const BulletinsDocument = gql`
query Bulletins($first: Int!, $after: String) {
me {
id
unacknowledgedStatefulNotificationsWithBulletinEnabled(
first: $first
after: $after
Expand Down
1 change: 1 addition & 0 deletions app/screens/home-screen/home-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ gql`
query Bulletins($first: Int!, $after: String) {
me {
id
unacknowledgedStatefulNotificationsWithBulletinEnabled(
first: $first
after: $after
Expand Down
1 change: 1 addition & 0 deletions app/screens/price/price-history-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const PriceHistoryScreen: React.FC = () => {
}, [accountId])

const styles = useStyles()

return (
<Screen preset="scroll" style={styles.screen}>
<PriceHistory />
Expand Down
7 changes: 0 additions & 7 deletions e2e/06-other-tests.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,6 @@ describe("Price graph flow", () => {
await clickIcon("graph")
})

it("check if price graph header is shown", async () => {
const priceGraphHeader = await $(selector(LL.PriceHistoryScreen.satPrice(), "Other"))
const rangeText = await $(selector("range", "StaticText"))
await priceGraphHeader.waitForDisplayed({ timeout })
expect(rangeText).toBeDisplayed()
})

it("click on one week button", async () => {
const oneWeekButton = await $(selector(LL.PriceHistoryScreen.oneWeek(), "Button"))
const rangeText = await $(selector("range", "StaticText"))
Expand Down

0 comments on commit 4021d45

Please sign in to comment.