Files
com.blitzwallet/App.tsx
T
2c4f41b556 Analytics (#777)
* docs: add portfolio chart design doc for analytics page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add portfolio chart implementation plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add buildDailyBalances helper for portfolio chart

Implements pure function to reconstruct daily closing balances for the
current calendar month from transaction data. Used by analytics dashboard
to display balance history per day.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: guard buildDailyBalances against cross-month transactions

* feat: add PortfolioChart SVG component

* fix: use unique gradient id in PortfolioChart to prevent collision

* feat: add portfolio value chart to analytics page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: budget card was showing spentTotal instead of leftToSpend

* docs: add balance pie chart design doc

* docs: add analytics charts redesign doc

* adding chart library

* making sure privacy sets for each wallet

* fixing pos server name bug

* switch toggle theme to analytics page

* fixing text align for close pool

* adding monthly buget to masterInfoObject

* creating AnalyticsProvider

* adding buget constants

* use new chart library

* building analytics homepage

* creating sent and received analytics pages

* creating budget pages

* adding analytics to navigation pages

* add budget helpers

* creating buget warning component

* adding budget warning to send pages

* fixed spelling bug

* fixing color inconsistancy

* fix dup key bug

* making sure to warn if current payment puts user over or near budget limit

* adding translations

* fix preset amount format

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 16:01:10 -04:00

761 lines
26 KiB
TypeScript

/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import { DefaultTheme, NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React, {
JSX,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { registerRootComponent } from 'expo';
import {
getLocalStorageItem,
retrieveData,
setLocalStorageItem,
removeLocalStorageItem,
} from './app/functions';
import {
AdminLogin,
ConnectingToNodeLoadingScreen,
} from './app/screens/inAccount';
import { GlobalContextProvider } from './context-store/context';
import { WebViewProvider } from './context-store/webViewContext';
import { Linking, Platform, NativeModules } from 'react-native';
import SplashScreen from './app/screens/splashScreen';
import { GlobalContactsList } from './context-store/globalContacts';
// import {GlobaleCashVariables} from './context-store/eCash';
import { CreateAccountHome } from './app/screens/createAccount';
import { getLocales } from 'react-native-localize';
import { supportedLanguagesList } from './locales/localeslist';
import { GlobalAppDataProvider } from './context-store/appData';
import { PushNotificationProvider } from './context-store/notificationManager';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import GetThemeColors from './app/hooks/themeColors';
import {
BLITZ_PAYMENT_DEEP_LINK_SCHEMES,
CONTACT_UNIVERSAL_LINK_REGEX,
GIFT_DEEPLINK_REGEX,
POOL_DEEPLINK_REGEX,
PAYLINK_DEEPLINK_REGEX,
LOGIN_SECUITY_MODE_KEY,
LOGIN_SECURITY_MODE_TYPE_KEY,
} from './app/constants';
import { LiquidEventProvider } from './context-store/liquidEventContext';
import {
LiquidNavigationListener,
RootstockNavigationListener,
// EcashNavigationListener,
// LightningNavigationListener,
// LiquidNavigationListener,
SparkNavigationListener,
} from './context-store/SDKNavigation';
// import {LightningEventProvider} from './context-store/lightningEventContext';
import {
GlobalThemeProvider,
useGlobalThemeContext,
} from './context-store/theme';
import { GLobalNodeContextProider } from './context-store/nodeContext';
import { AppStatusProvider, useAppStatus } from './context-store/appStatus';
import { KeysContextProvider, useKeysContext } from './context-store/keys';
import { POSTransactionsProvider } from './context-store/pos';
import {
FADE_SCREENS,
FADE_TRANSPARENT_MODAL_SCREENS,
SLIDE_FROM_BOTTOM_SCREENS,
SLIDE_FROM_RIGHT_SCREENS,
} from './navigation/screens';
import getDeepLinkUser from './app/components/admin/homeComponents/contacts/internalComponents/getDeepLinkUser';
import { navigationRef } from './navigation/navigationService';
// import {GlobalConbinedTxContextProvider} from './context-store/combinedTransactionsContext';
// import BreezTest from './app/screens/breezTest';
import { ImageCacheProvider } from './context-store/imageCache';
import {
runPinAndMnemoicMigration,
runSecureStoreMigrationV2,
} from './app/functions/secureStore';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import HandleLNURLPayments from './context-store/lnurl';
// import {SparkConnectionListener} from './context-store/connectToNode';
import { SparkWalletProvider } from './context-store/sparkContext';
import { DropdownProvider } from './context-store/dropdownContext';
// let setStatusBarBackgroundColor: ((color: string) => void) | undefined;
// let setStatusBarStyle: ((style: 'light' | 'dark') => void) | undefined;
// let SystemUI: any;
// let NavigationBar: any;
// if (Platform.OS === 'android') {
// const statusBar = require('expo-status-bar');
// setStatusBarBackgroundColor = statusBar.setStatusBarBackgroundColor;
// setStatusBarStyle = statusBar.setStatusBarStyle;
// SystemUI = require('expo-system-ui');
// NavigationBar = require('expo-navigation-bar');
// }
import * as ExpoSplashScreen from 'expo-splash-screen';
import * as NavigationBar from 'expo-navigation-bar';
import {
setStatusBarBackgroundColor,
setStatusBarStyle,
} from 'expo-status-bar';
import * as SystemUI from 'expo-system-ui';
import { InsetsProvider } from './context-store/insetsProvider';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ToastContainer, ToastProvider } from './context-store/toastManager';
import { RootstockSwapProvider } from './context-store/rootstockSwapContext';
import { SparkConnectionManager } from './context-store/sparkConnection';
import { GlobalNostrWalletConnectProvider } from './context-store/NWC';
import { GlobalServerTimeProvider } from './context-store/serverTime';
import { AuthStatusProvider } from './context-store/authContext';
import { ActiveCustodyAccountProvider } from './context-store/activeAccount';
import { GiftProvider } from './context-store/giftContext';
import { PoolProvider } from './context-store/poolContext';
import { UserBalanceProvider } from './context-store/userBalanceContext';
import { FlashnetProvider } from './context-store/flashnetContext';
import { SavingsProvider } from './context-store/savingsContext';
// import { LRC20EventProvider } from './context-store/lrc20Listener';
import { useTranslation } from 'react-i18next';
import { isMoreThan40MinOld } from './app/functions/rotateAddressDateChecker';
import { AnalyticsProvider } from './context-store/analyticsContext';
const DeepLinkIntentModule = NativeModules.DeepLinkIntentModule;
const Stack = createNativeStackNavigator();
// will unhide splashscreen when showing dynamic loading in splashscreen component
ExpoSplashScreen.preventAutoHideAsync()
.then(result =>
console.log(`SplashScreen.preventAutoHideAsync() succeeded: ${result}`),
)
.catch(console.warn);
function App(): JSX.Element {
return (
<GestureHandlerRootView>
<SafeAreaProvider>
<InsetsProvider>
<KeyboardProvider>
<ToastProvider>
<GlobalThemeProvider>
<DropdownProvider>
<AppStatusProvider>
<AuthStatusProvider>
<KeysContextProvider>
<GlobalContactsList>
<GlobalContextProvider>
<ActiveCustodyAccountProvider>
<WebViewProvider>
{/* <GlobaleCashVariables> */}
<SparkWalletProvider>
<GLobalNodeContextProider>
{/* <GlobalConbinedTxContextProvider> */}
<GlobalAppDataProvider>
<POSTransactionsProvider>
<PushNotificationProvider>
<LiquidEventProvider>
<RootstockSwapProvider>
{/* <LRC20EventProvider> */}
<GlobalNostrWalletConnectProvider>
{/* <LightningEventProvider> */}
<ImageCacheProvider>
<GlobalServerTimeProvider>
<GiftProvider>
<PoolProvider>
<FlashnetProvider>
<UserBalanceProvider>
<SavingsProvider>
<AnalyticsProvider>
{/* <Suspense
fallback={<FullLoadingScreen text={'Loading Page'} />}> */}
<ResetStack />
</AnalyticsProvider>
</SavingsProvider>
</UserBalanceProvider>
</FlashnetProvider>
</PoolProvider>
</GiftProvider>
{/* </Suspense> */}
</GlobalServerTimeProvider>
</ImageCacheProvider>
{/* </LightningEventProvider> */}
</GlobalNostrWalletConnectProvider>
{/* </LRC20EventProvider> */}
</RootstockSwapProvider>
</LiquidEventProvider>
</PushNotificationProvider>
</POSTransactionsProvider>
</GlobalAppDataProvider>
{/* <BreezTest /> */}
{/* </GlobalConbinedTxContextProvider> */}
</GLobalNodeContextProider>
</SparkWalletProvider>
{/* </GlobaleCashVariables> */}
</WebViewProvider>
</ActiveCustodyAccountProvider>
</GlobalContextProvider>
</GlobalContactsList>
</KeysContextProvider>
</AuthStatusProvider>
</AppStatusProvider>
</DropdownProvider>
</GlobalThemeProvider>
</ToastProvider>
</KeyboardProvider>
</InsetsProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
function ResetStack(): JSX.Element | null {
const [initSettings, setInitSettings] = useState<{
isLoggedIn: boolean | null;
hasSecurityEnabled: boolean | null;
isLoaded: boolean | null;
}>({
isLoggedIn: null,
hasSecurityEnabled: null,
isLoaded: null,
});
const [securitySettings, setSecuritySettings] = useState<any>(null);
const [pendingLinkData, setPendingLinkData] = useState<{
url: string;
timestamp: number | null;
}>({
url: '',
timestamp: null,
});
const { theme, darkModeType } = useGlobalThemeContext();
const { didGetToHomepage, appState } = useAppStatus();
const { publicKey, setAccountMnemonic } = useKeysContext();
const didInitializeSettings = useRef(false);
const { backgroundColor } = GetThemeColors();
const { i18n } = useTranslation();
const handleDeepLink = useCallback(
async (event: { url: string }, isInitialLoad = false) => {
console.log(event);
const { url } = event;
try {
if (isInitialLoad) {
const savedDeepLink = await getLocalStorageItem(
'lastHandledDeepLink',
);
const parsedSavedDeeplink = JSON.parse(savedDeepLink) || {
url: '',
dateAdded: null,
};
if (
Platform.OS === 'android' &&
DeepLinkIntentModule &&
DeepLinkIntentModule.clearIntent
) {
DeepLinkIntentModule.clearIntent();
}
if (parsedSavedDeeplink.url === url) {
console.log('Deep link already handled:', url);
return;
}
await setLocalStorageItem(
'lastHandledDeepLink',
JSON.stringify({ url: url, dateAdded: Date.now() }),
);
}
console.log('Deep link URL:', url);
const linkData = {
url: event.url,
timestamp: Date.now(),
};
setPendingLinkData(linkData);
await setLocalStorageItem(
'pendingDeepLinkData',
JSON.stringify(linkData),
);
console.log('Stored pending link in state AND AsyncStorage');
} catch (error) {
console.error('Error handling deep link:', error);
}
},
[setPendingLinkData],
);
const clearDeepLink = useCallback(async () => {
setPendingLinkData({
url: '',
timestamp: null,
});
await removeLocalStorageItem('pendingDeepLinkData');
}, []);
const getInitialURL = useCallback(async () => {
const url = await Linking.getInitialURL();
if (url) {
handleDeepLink({ url }, true);
console.log('Initial deep link stored:', url);
}
}, [handleDeepLink]);
const setNavigationBar = useCallback(async () => {
if (appState === 'active') {
try {
if (Platform.OS === 'android') {
await NavigationBar.setBackgroundColorAsync(backgroundColor);
await NavigationBar.setButtonStyleAsync(theme ? 'light' : 'dark');
setStatusBarBackgroundColor(backgroundColor, false);
setStatusBarStyle(theme ? 'light' : 'dark', false);
}
await SystemUI.setBackgroundColorAsync(backgroundColor);
} catch (error) {
console.warn('Failed to set navigation bar:', error);
}
}
}, [backgroundColor, theme, appState]);
useEffect(() => {
let debounceTimer: NodeJS.Timeout | null = null;
let cancelled = false;
debounceTimer = setTimeout(async () => {
if (cancelled) return;
const { url, timestamp } = pendingLinkData;
if (!url) return;
if (!navigationRef.current) return;
if (appState !== 'active') return;
if (!didGetToHomepage || !publicKey) return;
try {
// Convert URL to lowercase for case-insensitive checks
const lowerUrl = url.toLowerCase();
const contactSchemePrefix = 'blitz-wallet:';
console.log(
'Processing link:',
url,
'at timestamp:',
timestamp,
'conditions:',
{
didGetToHomepage,
hasNavigationRef: !!navigationRef.current,
hasPublicKey: !!publicKey,
},
);
const blockSoftReset =
(navigationRef.getRootState().routes[0]?.name === 'Home' &&
navigationRef.getRootState().routes.length === 1) ||
navigationRef.getRootState().routes[0]?.name === 'Splash';
if (!blockSoftReset) {
let isContactLink = false;
if (PAYLINK_DEEPLINK_REGEX.test(url)) {
const match = url.match(/paylink\/([A-Za-z0-9]{9})/i);
if (match) {
navigationRef.current.reset({
index: 0,
routes: [
{
name: 'HomeAdmin',
params: { screen: 'Home' },
},
{
name: 'ConfirmPaymentScreen',
params: {
btcAdress: `paylink://${match[1]}`,
fromPage: 'paylink',
},
},
],
});
}
} else if (POOL_DEEPLINK_REGEX.test(url)) {
const poolIdMatch = url.match(/pools\/([0-9a-f-]{36})/i);
if (poolIdMatch) {
navigationRef.current.navigate('PoolDetailScreen', {
poolId: poolIdMatch[1],
});
}
} else if (GIFT_DEEPLINK_REGEX.test(url)) {
navigationRef.current.navigate('CustomHalfModal', {
wantedContent: 'ClaimGiftScreen',
url,
sliderHight: 0.6,
claimType: 'claim',
});
} else {
if (CONTACT_UNIVERSAL_LINK_REGEX.test(url)) {
isContactLink = true;
}
if (lowerUrl.startsWith(contactSchemePrefix)) {
// If the URL starts with the contact scheme, check if it contains a wrapped payment scheme.
const contentAfterScheme = lowerUrl.substring(
contactSchemePrefix.length,
);
const isWrappedPaymentLink = BLITZ_PAYMENT_DEEP_LINK_SCHEMES.some(
scheme =>
// Check if the content starts with "scheme:" (e.g., "lightning:")
contentAfterScheme.startsWith(scheme + ':'),
);
isContactLink = !isWrappedPaymentLink;
}
if (isContactLink) {
// Logic for handling contact deep links
const deepLinkContact = await getDeepLinkUser({
deepLinkContent: url,
userProfile: { uuid: publicKey },
});
if (deepLinkContact.didWork) {
navigationRef.current.navigate('ExpandedAddContactsPage', {
newContact: deepLinkContact.data,
});
} else {
navigationRef.current.navigate('ErrorScreen', {
errorMessage: deepLinkContact.reason,
useTranslationString: true,
});
}
} else {
// Regex to strip 'blitz-wallet:' OR 'blitz:' prefix if it exists.
// This ensures only the core payment URI is passed to the ConfirmPaymentScreen.
const paymentUrl = url.replace(/^(blitz-wallet|blitz):/i, '');
navigationRef.current.navigate('ConfirmPaymentScreen', {
btcAdress: paymentUrl,
});
}
}
// Clear the pending link after successful processing
await clearDeepLink();
}
} catch (err) {
console.error('Error processing deep link:', err);
navigationRef.current.navigate('ErrorScreen', {
errorMessage: 'errormessages.processingDeepLinkError',
useTranslationString: true,
});
// Clear the pending link even if there was an error
await clearDeepLink();
}
}, 700);
return () => {
cancelled = true;
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [pendingLinkData, appState, didGetToHomepage, publicKey, clearDeepLink]);
useEffect(() => {
async function loadPendingDeepLink() {
try {
const stored = await getLocalStorageItem('pendingDeepLinkData');
if (stored) {
const parsed = JSON.parse(stored);
console.log('Loaded pending deep link from AsyncStorage:', parsed);
setPendingLinkData(parsed);
}
} catch (error) {
console.error('Error loading pending deep link:', error);
}
}
loadPendingDeepLink();
}, []);
useEffect(() => {
const subscription = Linking.addEventListener('url', handleDeepLink);
return () => {
subscription.remove();
};
}, [handleDeepLink]);
useEffect(() => {
async function initWallet() {
await runPinAndMnemoicMigration();
await runSecureStoreMigrationV2();
const [
initialURL,
loginModeType,
pin,
mnemonic,
securitySettings,
userSelectedLanguage,
] = await Promise.all([
getInitialURL(),
retrieveData(LOGIN_SECURITY_MODE_TYPE_KEY),
retrieveData('pinHash'),
retrieveData('encryptedMnemonic'),
getLocalStorageItem(LOGIN_SECUITY_MODE_KEY),
getLocalStorageItem('userSelectedLanguage').then(data =>
JSON.parse(data),
),
]);
const storedSettings = JSON.parse(securitySettings);
const isPinFromMode = loginModeType?.value === 'pin';
const isBiometricFromMode = loginModeType?.value === 'biometric';
const parsedSettings = storedSettings ?? {
isSecurityEnabled: true,
isPinEnabled: isPinFromMode || (!isPinFromMode && !isBiometricFromMode),
isBiometricEnabled: isBiometricFromMode,
};
if (!storedSettings)
setLocalStorageItem(
LOGIN_SECUITY_MODE_KEY,
JSON.stringify(parsedSettings),
);
if (mnemonic.value && !parsedSettings.isSecurityEnabled) {
setAccountMnemonic(mnemonic.value);
}
setSecuritySettings(parsedSettings);
setInitSettings(prev => {
return {
...prev,
isLoggedIn: !!pin.value && !!mnemonic.value,
hasSecurityEnabled: parsedSettings.isSecurityEnabled,
};
});
let resolvedLanguage = userSelectedLanguage;
if (!resolvedLanguage) {
const [{ languageTag = 'en' }] = getLocales();
const deviceShortId = languageTag.split('-')[0];
const matched = supportedLanguagesList.find(
l => l.shortId === deviceShortId,
);
resolvedLanguage = matched ? matched.id : 'en';
setLocalStorageItem(
'userSelectedLanguage',
JSON.stringify(resolvedLanguage),
);
}
i18n.changeLanguage(resolvedLanguage);
}
if (appState === 'background') return;
if (!didInitializeSettings.current) {
didInitializeSettings.current = true;
initWallet();
} else {
// Re-check login status when app becomes active again
async function recheckLoginStatus() {
const [pin, mnemonic, securitySettings] = await Promise.all([
retrieveData('pinHash'),
retrieveData('encryptedMnemonic'),
getLocalStorageItem(LOGIN_SECUITY_MODE_KEY),
]);
const storedSettings = JSON.parse(securitySettings);
if (mnemonic.value && !storedSettings.isSecurityEnabled) {
setAccountMnemonic(mnemonic.value);
}
setSecuritySettings(storedSettings);
setInitSettings(prev => {
return {
...prev,
isLoggedIn: !!pin.value && !!mnemonic.value,
hasSecurityEnabled: storedSettings?.isSecurityEnabled ?? true,
};
});
}
recheckLoginStatus();
}
}, [appState]);
const handleAnimationFinish = () => {
setInitSettings(prev => {
return { ...prev, isLoaded: true };
});
};
const navigationTheme = useMemo(
() => ({
...DefaultTheme,
dark: theme,
colors: {
...DefaultTheme.colors,
primary: '#1E1E1E',
background: backgroundColor,
card: '#1E1E1E',
text: '#1E1E1E',
border: '#1E1E1E',
notification: '#1E1E1E',
},
}),
[theme, backgroundColor],
);
useEffect(() => {
if (appState === 'background') return;
setNavigationBar();
}, [backgroundColor, theme, appState]);
const screenOptions = useMemo(() => {
return {
headerShown: false,
};
}, []);
const HomeComponent = useMemo(() => {
if (initSettings.isLoggedIn) {
return initSettings.hasSecurityEnabled
? AdminLogin
: ConnectingToNodeLoadingScreen;
}
return CreateAccountHome;
}, [initSettings.isLoggedIn, initSettings.hasSecurityEnabled]);
if (theme === null || darkModeType === null) {
return null;
}
if (appState === 'background' && !didInitializeSettings.current) return null;
return (
<NavigationContainer theme={navigationTheme} ref={navigationRef}>
{/* <StatusBar style={theme ? 'light' : 'dark'} translucent={true} /> */}
<HandleLNURLPayments />
<SparkNavigationListener />
<RootstockNavigationListener />
<LiquidNavigationListener />
<ToastContainer />
<SparkConnectionManager />
{/* <EcashNavigationListener /> */}
{/* <SparkConnectionListener /> */}
{/* <LiquidNavigationListener /> */}
{/* <LightningNavigationListener /> */}
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen
name="Splash"
component={SplashScreen}
options={{ animation: 'fade', gestureEnabled: false }}
// initialParams={{ onAnimationFinish: handleAnimationFinish }}
/>
<Stack.Screen
name="SplashReload"
component={SplashScreen}
options={{ animation: 'none', gestureEnabled: false }}
/>
<Stack.Screen
name="Home"
component={HomeComponent}
initialParams={securitySettings}
options={{
animation: 'fade',
gestureEnabled: false,
contentStyle: {
backgroundColor: backgroundColor,
backfaceVisibility: 'hidden',
},
}}
/>
<Stack.Screen
name="ConnectingToNodeLoadingScreen"
component={ConnectingToNodeLoadingScreen}
options={{
gestureEnabled: false,
animation: 'fade',
contentStyle: {
backgroundColor: backgroundColor,
backfaceVisibility: 'hidden',
},
}}
/>
<Stack.Group
screenOptions={{
presentation: 'containedTransparentModal',
animation: 'slide_from_bottom',
}}
>
{SLIDE_FROM_BOTTOM_SCREENS.map(({ name, component: Component }) => (
<Stack.Screen
key={name}
name={name}
component={Component as React.ComponentType<any>}
/>
))}
</Stack.Group>
<Stack.Group
screenOptions={{
animation: 'slide_from_right',
}}
>
{SLIDE_FROM_RIGHT_SCREENS.map(
({ name, component: Component, options = {} }) => (
<Stack.Screen
key={name}
name={name}
component={Component as React.ComponentType<any>}
options={{ ...options }}
/>
),
)}
</Stack.Group>
<Stack.Group
screenOptions={{
animation: 'fade',
presentation: 'containedTransparentModal',
}}
>
{FADE_SCREENS.map(({ name, component: Component, options = {} }) => (
<Stack.Screen
key={name}
name={name}
options={{ ...options }}
component={Component as React.ComponentType<any>}
/>
))}
</Stack.Group>
<Stack.Group
screenOptions={{
animation: 'fade',
presentation: 'transparentModal',
}}
>
{FADE_TRANSPARENT_MODAL_SCREENS.map(
({ name, component: Component }) => (
<Stack.Screen
key={name}
name={name}
component={Component as React.ComponentType<any>}
/>
),
)}
</Stack.Group>
</Stack.Navigator>
</NavigationContainer>
);
}
export default App;
registerRootComponent(App);