From 39a79161f91d1958896a08899dd7ffd3b8c4c881 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 21 Jan 2025 16:43:23 +0100 Subject: [PATCH] feat(widgets): add weather widget --- biome.json | 3 + src/assets/icons/widgets.ts | 8 + src/components/Widgets.tsx | 74 +++-- src/components/widgets/BaseWidget.tsx | 12 + src/components/widgets/WeatherWidget.tsx | 156 +++++++++ src/constants/widgets.ts | 3 +- src/hooks/weatherWidget.ts | 150 +++++++++ src/navigation/root/RootNavigator.tsx | 4 +- src/navigation/types/index.ts | 10 +- src/screens/Widgets/FeedWidgetEdit.tsx | 285 +++++++++++++++++ src/screens/Widgets/SlashfeedWidget.tsx | 4 +- src/screens/Widgets/Widget.tsx | 64 +++- src/screens/Widgets/WidgetEdit.tsx | 351 +++++++++------------ src/screens/Widgets/WidgetsSuggestions.tsx | 1 + src/store/types/widgets.ts | 13 +- src/utils/i18n/locales/en/common.json | 3 + src/utils/i18n/locales/en/widgets.json | 52 +++ 17 files changed, 946 insertions(+), 247 deletions(-) create mode 100644 src/components/widgets/WeatherWidget.tsx create mode 100644 src/hooks/weatherWidget.ts create mode 100644 src/screens/Widgets/FeedWidgetEdit.tsx diff --git a/biome.json b/biome.json index 977c5283c..8aa808265 100644 --- a/biome.json +++ b/biome.json @@ -8,6 +8,9 @@ "noBannedTypes": "off", "noForEach": "off" }, + "nursery": { + "useExplicitType": "off" + }, "performance": { "noAccumulatingSpread": "off" }, diff --git a/src/assets/icons/widgets.ts b/src/assets/icons/widgets.ts index b3c97d7fb..330b8f1d2 100644 --- a/src/assets/icons/widgets.ts +++ b/src/assets/icons/widgets.ts @@ -13,3 +13,11 @@ export const calculatorIcon = (): string => `; + +export const weatherIcon = (): string => + ` + + + + +`; diff --git a/src/components/Widgets.tsx b/src/components/Widgets.tsx index 76af6d8fe..20a7430b7 100644 --- a/src/components/Widgets.tsx +++ b/src/components/Widgets.tsx @@ -21,7 +21,7 @@ import { widgetsSelector, } from '../store/reselect/widgets'; import { setWidgetsSortOrder } from '../store/slices/widgets'; -import { TFeedWidget } from '../store/types/widgets'; +import { TFeedWidget, TWeatherWidgetOptions } from '../store/types/widgets'; import { TouchableOpacity, View } from '../styles/components'; import { Checkmark, PlusIcon, SortAscendingIcon } from '../styles/icons'; import { Caption13Up } from '../styles/text'; @@ -34,6 +34,7 @@ import LuganoFeedWidget from './LuganoFeedWidget'; import PriceWidget from './PriceWidget'; import Button from './buttons/Button'; import CalculatorWidget from './widgets/CalculatorWidget'; +import WeatherWidget from './widgets/WeatherWidget'; const Widgets = (): ReactElement => { const { t } = useTranslation('slashtags'); @@ -82,33 +83,40 @@ const Widgets = (): ReactElement => { } }; - let testID: string; - let Component: - | typeof PriceWidget - | typeof HeadlinesWidget - | typeof BlocksWidget - | typeof FactsWidget - | typeof FeedWidget - | typeof CalculatorWidget; - if (id === 'calculator') { - Component = CalculatorWidget; - testID = 'CalculatorWidget'; + return ( + + ); + } + if (id === 'weather') { + const options = widgets[id] as TWeatherWidgetOptions; return ( - - - + ); } const feedWidget = widgets[id] as TFeedWidget; + let testID: string; + let Component: + | typeof PriceWidget + | typeof HeadlinesWidget + | typeof BlocksWidget + | typeof FactsWidget + | typeof FeedWidget; switch (feedWidget.type) { case SUPPORTED_FEED_TYPES.PRICE_FEED: @@ -137,17 +145,15 @@ const Widgets = (): ReactElement => { } return ( - - - + ); }, [editing, widgets, sortedWidgets.length], @@ -174,7 +180,9 @@ const Widgets = (): ReactElement => { id} - renderItem={renderItem} + renderItem={(params): ReactElement => ( + {renderItem(params)} + )} scrollEnabled={false} activationDistance={editing ? 0 : 100} onDragEnd={onDragEnd} diff --git a/src/components/widgets/BaseWidget.tsx b/src/components/widgets/BaseWidget.tsx index 88ea0e630..b91813fec 100644 --- a/src/components/widgets/BaseWidget.tsx +++ b/src/components/widgets/BaseWidget.tsx @@ -13,11 +13,13 @@ import { ListIcon, SettingsIcon, TrashIcon } from '../../styles/icons'; import { BodyMSB } from '../../styles/text'; import { truncate } from '../../utils/helpers'; import Dialog from '../Dialog'; +import LoadingView from '../LoadingView'; import SvgImage from '../SvgImage'; const BaseWidget = ({ id, children, + isLoading, isEditing, style, testID, @@ -27,6 +29,7 @@ const BaseWidget = ({ }: { id: string; children: ReactElement; + isLoading?: boolean; isEditing?: boolean; style?: StyleProp; testID?: string; @@ -110,6 +113,15 @@ const BaseWidget = ({ {showTitle && !isEditing && } {!isEditing && children} + + {/* {!isEditing && ( + + {children} + + )} */} ; + testID?: string; + onPressIn?: () => void; + onLongPress?: () => void; +}): ReactElement => { + const { t } = useTranslation('widgets'); + const { data, status } = useWeatherWidget(); + const { condition, currentFee, nextBlockFee } = data; + const currentFeeFiat = useDisplayValues(currentFee); + + return ( + + + {options.showStatus && ( + + + {t(`weather.condition.${condition}.title`)} + + + + {condition === 'good' && '☀️'} + {condition === 'average' && '⛅'} + {condition === 'poor' && '⛈️'} + + + )} + + {options.showText && ( + {t(`weather.condition.${condition}.description`)} + )} + + {(options.showMedian || options.showNextBlockFee) && ( + + {options.showMedian && ( + + + + {t('weather.current_fee')} + + + + + {currentFeeFiat.fiatSymbol} {currentFeeFiat.fiatFormatted} + + + + )} + + {options.showNextBlockFee && ( + + + + {t('weather.next_block')} + + + + {nextBlockFee} bitcoin/vB + + + )} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + gap: 16, + }, + condition: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + conditionText: { + fontSize: 34, + fontWeight: 'bold', + lineHeight: 34, + letterSpacing: 0, + maxWidth: '70%', + }, + conditionIcon: { + height: 100, + marginTop: 0, + paddingTop: 16, + paddingBottom: -16, + ...Platform.select({ + ios: { + fontSize: 100, + lineHeight: 100, + }, + android: { + fontSize: 85, + lineHeight: 85, + }, + }), + }, + rows: { + gap: 8, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 20, + }, + columnLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + columnRight: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + }, +}); + +export default WeatherWidget; diff --git a/src/constants/widgets.ts b/src/constants/widgets.ts index 7cdd3c446..f096b816b 100644 --- a/src/constants/widgets.ts +++ b/src/constants/widgets.ts @@ -1,4 +1,4 @@ -import { calculatorIcon } from '../assets/icons/widgets'; +import { calculatorIcon, weatherIcon } from '../assets/icons/widgets'; export const priceFeedUrl = 'slashfeed:9ckhj7ea31ugskdewy9eiod5trhtbgcu9juza8aypjyugsp5f4oo/Bitcoin Price'; @@ -11,4 +11,5 @@ export const bitcoinFactsUrl = export const widgets = { calculator: { id: 'calculator', icon: calculatorIcon() }, + weather: { id: 'weather', icon: weatherIcon() }, }; diff --git a/src/hooks/weatherWidget.ts b/src/hooks/weatherWidget.ts new file mode 100644 index 000000000..6cfef55b1 --- /dev/null +++ b/src/hooks/weatherWidget.ts @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react'; +import { refreshOnchainFeeEstimates } from '../store/utils/fees'; + +enum EFeeCondition { + Good = 'good', + Average = 'average', + Poor = 'poor', +} + +type TWeatherWidgetData = { + condition: EFeeCondition; + currentFee: number; + nextBlockFee: number; +}; + +enum EWidgetStatus { + Loading = 'loading', + Error = 'error', + Ready = 'ready', +} + +type TWeatherWidgetResponse = { + data: TWeatherWidgetData; + status: EWidgetStatus; +}; + +type TBlock = { + extras: { + medianFee: number; + }; +}; + +type TBlockFeeRates = { + avgHeight: number; + timestamp: number; + avgFee_0: number; + avgFee_10: number; + avgFee_25: number; + avgFee_50: number; + avgFee_75: number; + avgFee_90: number; + avgFee_100: number; +}; + +const BASE_URL = 'https://mempool.space/api/v1'; +const REFRESH_INTERVAL = 1000 * 60 * 2; // 2 minutes +const VBYTES_SIZE = 140; // average native segwit transaction size + +const calculateCondition = ( + currentFee: number, + history: { avgFee_50: number }[], +) => { + // Calculate percentiles from historical data + const historicalFees = history.map((block) => block.avgFee_50); + // Sort fees in ascending order + const sortedFees = [...historicalFees].sort((a, b) => a - b); + + // Calculate 33rd and 66th percentiles + const lowThreshold = sortedFees[Math.floor(sortedFees.length * 0.33)]; + const highThreshold = sortedFees[Math.floor(sortedFees.length * 0.66)]; + + // Determine status based on current fee relative to percentiles + if (currentFee <= lowThreshold) { + return EFeeCondition.Good; + } + + if (currentFee >= highThreshold) { + return EFeeCondition.Poor; + } + + return EFeeCondition.Average; +}; + +const useWeatherWidget = (): TWeatherWidgetResponse => { + const [status, setStatus] = useState(EWidgetStatus.Loading); + const [data, setData] = useState({ + condition: EFeeCondition.Good, + currentFee: 342, + nextBlockFee: 8, + }); + + useEffect(() => { + const abortController = new AbortController(); + + const fetchMedianFee = async (): Promise => { + const response = await fetch(`${BASE_URL}/blocks`, { + signal: abortController.signal, + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const blocks = (await response.json()) as TBlock[]; + // return median fee based on average native segwit transaction of 140 vBytes + return blocks[0].extras.medianFee * VBYTES_SIZE; + }; + + const fetchHistory = async (): Promise => { + // Get historical fee data for the last 3 months + const response = await fetch(`${BASE_URL}/mining/blocks/fee-rates/3m`, { + signal: abortController.signal, + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }; + + const fetchData = async () => { + setStatus(EWidgetStatus.Loading); + try { + const [feesResult, medianFee, history] = await Promise.all([ + refreshOnchainFeeEstimates({ forceUpdate: true }), + fetchMedianFee(), + fetchHistory(), + ]); + + if (feesResult.isErr()) { + setStatus(EWidgetStatus.Error); + return; + } + + const fees = feesResult.value; + const condition = calculateCondition(fees.normal, history); + + setData({ + condition, + currentFee: medianFee, + nextBlockFee: fees.fast, + }); + setStatus(EWidgetStatus.Ready); + } catch (error) { + console.error('Failed to fetch fee data:', error); + setStatus(EWidgetStatus.Error); + } + }; + + fetchData(); + + const interval = setInterval(fetchData, REFRESH_INTERVAL); + + return () => { + clearInterval(interval); + abortController.abort(); + }; + }, []); + + return { data, status }; +}; + +export default useWeatherWidget; diff --git a/src/navigation/root/RootNavigator.tsx b/src/navigation/root/RootNavigator.tsx index 8226f847c..40c4d7844 100644 --- a/src/navigation/root/RootNavigator.tsx +++ b/src/navigation/root/RootNavigator.tsx @@ -33,6 +33,7 @@ import Profile from '../../screens/Profile/Profile'; import ProfileEdit from '../../screens/Profile/ProfileEdit'; import ScannerScreen from '../../screens/Scanner/MainScanner'; import ForgotPIN from '../../screens/Settings/PIN/ForgotPIN'; +import FeedWidgetEdit from '../../screens/Widgets/FeedWidgetEdit'; import FeedWidget from '../../screens/Widgets/SlashfeedWidget'; import Widget from '../../screens/Widgets/Widget'; import WidgetEdit from '../../screens/Widgets/WidgetEdit'; @@ -226,8 +227,9 @@ const RootNavigator = (): ReactElement => { component={WidgetsSuggestions} /> - + + diff --git a/src/navigation/types/index.ts b/src/navigation/types/index.ts index 7b49dc68d..e98392328 100644 --- a/src/navigation/types/index.ts +++ b/src/navigation/types/index.ts @@ -9,7 +9,10 @@ import { import type { RecoveryStackParamList } from '../../screens/Recovery/RecoveryNavigator'; import type { IActivityItem } from '../../store/types/activity'; -import type { TFeedWidgetOptions } from '../../store/types/widgets'; +import type { + TFeedWidgetOptions, + TWidgetOptions, +} from '../../store/types/widgets'; import type { BackupStackParamList } from '../bottom-sheet/BackupNavigation'; import type { LNURLWithdrawStackParamList } from '../bottom-sheet/LNURLWithdrawNavigation'; import type { OrangeTicketStackParamList } from '../bottom-sheet/OrangeTicketNavigation'; @@ -45,9 +48,10 @@ export type RootStackParamList = { BuyBitcoin: undefined; WidgetsOnboarding: undefined; WidgetsSuggestions: undefined; - Widget: { id: string }; + Widget: { id: string; preview?: TWidgetOptions }; + WidgetEdit: { id: string; initialFields: TWidgetOptions }; FeedWidget: { url: string; preview?: TFeedWidgetOptions }; - WidgetEdit: { url: string; initialFields: TFeedWidgetOptions }; + FeedWidgetEdit: { url: string; initialFields: TFeedWidgetOptions }; }; // Root Stack Navigator diff --git a/src/screens/Widgets/FeedWidgetEdit.tsx b/src/screens/Widgets/FeedWidgetEdit.tsx new file mode 100644 index 000000000..48840c874 --- /dev/null +++ b/src/screens/Widgets/FeedWidgetEdit.tsx @@ -0,0 +1,285 @@ +import isEqual from 'lodash/isEqual'; +import React, { useState, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import Divider from '../../components/Divider'; +import HourglassSpinner from '../../components/HourglassSpinner'; +import NavigationHeader from '../../components/NavigationHeader'; +import PriceChart from '../../components/PriceChart'; +import SafeAreaInset from '../../components/SafeAreaInset'; +import Button from '../../components/buttons/Button'; +import { useAppDispatch } from '../../hooks/redux'; +import { useSlashfeed } from '../../hooks/widgets'; +import type { RootStackScreenProps } from '../../navigation/types'; +import { deleteWidget } from '../../store/slices/widgets'; +import { + SlashFeedJSON, + TFeedWidgetOptions, + TGraphPeriod, +} from '../../store/types/widgets'; +import { ScrollView, View as ThemedView } from '../../styles/components'; +import { Checkmark } from '../../styles/icons'; +import { BodyM, BodySSB, CaptionB } from '../../styles/text'; +import { SUPPORTED_FEED_TYPES } from '../../utils/widgets'; + +export const getDefaultSettings = ( + config?: SlashFeedJSON, +): TFeedWidgetOptions => { + if (config) { + if (config.type === SUPPORTED_FEED_TYPES.PRICE_FEED) { + return { + fields: ['BTC/USD'], + extras: { period: '1D', showSource: false }, + }; + } + if (config.type === SUPPORTED_FEED_TYPES.BLOCKS_FEED) { + return { + fields: ['Block', 'Time', 'Date'], + extras: { showSource: false }, + }; + } + return { fields: [config.fields[0].name], extras: {} }; + } + return { fields: [], extras: {} }; +}; + +const FeedWidgetEdit = ({ + navigation, + route, +}: RootStackScreenProps<'FeedWidgetEdit'>): ReactElement => { + const { url, initialFields } = route.params; + const { t } = useTranslation('slashtags'); + const dispatch = useAppDispatch(); + const { config, fields, loading } = useSlashfeed({ url }); + const [settings, setSettings] = useState(initialFields); + + const defaultSettings = getDefaultSettings(config); + const hasEdited = !isEqual(settings, defaultSettings); + + const onSave = (): void => { + navigation.navigate('FeedWidget', { url, preview: settings }); + }; + + const onReset = (): void => { + dispatch(deleteWidget(url)); + setSettings(defaultSettings); + }; + + return ( + + + + + {!config || loading ? ( + + ) : ( + + {config.name && ( + + {t('widget_edit_description', { name: config.name })} + + )} + + + {loading && ( + + {t('widget_loading_options')} + + )} + + {!loading && ( + + {fields.length > 0 && + fields.map((field) => { + const isSelected = settings.fields.includes(field.name); + return ( + { + if (isSelected) { + setSettings((prevState) => ({ + ...prevState, + fields: prevState.fields.filter( + (f) => f !== field.name, + ), + })); + } else { + setSettings((prevState) => ({ + ...prevState, + fields: [...prevState.fields, field.name], + })); + } + }}> + + + {field.name} + + + + {field.value} + + + + + + + ); + })} + + {config.type === SUPPORTED_FEED_TYPES.PRICE_FEED && + config.fields && + Object.keys(config.fields[0].files).map((period) => { + const allowedPeriods = ['1D', '1W', '1M']; + const isSelected = settings.extras?.period === period; + + if (!allowedPeriods.includes(period)) { + return; + } + + return ( + { + setSettings((prevState) => ({ + ...prevState, + extras: { + ...prevState.extras, + period: period as TGraphPeriod, + }, + })); + }}> + + + + + + + ); + })} + + {config.source && ( + { + setSettings((prevState) => ({ + ...prevState, + extras: { + ...prevState.extras, + showSource: !prevState.extras?.showSource, + }, + })); + }}> + + Source + + + {config.source.name} + + + + + + + )} + + )} + + + +