Compare commits
40 Commits
master
...
feat-ios26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f69b205c1 | ||
|
|
8c84f703df | ||
|
|
ae22fd4986 | ||
|
|
8bebe748e8 | ||
|
|
4dfbf9b672 | ||
|
|
3e5989f60d | ||
|
|
ac1f1b5969 | ||
|
|
be036805d1 | ||
|
|
f0d7e89534 | ||
|
|
8739bb3c8e | ||
|
|
9683bac8f0 | ||
|
|
61f6b1fcc5 | ||
|
|
206545b286 | ||
|
|
b5cf1c3438 | ||
|
|
d344c30806 | ||
|
|
d44382dc7c | ||
|
|
761cb9740c | ||
|
|
c8e96ef786 | ||
|
|
671a0b0ab2 | ||
|
|
adcc927f94 | ||
|
|
0c57b12832 | ||
|
|
23a9170fc5 | ||
|
|
9027641d3f | ||
|
|
1d424709be | ||
|
|
7dcb7c5773 | ||
|
|
590109453a | ||
|
|
3b1f59bab2 | ||
|
|
52a9687399 | ||
|
|
7b2e2ff0a4 | ||
|
|
02109024b4 | ||
|
|
5eefcea9fc | ||
|
|
fb0c50c3cb | ||
|
|
8c836a59b6 | ||
|
|
07596096ab | ||
|
|
d898b128b8 | ||
|
|
34dc0be2fc | ||
|
|
3db81d2d91 | ||
|
|
825ee9a419 | ||
|
|
31aa6a7212 | ||
|
|
1ce2dcc1cd |
@ -7,10 +7,18 @@ import { useTheme } from './themes';
|
||||
interface SafeAreaScrollViewProps extends ScrollViewProps {
|
||||
floatingButtonHeight?: number;
|
||||
headerHeight?: number; // Additional header height to account for (e.g., when headerTransparent is true)
|
||||
disableDefaultTopPadding?: boolean;
|
||||
}
|
||||
|
||||
const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((props, ref) => {
|
||||
const { style, contentContainerStyle, floatingButtonHeight = 0, headerHeight = 0, ...otherProps } = props;
|
||||
const {
|
||||
style,
|
||||
contentContainerStyle,
|
||||
floatingButtonHeight = 0,
|
||||
headerHeight = 0,
|
||||
disableDefaultTopPadding = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@ -32,7 +40,10 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
|
||||
if (headerHeight > 0) {
|
||||
return headerHeight;
|
||||
}
|
||||
// iOS safe area or no status bar
|
||||
if (disableDefaultTopPadding) {
|
||||
return 0;
|
||||
}
|
||||
// Preserve legacy behavior for existing screens
|
||||
return insets.top > 0 ? 5 : 0;
|
||||
})(),
|
||||
};
|
||||
@ -48,7 +59,7 @@ const SafeAreaScrollView = forwardRef<ScrollView, SafeAreaScrollViewProps>((prop
|
||||
|
||||
// Now compose with contentContainerStyle to ensure passed styles override defaults
|
||||
return StyleSheet.compose(basePadding, contentContainerStyle);
|
||||
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight]);
|
||||
}, [insets, contentContainerStyle, floatingButtonHeight, headerHeight, disableDefaultTopPadding]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
|
||||
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import LinearGradient from 'react-native-linear-gradient';
|
||||
import { useTheme } from './themes';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet';
|
||||
import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet';
|
||||
@ -14,35 +14,39 @@ import { FiatUnit } from '../models/fiatUnit';
|
||||
import { BlurredBalanceView } from './BlurredBalanceView';
|
||||
import { useSettings } from '../hooks/context/useSettings';
|
||||
import ToolTipMenu from './TooltipMenu';
|
||||
import useAnimateOnChange from '../hooks/useAnimateOnChange';
|
||||
import { useLocale } from '@react-navigation/native';
|
||||
import ActionSheet from '../screen/ActionSheet';
|
||||
|
||||
const HERO_BASE_BODY_MIN_HEIGHT = 120;
|
||||
const HERO_MIN_BODY_HEIGHT = Math.round(HERO_BASE_BODY_MIN_HEIGHT * 1.2);
|
||||
const HERO_BOTTOM_PADDING = 32;
|
||||
const WALLET_LABEL_TOP_GAP = 32;
|
||||
|
||||
interface TransactionsNavigationHeaderProps {
|
||||
wallet: TWallet;
|
||||
unit: BitcoinUnit;
|
||||
headerOverlayHeight: number;
|
||||
onWalletUnitChange: (unit: BitcoinUnit) => void;
|
||||
onManageFundsPressed?: (id?: string) => void;
|
||||
onWalletBalanceVisibilityChange?: (isShouldBeVisible: boolean) => void;
|
||||
onWalletBalanceVisibilityChange?: (shouldHideBalance: boolean) => void;
|
||||
unitSwitching?: boolean;
|
||||
}
|
||||
|
||||
const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps> = ({
|
||||
wallet,
|
||||
headerOverlayHeight,
|
||||
onWalletUnitChange,
|
||||
onManageFundsPressed,
|
||||
onWalletBalanceVisibilityChange,
|
||||
unit = BitcoinUnit.BTC,
|
||||
unitSwitching = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const { hideBalance } = wallet;
|
||||
const isLightningWallet = wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type;
|
||||
const [allowOnchainAddress, setAllowOnchainAddress] = useState(isLightningWallet);
|
||||
const { preferredFiatCurrency } = useSettings();
|
||||
const { direction } = useLocale();
|
||||
const balanceOpacity = useSharedValue(1);
|
||||
const balanceTranslateY = useSharedValue(0);
|
||||
const previousBalance = useRef<string | undefined>(undefined);
|
||||
|
||||
const verifyIfWalletAllowsOnchainAddress = useCallback(() => {
|
||||
if (isLightningWallet) {
|
||||
@ -73,13 +77,14 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
|
||||
const handleBalanceVisibility = useCallback(() => {
|
||||
onWalletBalanceVisibilityChange?.(!hideBalance);
|
||||
}, [onWalletBalanceVisibilityChange, hideBalance]);
|
||||
}, [hideBalance, onWalletBalanceVisibilityChange]);
|
||||
|
||||
const changeWalletBalanceUnit = () => {
|
||||
if (hideBalance) {
|
||||
return;
|
||||
}
|
||||
let newWalletPreferredUnit = wallet.getPreferredBalanceUnit();
|
||||
|
||||
console.debug('[UnitSwitch/UI] tap unit change', { walletID: wallet.getID?.(), current: newWalletPreferredUnit });
|
||||
|
||||
if (newWalletPreferredUnit === BitcoinUnit.BTC) {
|
||||
newWalletPreferredUnit = BitcoinUnit.SATS;
|
||||
} else if (newWalletPreferredUnit === BitcoinUnit.SATS) {
|
||||
@ -88,7 +93,6 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
newWalletPreferredUnit = BitcoinUnit.BTC;
|
||||
}
|
||||
|
||||
console.debug('[UnitSwitch/UI] next unit resolved', { walletID: wallet.getID?.(), next: newWalletPreferredUnit });
|
||||
onWalletUnitChange(newWalletPreferredUnit);
|
||||
};
|
||||
|
||||
@ -103,9 +107,9 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
|
||||
const onPressMenuItem = useCallback(
|
||||
(id: string) => {
|
||||
if (id === 'walletBalanceVisibility') {
|
||||
if (id === actionKeys.WalletBalanceVisibility) {
|
||||
handleBalanceVisibility();
|
||||
} else if (id === 'copyToClipboard') {
|
||||
} else if (id === actionKeys.CopyToClipboard) {
|
||||
handleCopyPress();
|
||||
}
|
||||
},
|
||||
@ -140,148 +144,159 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
}, [unit, currentBalance]);
|
||||
|
||||
const balance = !wallet.hideBalance && formattedBalance;
|
||||
const safeBalance = balance ? String(balance) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (hideBalance) {
|
||||
previousBalance.current = undefined;
|
||||
balanceOpacity.value = 1;
|
||||
balanceTranslateY.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) {
|
||||
balanceOpacity.value = 0;
|
||||
balanceTranslateY.value = 6;
|
||||
balanceOpacity.value = withTiming(1, { duration: 180 });
|
||||
balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 });
|
||||
}
|
||||
|
||||
previousBalance.current = safeBalance;
|
||||
}, [safeBalance, hideBalance, balanceOpacity, balanceTranslateY]);
|
||||
|
||||
const balanceAnimationKey = useMemo(
|
||||
() => `${wallet.getID?.() ?? ''}-${unit}-${hideBalance}-${safeBalance ?? ''}`,
|
||||
[safeBalance, hideBalance, unit, wallet],
|
||||
);
|
||||
const balanceAnimatedStyle = useAnimateOnChange(balanceAnimationKey);
|
||||
|
||||
const animatedBalanceTextStyle = useAnimatedStyle(() => ({
|
||||
opacity: balanceOpacity.value,
|
||||
transform: [{ translateY: balanceTranslateY.value }],
|
||||
}));
|
||||
|
||||
const toolTipWalletBalanceActions = useMemo(() => {
|
||||
return hideBalance
|
||||
? [
|
||||
{
|
||||
id: 'walletBalanceVisibility',
|
||||
id: actionKeys.WalletBalanceVisibility,
|
||||
text: loc.transactions.details_balance_show,
|
||||
icon: {
|
||||
iconValue: 'eye',
|
||||
},
|
||||
icon: actionIcons.Eye,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'walletBalanceVisibility',
|
||||
id: actionKeys.WalletBalanceVisibility,
|
||||
text: loc.transactions.details_balance_hide,
|
||||
icon: {
|
||||
iconValue: 'eye.slash',
|
||||
},
|
||||
icon: actionIcons.EyeSlash,
|
||||
},
|
||||
{
|
||||
id: 'copyToClipboard',
|
||||
id: actionKeys.CopyToClipboard,
|
||||
text: loc.transactions.details_copy,
|
||||
icon: {
|
||||
iconValue: 'doc.on.doc',
|
||||
},
|
||||
icon: actionIcons.Clipboard,
|
||||
},
|
||||
];
|
||||
}, [hideBalance]);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug('[UnitSwitch/UI] render state', {
|
||||
walletID: wallet.getID?.(),
|
||||
unit,
|
||||
hideBalance,
|
||||
preferredFiat: preferredFiatCurrency?.endPointKey,
|
||||
switching: unitSwitching,
|
||||
});
|
||||
}, [wallet, unit, hideBalance, preferredFiatCurrency, unitSwitching]);
|
||||
|
||||
return (
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={styles.lineaderGradient}>
|
||||
<View
|
||||
style={[
|
||||
styles.lineaderGradient,
|
||||
{
|
||||
paddingTop: headerOverlayHeight,
|
||||
minHeight: headerOverlayHeight + HERO_MIN_BODY_HEIGHT,
|
||||
backgroundColor: WalletGradient.headerColorFor(wallet.type),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LinearGradient colors={WalletGradient.gradientsFor(wallet.type)} style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.contentContainer}>
|
||||
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
|
||||
{wallet.getLabel()}
|
||||
</Text>
|
||||
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress
|
||||
isButton
|
||||
enableAndroidRipple={false}
|
||||
buttonStyle={styles.walletBalance}
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
actions={toolTipWalletBalanceActions}
|
||||
>
|
||||
<View style={styles.walletBalance}>
|
||||
{hideBalance ? (
|
||||
<BlurredBalanceView />
|
||||
) : (
|
||||
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
|
||||
<Animated.Text
|
||||
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
|
||||
<View style={styles.balanceSection}>
|
||||
<View style={styles.walletBalanceAndUnitContainer}>
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress
|
||||
isButton
|
||||
enableAndroidRipple={false}
|
||||
buttonStyle={styles.walletBalance}
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
actions={toolTipWalletBalanceActions}
|
||||
>
|
||||
<View style={styles.walletBalance}>
|
||||
{hideBalance ? (
|
||||
<BlurredBalanceView />
|
||||
) : (
|
||||
<Text
|
||||
testID="WalletBalance"
|
||||
numberOfLines={1}
|
||||
minimumFontScale={0.5}
|
||||
adjustsFontSizeToFit
|
||||
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
|
||||
style={styles.walletBalanceText}
|
||||
>
|
||||
{balance}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
|
||||
<Text style={styles.walletPreferredUnitText}>
|
||||
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
{!hideBalance && (
|
||||
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
|
||||
<Text style={styles.walletPreferredUnitText}>
|
||||
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{wallet.type === MultisigHDWallet.type && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
<View style={styles.bottomBarSpacer}>
|
||||
<View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
{
|
||||
backgroundColor: colors.background,
|
||||
...Platform.select({
|
||||
ios: { shadowColor: colors.shadowColor },
|
||||
android: {},
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
lineaderGradient: {
|
||||
minHeight: 140,
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 15,
|
||||
paddingTop: WALLET_LABEL_TOP_GAP,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: HERO_BOTTOM_PADDING,
|
||||
},
|
||||
bottomBarSpacer: {
|
||||
position: 'relative',
|
||||
height: 12,
|
||||
marginBottom: 0,
|
||||
},
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
height: 13,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
},
|
||||
android: {
|
||||
elevation: 0.5,
|
||||
},
|
||||
}),
|
||||
},
|
||||
walletLabel: {
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 19,
|
||||
color: '#fff',
|
||||
marginBottom: 10,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
marginBottom: 4,
|
||||
},
|
||||
walletBalance: {
|
||||
flexShrink: 1,
|
||||
marginRight: 6,
|
||||
minHeight: 39,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
balanceSection: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
manageFundsButton: {
|
||||
marginTop: 14,
|
||||
@ -302,13 +317,13 @@ const styles = StyleSheet.create({
|
||||
walletBalanceAndUnitContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingRight: 10, // Ensure there's some padding to the right
|
||||
paddingRight: 10,
|
||||
},
|
||||
walletBalanceText: {
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 36,
|
||||
flexShrink: 1, // Allow the text to shrink if there's not enough space
|
||||
flexShrink: 1,
|
||||
},
|
||||
walletPreferredUnitView: {
|
||||
justifyContent: 'center',
|
||||
|
||||
@ -31,6 +31,8 @@ export { platformColors } from '../themes';
|
||||
|
||||
export const isAndroid = Platform.OS === 'android';
|
||||
const isIOS = Platform.OS === 'ios';
|
||||
const iosMajorVersion = isIOS ? Number(String(Platform.Version).split('.')[0]) : 0;
|
||||
export const isIOS26OrHigher = isIOS && Number.isFinite(iosMajorVersion) && iosMajorVersion >= 26;
|
||||
|
||||
export const platformSizing = {
|
||||
horizontalPadding: isIOS ? 16 : 20,
|
||||
@ -107,6 +109,15 @@ export const getSettingsHeaderOptions = (
|
||||
const cardColor = colors.lightButton ?? colors.modal ?? colors.elevated ?? defaultBackgroundColor;
|
||||
const headerBackgroundColor = isIOS ? (dark ? defaultBackgroundColor : cardColor) : defaultBackgroundColor;
|
||||
|
||||
if (isIOS26OrHigher) {
|
||||
return {
|
||||
title,
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleShadowVisible: true,
|
||||
headerBackButtonDisplayMode: 'minimal' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
headerLargeTitle: isIOS,
|
||||
@ -192,6 +203,7 @@ export const SettingsScrollView = forwardRef<ScrollView, SettingsScrollViewProps
|
||||
ref={ref}
|
||||
style={[style, { backgroundColor: screenBackgroundColor }]}
|
||||
headerHeight={resolvedHeaderHeight}
|
||||
disableDefaultTopPadding={isIOS26OrHigher}
|
||||
floatingButtonHeight={floatingButtonHeight}
|
||||
contentContainerStyle={[staticStyles.contentContainer, contentContainerStyle]}
|
||||
{...rest}
|
||||
|
||||
@ -14,6 +14,8 @@ export const BlueDefaultTheme = {
|
||||
foregroundColor: '#0c2550',
|
||||
borderTopColor: 'rgba(0, 0, 0, 0.1)',
|
||||
buttonBackgroundColor: '#ccddf9',
|
||||
/** Softer fill for native iOS 26+ prominent header bar buttons (derived from `buttonBackgroundColor`). */
|
||||
headerProminentButtonBackgroundColor: 'rgba(204, 221, 249, 0.9)',
|
||||
buttonTextColor: '#0c2550',
|
||||
secondButtonTextColor: '#50555C',
|
||||
buttonAlternativeTextColor: '#2f5fb3',
|
||||
@ -101,6 +103,7 @@ export const BlueDarkTheme: Theme = {
|
||||
foregroundColor: '#ffffff',
|
||||
buttonDisabledBackgroundColor: '#3A3A3C',
|
||||
buttonBackgroundColor: '#3A3A3C',
|
||||
headerProminentButtonBackgroundColor: 'rgba(58, 58, 60, 0.6)',
|
||||
buttonTextColor: '#ffffff',
|
||||
lightButton: 'rgba(255,255,255,.1)',
|
||||
buttonAlternativeTextColor: '#ffffff',
|
||||
|
||||
@ -245,8 +245,6 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>UIDesignRequiresCompatibility</key>
|
||||
<true/>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, AppState, View, Platform, PlatformColor, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
import navigationStyle, { CloseButtonPosition } from '../components/navigationStyle';
|
||||
import { useTheme } from '../components/themes';
|
||||
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
|
||||
@ -57,6 +57,9 @@ import { ConnectionPollContext } from './ConnectionPollContext';
|
||||
import ManageWallets from '../screen/wallets/ManageWallets';
|
||||
import ReceiveDetails from '../screen/receive/ReceiveDetails';
|
||||
import ReceiveCustomAmountSheet from '../screen/receive/ReceiveCustomAmountSheet';
|
||||
import { isIOS26OrHigher } from '../components/platform';
|
||||
|
||||
type HeaderRightItem = ReturnType<NonNullable<NativeStackNavigationOptions['unstable_headerRightItems']>>[number];
|
||||
|
||||
const PaymentCodesList = lazy(() => import('../screen/wallets/PaymentCodesList'));
|
||||
const PaymentCodesListComponent = withLazySuspense(PaymentCodesList);
|
||||
@ -150,6 +153,15 @@ const DetailViewStackScreensStack = () => {
|
||||
navigation.navigate('AddWalletRoot');
|
||||
}, [navigation]);
|
||||
|
||||
const navigateToSettings = useCallback(() => {
|
||||
navigation.navigate('DrawerRoot', {
|
||||
screen: 'DetailViewStackScreensStack',
|
||||
params: {
|
||||
screen: 'Settings',
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const RightBarButtons = useMemo(
|
||||
() =>
|
||||
sizeClass === SizeClass.Large ? (
|
||||
@ -219,6 +231,53 @@ const DetailViewStackScreensStack = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
if (isIOS26OrHigher) {
|
||||
// Status pills: `unstable_headerLeftItems` + `hidesSharedBackground` avoids the
|
||||
// navigation bar's shared liquid-glass chrome on the pill (solid colors only).
|
||||
return {
|
||||
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
|
||||
headerLargeTitle: false,
|
||||
headerTransparent: true,
|
||||
unstable_headerLeftItems: (): NativeStackHeaderItem[] => {
|
||||
const element = renderHeaderLeft();
|
||||
if (element == null) {
|
||||
return [];
|
||||
}
|
||||
return [{ type: 'custom', element, hidesSharedBackground: true }];
|
||||
},
|
||||
unstable_headerRightItems: () => {
|
||||
if (isDesktop) {
|
||||
return [];
|
||||
}
|
||||
const items: HeaderRightItem[] = [
|
||||
{
|
||||
type: 'button',
|
||||
label: loc.wallets.add_title,
|
||||
icon: { type: 'sfSymbol', name: 'plus' },
|
||||
variant: 'prominent',
|
||||
tintColor: theme.colors.headerProminentButtonBackgroundColor,
|
||||
identifier: 'AddWalletButton',
|
||||
accessibilityLabel: 'AddWalletButton',
|
||||
sharesBackground: false,
|
||||
onPress: navigateToAddWallet,
|
||||
},
|
||||
];
|
||||
if (sizeClass !== SizeClass.Large) {
|
||||
items.push({
|
||||
type: 'button',
|
||||
label: loc.settings.default_title,
|
||||
icon: { type: 'sfSymbol', name: 'ellipsis' },
|
||||
identifier: 'SettingsButton',
|
||||
accessibilityLabel: 'SettingsButton',
|
||||
sharesBackground: false,
|
||||
onPress: navigateToSettings,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: sizeClass === SizeClass.Large ? loc.wallets.list_title : '',
|
||||
headerLargeTitle: false,
|
||||
@ -233,6 +292,7 @@ const DetailViewStackScreensStack = () => {
|
||||
RightBarButtons,
|
||||
sizeClass,
|
||||
theme.colors.customHeader,
|
||||
theme.colors.headerProminentButtonBackgroundColor,
|
||||
theme.colors.foregroundColor,
|
||||
theme.colors.lightButton,
|
||||
theme.colors.redBG,
|
||||
@ -242,6 +302,8 @@ const DetailViewStackScreensStack = () => {
|
||||
electrumConnected,
|
||||
isElectrumDisabled,
|
||||
navigateToElectrumSettings,
|
||||
navigateToAddWallet,
|
||||
navigateToSettings,
|
||||
walletTransactionUpdateStatus,
|
||||
]);
|
||||
|
||||
@ -251,6 +313,14 @@ const DetailViewStackScreensStack = () => {
|
||||
|
||||
// Consistent header configuration for all settings screens
|
||||
const getSettingsHeaderOptions = (title: string) => {
|
||||
if (isIOS26OrHigher) {
|
||||
return {
|
||||
title,
|
||||
headerLargeTitle: true,
|
||||
headerLargeTitleShadowVisible: true,
|
||||
headerBackButtonDisplayMode: 'minimal' as const,
|
||||
};
|
||||
}
|
||||
// Use PlatformColor for iOS to match the Settings component, fallback to theme color
|
||||
const titleColor = Platform.OS === 'ios' ? PlatformColor('label') : theme.colors.foregroundColor;
|
||||
// Convert PlatformColor to string for TypeScript compatibility
|
||||
@ -273,6 +343,9 @@ const DetailViewStackScreensStack = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const settingsScreenOptions = (title: string) =>
|
||||
isIOS26OrHigher ? getSettingsHeaderOptions(title) : navigationStyle(getSettingsHeaderOptions(title))(theme);
|
||||
|
||||
return (
|
||||
<ConnectionPollContext.Provider value={connectionPollContextValue}>
|
||||
<DetailViewStack.Navigator
|
||||
@ -339,22 +412,14 @@ const DetailViewStackScreensStack = () => {
|
||||
options={navigationStyle({ title: loc.lndViewInvoice.additional_info })(theme)}
|
||||
/>
|
||||
|
||||
<DetailViewStack.Screen
|
||||
name="Broadcast"
|
||||
component={Broadcast}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.send.create_broadcast))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="Broadcast" component={Broadcast} options={settingsScreenOptions(loc.send.create_broadcast)} />
|
||||
<DetailViewStack.Screen
|
||||
name="IsItMyAddress"
|
||||
component={IsItMyAddress}
|
||||
initialParams={{ address: undefined }}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.is_it_my_address.title))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="GenerateWord"
|
||||
component={GenerateWord}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.autofill_word.title))(theme)}
|
||||
options={settingsScreenOptions(loc.is_it_my_address.title)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="GenerateWord" component={GenerateWord} options={settingsScreenOptions(loc.autofill_word.title)} />
|
||||
<DetailViewStack.Screen
|
||||
name="LnurlPay"
|
||||
component={LnurlPay}
|
||||
@ -397,115 +462,90 @@ const DetailViewStackScreensStack = () => {
|
||||
<DetailViewStack.Screen
|
||||
name="Settings"
|
||||
component={Settings}
|
||||
options={navigationStyle({
|
||||
title: loc.settings.header,
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerShadowVisible: false,
|
||||
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
|
||||
headerLargeTitle: Platform.OS === 'ios',
|
||||
headerLargeTitleStyle:
|
||||
Platform.OS === 'ios'
|
||||
? {
|
||||
options={
|
||||
isIOS26OrHigher
|
||||
? getSettingsHeaderOptions(loc.settings.header)
|
||||
: navigationStyle({
|
||||
title: loc.settings.header,
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerBackTitle: '',
|
||||
headerShadowVisible: false,
|
||||
// headerLargeTitle is iOS-only, disable on Android for better compatibility with older versions
|
||||
headerLargeTitle: Platform.OS === 'ios',
|
||||
headerLargeTitleStyle:
|
||||
Platform.OS === 'ios'
|
||||
? {
|
||||
color:
|
||||
typeof theme.colors.foregroundColor === 'string'
|
||||
? theme.colors.foregroundColor
|
||||
: String(theme.colors.foregroundColor),
|
||||
}
|
||||
: undefined,
|
||||
headerTitleStyle: {
|
||||
color:
|
||||
typeof theme.colors.foregroundColor === 'string'
|
||||
? theme.colors.foregroundColor
|
||||
: String(theme.colors.foregroundColor),
|
||||
}
|
||||
: undefined,
|
||||
headerTitleStyle: {
|
||||
color: typeof theme.colors.foregroundColor === 'string' ? theme.colors.foregroundColor : String(theme.colors.foregroundColor),
|
||||
},
|
||||
headerTransparent: false,
|
||||
headerBlurEffect: undefined,
|
||||
headerStyle: {
|
||||
backgroundColor: settingsHeaderBackgroundColor,
|
||||
},
|
||||
animationTypeForReplace: 'push',
|
||||
})(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="Currency"
|
||||
component={Currency}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.currency))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="GeneralSettings"
|
||||
component={GeneralSettings}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.general))(theme)}
|
||||
},
|
||||
headerTransparent: false,
|
||||
headerBlurEffect: undefined,
|
||||
headerStyle: {
|
||||
backgroundColor: settingsHeaderBackgroundColor,
|
||||
},
|
||||
animationTypeForReplace: 'push',
|
||||
})(theme)
|
||||
}
|
||||
/>
|
||||
<DetailViewStack.Screen name="Currency" component={Currency} options={settingsScreenOptions(loc.settings.currency)} />
|
||||
<DetailViewStack.Screen name="GeneralSettings" component={GeneralSettings} options={settingsScreenOptions(loc.settings.general)} />
|
||||
<DetailViewStack.Screen
|
||||
name="PlausibleDeniability"
|
||||
component={PlausibleDeniability}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.plausibledeniability.title))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="Licensing"
|
||||
component={Licensing}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.license))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="NetworkSettings"
|
||||
component={NetworkSettings}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.network))(theme)}
|
||||
options={settingsScreenOptions(loc.plausibledeniability.title)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="Licensing" component={Licensing} options={settingsScreenOptions(loc.settings.license)} />
|
||||
<DetailViewStack.Screen name="NetworkSettings" component={NetworkSettings} options={settingsScreenOptions(loc.settings.network)} />
|
||||
<DetailViewStack.Screen
|
||||
name="SettingsBlockExplorer"
|
||||
component={SettingsBlockExplorer}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.block_explorer))(theme)}
|
||||
options={settingsScreenOptions(loc.settings.block_explorer)}
|
||||
/>
|
||||
|
||||
<DetailViewStack.Screen
|
||||
name="About"
|
||||
component={About}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="About" component={About} options={settingsScreenOptions(loc.settings.about)} />
|
||||
{/* <DetailViewStack.Screen
|
||||
name="DefaultView"
|
||||
component={DefaultView}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.default_title))(theme)}
|
||||
options={settingsScreenOptions(loc.settings.default_title)}
|
||||
/> */}
|
||||
<DetailViewStack.Screen
|
||||
name="ElectrumSettings"
|
||||
component={ElectrumSettings}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.electrum_settings_server))(theme)}
|
||||
options={settingsScreenOptions(loc.settings.electrum_settings_server)}
|
||||
initialParams={{ server: undefined }}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="EncryptStorage"
|
||||
component={EncryptStorage}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.encrypt_title))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="Language"
|
||||
component={Language}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.language))(theme)}
|
||||
options={settingsScreenOptions(loc.settings.encrypt_title)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="Language" component={Language} options={settingsScreenOptions(loc.settings.language)} />
|
||||
<DetailViewStack.Screen
|
||||
name="LightningSettings"
|
||||
component={LightningSettings}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.lightning_settings))(theme)}
|
||||
options={settingsScreenOptions(loc.settings.lightning_settings)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="NotificationSettings"
|
||||
component={NotificationSettings}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.notifications))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="SelfTest"
|
||||
component={SelfTest}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.selfTest))(theme)}
|
||||
options={settingsScreenOptions(loc.settings.notifications)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="SelfTest" component={SelfTest} options={settingsScreenOptions(loc.settings.selfTest)} />
|
||||
<DetailViewStack.Screen
|
||||
name="ReleaseNotes"
|
||||
component={ReleaseNotes}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.about_release_notes))(theme)}
|
||||
/>
|
||||
<DetailViewStack.Screen
|
||||
name="SettingsTools"
|
||||
component={SettingsTools}
|
||||
options={navigationStyle(getSettingsHeaderOptions(loc.settings.tools))(theme)}
|
||||
options={settingsScreenOptions(loc.settings.about_release_notes)}
|
||||
/>
|
||||
<DetailViewStack.Screen name="SettingsTools" component={SettingsTools} options={settingsScreenOptions(loc.settings.tools)} />
|
||||
<DetailViewStack.Screen
|
||||
name="PromptPasswordConfirmationSheet"
|
||||
component={PromptPasswordConfirmationSheet}
|
||||
|
||||
@ -1,44 +1,98 @@
|
||||
import React from 'react';
|
||||
import { TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Platform, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import type { NativeStackHeaderItem, NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
import Icon from '../../components/Icon';
|
||||
import WalletGradient from '../../class/wallet-gradient';
|
||||
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
import { DetailViewStackParamList } from '../DetailViewStackParamList';
|
||||
import { navigationRef } from '../../NavigationService';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
import { isDesktop } from '../../blue_modules/environment';
|
||||
import { isIOS26OrHigher } from '../../components/platform';
|
||||
import loc from '../../loc';
|
||||
|
||||
export type WalletTransactionsRouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
|
||||
|
||||
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
|
||||
const { isLoading = false, walletID, walletType } = route.params;
|
||||
const HERO_HEADER_ICON_COLOR = '#FFFFFF';
|
||||
|
||||
const onPress = () => {
|
||||
navigationRef.navigate('WalletDetails', {
|
||||
walletID,
|
||||
});
|
||||
};
|
||||
const navigateToWalletDetails = (walletID: string) => {
|
||||
navigationRef.navigate('WalletDetails', {
|
||||
walletID,
|
||||
});
|
||||
};
|
||||
|
||||
const RightButton = (
|
||||
<TouchableOpacity accessibilityRole="button" testID="WalletDetails" disabled={isLoading} style={styles.walletDetails} onPress={onPress}>
|
||||
<Icon name="more-horiz" type="material" size={22} color="#FFFFFF" />
|
||||
/** Material "more" button for WalletTransactions header (pre–iOS 26 and Android). */
|
||||
export const createWalletDetailsHeaderRight = ({
|
||||
walletID,
|
||||
isLoading = false,
|
||||
iconColor = HERO_HEADER_ICON_COLOR,
|
||||
}: {
|
||||
walletID: string;
|
||||
isLoading?: boolean;
|
||||
iconColor?: string;
|
||||
}): (() => React.ReactElement) => {
|
||||
return () => (
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
testID="WalletDetails"
|
||||
disabled={isLoading}
|
||||
style={styles.walletDetails}
|
||||
onPress={() => navigateToWalletDetails(walletID)}
|
||||
>
|
||||
<Icon name="more-horiz" type="material" size={22} color={iconColor} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const backgroundColor = WalletGradient.headerColorFor(walletType);
|
||||
/** Native toolbar ellipsis for WalletTransactions on iOS 26+. */
|
||||
export const createWalletDetailsHeaderRightItems = ({
|
||||
isLoading = false,
|
||||
walletID,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
walletID: string;
|
||||
}): (() => NativeStackHeaderItem[]) => {
|
||||
return () => [
|
||||
{
|
||||
type: 'button',
|
||||
label: loc.wallets.details_title,
|
||||
icon: { type: 'sfSymbol', name: 'ellipsis' },
|
||||
identifier: 'WalletDetails',
|
||||
accessibilityLabel: 'WalletDetails',
|
||||
sharesBackground: false,
|
||||
onPress: () => navigateToWalletDetails(walletID),
|
||||
disabled: isLoading,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return {
|
||||
const getWalletTransactionsOptions = ({ route }: { route: WalletTransactionsRouteProps }): NativeStackNavigationOptions => {
|
||||
const { isLoading = false, walletID } = route.params;
|
||||
|
||||
const base: NativeStackNavigationOptions = {
|
||||
title: '',
|
||||
headerBackTitleStyle: { fontSize: 0 },
|
||||
headerTransparent: true,
|
||||
headerStyle: {
|
||||
backgroundColor,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: '#FFFFFF',
|
||||
headerTintColor: HERO_HEADER_ICON_COLOR,
|
||||
headerBlurEffect: undefined,
|
||||
statusBarStyle: 'light',
|
||||
headerBackTitle: undefined,
|
||||
headerRight: () => RightButton,
|
||||
headerRight: createWalletDetailsHeaderRight({ walletID, isLoading, iconColor: HERO_HEADER_ICON_COLOR }),
|
||||
};
|
||||
|
||||
if (Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop) {
|
||||
return {
|
||||
...base,
|
||||
headerRight: undefined,
|
||||
experimental_userInterfaceStyle: 'dark' as const,
|
||||
unstable_headerRightItems: createWalletDetailsHeaderRightItems({ isLoading, walletID }),
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
84
patches/@react-navigation+native-stack+7.15.1.patch
Normal file
84
patches/@react-navigation+native-stack+7.15.1.patch
Normal file
@ -0,0 +1,84 @@
|
||||
diff --git a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
|
||||
index a42477a..3ff714c 100644
|
||||
--- a/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
|
||||
+++ b/node_modules/@react-navigation/native-stack/lib/module/views/useHeaderConfigProps.js
|
||||
@@ -159,7 +159,8 @@ export function useHeaderConfigProps({
|
||||
route,
|
||||
title,
|
||||
unstable_headerLeftItems: headerLeftItems,
|
||||
- unstable_headerRightItems: headerRightItems
|
||||
+ unstable_headerRightItems: headerRightItems,
|
||||
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption
|
||||
}) {
|
||||
const {
|
||||
direction
|
||||
@@ -365,7 +366,7 @@ export function useHeaderConfigProps({
|
||||
children,
|
||||
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
|
||||
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
|
||||
- experimental_userInterfaceStyle: dark ? 'dark' : 'light'
|
||||
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=useHeaderConfigProps.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
|
||||
index 2f1351a..5742b66 100644
|
||||
--- a/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
|
||||
+++ b/node_modules/@react-navigation/native-stack/lib/typescript/src/types.d.ts
|
||||
@@ -302,6 +302,14 @@ export type NativeStackNavigationOptions = {
|
||||
* @platform ios
|
||||
*/
|
||||
unstable_headerRightItems?: (props: NativeStackHeaderItemProps) => NativeStackHeaderItem[];
|
||||
+ /**
|
||||
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
|
||||
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
|
||||
+ *
|
||||
+ * @platform ios
|
||||
+ * @experimental
|
||||
+ */
|
||||
+ experimental_userInterfaceStyle?: import('react-native-screens').ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
|
||||
/**
|
||||
* String or a function that returns a React Element to be used by the header.
|
||||
* Defaults to screen `title` or route name.
|
||||
diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx
|
||||
index 7488b1c..542333e 100644
|
||||
--- a/node_modules/@react-navigation/native-stack/src/types.tsx
|
||||
+++ b/node_modules/@react-navigation/native-stack/src/types.tsx
|
||||
@@ -350,6 +350,15 @@ export type NativeStackNavigationOptions = {
|
||||
unstable_headerRightItems?: (
|
||||
props: NativeStackHeaderItemProps
|
||||
) => NativeStackHeaderItem[];
|
||||
+ /**
|
||||
+ * When set, overrides the navigation header `UIUserInterfaceStyle` (affects iOS 26+ bar materials and tint resolution).
|
||||
+ * If omitted, React Navigation sets this from the navigation theme `dark` boolean (`true` → `"dark"`, else `"light"`).
|
||||
+ *
|
||||
+ * @platform ios
|
||||
+ * @experimental
|
||||
+ * @see {@link https://github.com/react-navigation/react-navigation/issues/13069}
|
||||
+ */
|
||||
+ experimental_userInterfaceStyle?: ScreenStackHeaderConfigProps['experimental_userInterfaceStyle'];
|
||||
/**
|
||||
* String or a function that returns a React Element to be used by the header.
|
||||
* Defaults to screen `title` or route name.
|
||||
diff --git a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
|
||||
index 6f74856..d12cf7d 100644
|
||||
--- a/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
|
||||
+++ b/node_modules/@react-navigation/native-stack/src/views/useHeaderConfigProps.tsx
|
||||
@@ -217,6 +217,7 @@ export function useHeaderConfigProps({
|
||||
title,
|
||||
unstable_headerLeftItems: headerLeftItems,
|
||||
unstable_headerRightItems: headerRightItems,
|
||||
+ experimental_userInterfaceStyle: experimentalUserInterfaceStyleOption,
|
||||
}: Props): ScreenStackHeaderConfigProps {
|
||||
const { direction } = useLocale();
|
||||
const { colors, fonts, dark } = useTheme();
|
||||
@@ -527,6 +528,7 @@ export function useHeaderConfigProps({
|
||||
children,
|
||||
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
|
||||
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
|
||||
- experimental_userInterfaceStyle: dark ? 'dark' : 'light',
|
||||
+ experimental_userInterfaceStyle:
|
||||
+ experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light'),
|
||||
} as const;
|
||||
}
|
||||
@ -64,3 +64,48 @@ delivered.
|
||||
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8424
|
||||
during a React Native bump. Remove once `react-native-notifications`
|
||||
ships New-Architecture-safe token delivery.
|
||||
|
||||
---
|
||||
|
||||
## `@react-navigation+native-stack+7.15.1.patch`
|
||||
|
||||
**What:** adds an `experimental_userInterfaceStyle` navigation option to
|
||||
`NativeStackNavigationOptions` (typed in `src/types.tsx` and the built
|
||||
`lib/typescript` d.ts) and threads it through `useHeaderConfigProps` so a
|
||||
screen can override the header's `UIUserInterfaceStyle`. When omitted it
|
||||
falls back to the previous behaviour via
|
||||
`experimentalUserInterfaceStyleOption ?? (dark ? 'dark' : 'light')`.
|
||||
|
||||
**Why:** on iOS 26 the navigation bar's liquid-glass material and tint are
|
||||
resolved from `UIUserInterfaceStyle`. React Navigation hard-codes this from
|
||||
the theme `dark` boolean, so a screen cannot force a light/dark header
|
||||
independent of the active theme. The iOS 26 glass header
|
||||
(`screen/wallets/WalletTransactions.tsx`) needs that per-screen override.
|
||||
|
||||
**Upstream:** https://github.com/react-navigation/react-navigation/issues/13069 (open)
|
||||
|
||||
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
|
||||
Remove once `@react-navigation/native-stack` exposes a header
|
||||
`UIUserInterfaceStyle` override upstream. When bumping the dependency,
|
||||
rename this patch to the new version and re-confirm the hunks still apply
|
||||
(`npx patch-package`).
|
||||
|
||||
---
|
||||
|
||||
## `react-native-screens+4.25.2.patch`
|
||||
|
||||
**What:** in `RNSBarButtonItem.mm`, also set `self.accessibilityIdentifier`
|
||||
when the JS `identifier` is provided (one line, alongside the existing
|
||||
`self.identifier = identifier`).
|
||||
|
||||
**Why:** the iOS 26 glass header builds nav-bar buttons through
|
||||
`unstable_headerRightItems`. The native `identifier` is not exposed as an
|
||||
accessibility identifier, so Detox/XCUITest could not target those bar
|
||||
buttons. Mirroring it onto `accessibilityIdentifier` makes them reachable
|
||||
from e2e tests.
|
||||
|
||||
**Upstream:** no issue filed yet — local accessibility enhancement.
|
||||
|
||||
Added in BlueWallet PR https://github.com/BlueWallet/BlueWallet/pull/8508.
|
||||
When bumping `react-native-screens`, rename this patch to the new version
|
||||
and re-confirm the hunk still applies (`npx patch-package`).
|
||||
|
||||
12
patches/react-native-screens+4.25.2.patch
Normal file
12
patches/react-native-screens+4.25.2.patch
Normal file
@ -0,0 +1,12 @@
|
||||
diff --git a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
|
||||
index 0eb1f09..324b888 100644
|
||||
--- a/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
|
||||
+++ b/node_modules/react-native-screens/ios/RNSBarButtonItem.mm
|
||||
@@ -81,6 +81,7 @@ - (instancetype)initWithConfig:(NSDictionary<NSString *, id> *)dict
|
||||
NSString *identifier = dict[@"identifier"];
|
||||
if (identifier != nil) {
|
||||
self.identifier = identifier;
|
||||
+ self.accessibilityIdentifier = identifier;
|
||||
}
|
||||
NSDictionary *badgeConfig = dict[@"badge"];
|
||||
if (badgeConfig != nil) {
|
||||
@ -2,7 +2,13 @@ import React, { useMemo, useLayoutEffect, useCallback } from 'react';
|
||||
import { View, StyleSheet, Linking, Image, Platform } from 'react-native';
|
||||
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
||||
import loc from '../../loc';
|
||||
import { SettingsScrollView, SettingsSection, SettingsListItem, getSettingsHeaderOptions } from '../../components/platform';
|
||||
import {
|
||||
SettingsScrollView,
|
||||
SettingsSection,
|
||||
SettingsListItem,
|
||||
getSettingsHeaderOptions,
|
||||
isIOS26OrHigher,
|
||||
} from '../../components/platform';
|
||||
import { useSettings } from '../../hooks/context/useSettings';
|
||||
import { useTheme } from '../../components/themes';
|
||||
|
||||
@ -15,6 +21,9 @@ const Settings = () => {
|
||||
const settingsScreenBackgroundColor = isIOSLightMode ? settingsCardColor : colors.background;
|
||||
const settingsListItemBackgroundColor = isIOSLightMode ? colors.background : undefined;
|
||||
useLayoutEffect(() => {
|
||||
if (isIOS26OrHigher) {
|
||||
return;
|
||||
}
|
||||
setOptions(getSettingsHeaderOptions(loc.settings.header, { ...colors, background: settingsScreenBackgroundColor }, dark));
|
||||
}, [setOptions, language, colors, settingsScreenBackgroundColor, dark]); // Include language to trigger re-render when language changes
|
||||
|
||||
|
||||
@ -11,9 +11,15 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
RefreshControl,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Icon from '../../components/Icon';
|
||||
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
||||
import { isDesktop } from '../../blue_modules/environment';
|
||||
@ -35,10 +41,14 @@ import { Chain } from '../../models/bitcoinUnits';
|
||||
import ActionSheet from '../ActionSheet';
|
||||
import { useStorage } from '../../hooks/context/useStorage';
|
||||
import WatchOnlyWarning from '../../components/WatchOnlyWarning';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { NativeStackNavigationOptions, NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
|
||||
import { Transaction, TWallet } from '../../class/wallets/types';
|
||||
import getWalletTransactionsOptions, { WalletTransactionsRouteProps } from '../../navigation/helpers/getWalletTransactionsOptions';
|
||||
import getWalletTransactionsOptions, {
|
||||
WalletTransactionsRouteProps,
|
||||
createWalletDetailsHeaderRight,
|
||||
createWalletDetailsHeaderRightItems,
|
||||
} from '../../navigation/helpers/getWalletTransactionsOptions';
|
||||
import { presentWalletExportReminder } from '../../helpers/presentWalletExportReminder';
|
||||
import selectWallet from '../../helpers/select-wallet';
|
||||
import assert from 'assert';
|
||||
@ -49,6 +59,8 @@ import { getClipboardContent } from '../../blue_modules/clipboard';
|
||||
import HandOffComponent from '../../components/HandOffComponent';
|
||||
import { HandOffActivityType } from '../../components/types';
|
||||
import WalletGradient from '../../class/wallet-gradient';
|
||||
import { isIOS26OrHigher } from '../../components/platform';
|
||||
import Animated, { SharedValue, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
|
||||
const buttonFontSize =
|
||||
PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26) > 22
|
||||
@ -59,7 +71,109 @@ type RouteProps = RouteProp<DetailViewStackParamList, 'WalletTransactions'>;
|
||||
|
||||
type WalletTransactionsProps = NativeStackScreenProps<DetailViewStackParamList, 'WalletTransactions'>;
|
||||
|
||||
type TransactionListItem = Transaction & { type: 'transaction' | 'header' };
|
||||
/** Scroll offset after which the compact wallet name + balance header is shown. */
|
||||
const SCROLLED_HEADER_SHOW_OFFSET = 180;
|
||||
const SCROLLED_HEADER_FADE_IN_MS = 180;
|
||||
const SCROLLED_HEADER_FADE_OUT_MS = 150;
|
||||
|
||||
const usesIos26AnimatedScrolledHeader = Platform.OS === 'ios' && isIOS26OrHigher && !isDesktop;
|
||||
|
||||
/** Native stack options used when scrolled; includes props missing from the published TS types. */
|
||||
type WalletTransactionsScrolledHeaderOptions = NativeStackNavigationOptions & {
|
||||
headerTitleContainerStyle?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
/** Horizontal space reserved so the scrolled title does not run under back / header-right actions. */
|
||||
const getScrolledHeaderTitleLayout = (screenWidth: number) => {
|
||||
const titleInsetLeft = Platform.OS === 'ios' ? (isIOS26OrHigher ? 40 : 56) : 72;
|
||||
const titleInsetRight = Platform.OS === 'ios' ? (isIOS26OrHigher ? 96 : 84) : 84;
|
||||
return {
|
||||
maxWidth: Math.max(0, screenWidth - titleInsetLeft - titleInsetRight),
|
||||
titleInsetLeft,
|
||||
titleInsetRight,
|
||||
};
|
||||
};
|
||||
|
||||
const buildIos26HeaderTitleLayoutOptions = (
|
||||
screenWidth: number,
|
||||
): Pick<WalletTransactionsScrolledHeaderOptions, 'headerTitleAlign' | 'headerTitleContainerStyle'> => ({
|
||||
headerTitleAlign: 'left',
|
||||
headerTitleContainerStyle: {
|
||||
width: screenWidth,
|
||||
maxWidth: screenWidth,
|
||||
alignSelf: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
left: 0,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
type WalletTransactionsScrolledHeaderTitleProps = {
|
||||
walletLabel: string;
|
||||
balance: string;
|
||||
};
|
||||
|
||||
type WalletTransactionsScrolledHeaderTitleAnimatedProps = WalletTransactionsScrolledHeaderTitleProps & {
|
||||
opacity: SharedValue<number>;
|
||||
};
|
||||
|
||||
const WalletTransactionsScrolledHeaderTitleAnimated: React.FC<WalletTransactionsScrolledHeaderTitleAnimatedProps> = ({
|
||||
opacity,
|
||||
walletLabel,
|
||||
balance,
|
||||
}) => {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[scrolledHeaderTitleStyles.animatedTitleWrapper, { width: screenWidth }, animatedStyle]} pointerEvents="box-none">
|
||||
<WalletTransactionsScrolledHeaderTitle walletLabel={walletLabel} balance={balance} />
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const WalletTransactionsScrolledHeaderTitle: React.FC<WalletTransactionsScrolledHeaderTitleProps> = ({ walletLabel, balance }) => {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const { colors } = useTheme();
|
||||
const { maxWidth, titleInsetLeft, titleInsetRight } = getScrolledHeaderTitleLayout(screenWidth);
|
||||
|
||||
const titleColor = Platform.OS === 'ios' ? colors.foregroundColor : '#FFFFFF';
|
||||
|
||||
const titleContent = (
|
||||
<>
|
||||
<Text style={[scrolledHeaderTitleStyles.walletLabel, { color: titleColor }]} numberOfLines={1} ellipsizeMode="tail">
|
||||
{walletLabel}
|
||||
</Text>
|
||||
{balance.length > 0 ? (
|
||||
<Text style={[scrolledHeaderTitleStyles.balance, { color: titleColor }]} numberOfLines={1} ellipsizeMode="tail">
|
||||
{balance}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<View style={[scrolledHeaderTitleStyles.iosHeaderRoot, { width: screenWidth }]}>
|
||||
<View
|
||||
style={[
|
||||
scrolledHeaderTitleStyles.container,
|
||||
scrolledHeaderTitleStyles.iosTitleArea,
|
||||
{ left: titleInsetLeft, right: titleInsetRight },
|
||||
]}
|
||||
>
|
||||
{titleContent}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={[scrolledHeaderTitleStyles.container, { maxWidth }]}>{titleContent}</View>;
|
||||
};
|
||||
|
||||
const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { route: WalletTransactionsRouteProps }) => {
|
||||
const { wallets, saveToDisk } = useStorage();
|
||||
const { registerTransactionsHandler, unregisterTransactionsHandler } = useMenuElements();
|
||||
@ -73,8 +187,11 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
const [pageSize] = useState(20);
|
||||
const navigation = useExtendedNavigation();
|
||||
const { setOptions, navigate } = navigation;
|
||||
const { colors } = useTheme();
|
||||
const { colors, dark } = useTheme();
|
||||
const { isElectrumDisabled } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const navBarHeight = Platform.select({ ios: 44, android: 56, default: 44 }) ?? 44;
|
||||
const headerOverlayHeight = insets.top + navBarHeight;
|
||||
const walletActionButtonsRef = useRef<View>(null);
|
||||
const [lastFetchTimestamp, setLastFetchTimestamp] = useState(() => wallet._lastTxFetch || 0);
|
||||
const [fetchFailures, setFetchFailures] = useState(0);
|
||||
@ -87,7 +204,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
const MAX_FAILURES = 3;
|
||||
const flatListRef = useRef<FlatList<Transaction>>(null);
|
||||
const headerRef = useRef<View>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const headerScrolledRef = useRef(false);
|
||||
const scrolledHeaderOpacity = useSharedValue(0);
|
||||
|
||||
const stylesHook = StyleSheet.create({
|
||||
listHeaderText: {
|
||||
@ -100,44 +218,17 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
backgroundContainer: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
gradientBackground: {
|
||||
backgroundColor: WalletGradient.headerColorFor(wallet.type),
|
||||
height: headerHeight > 0 ? headerHeight : '30%',
|
||||
},
|
||||
activityIndicatorStyle: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
sendIcon: { transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }] },
|
||||
receiveIcon: { transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }] },
|
||||
headerBottomBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 12,
|
||||
height: 12,
|
||||
backgroundColor: colors.background,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: colors.shadowColor,
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
},
|
||||
android: {
|
||||
elevation: 0.5,
|
||||
},
|
||||
}),
|
||||
sendIcon: {
|
||||
transform: [{ rotate: direction === 'rtl' ? '-225deg' : '225deg' }],
|
||||
},
|
||||
receiveIcon: {
|
||||
transform: [{ rotate: direction === 'rtl' ? '-45deg' : '45deg' }],
|
||||
},
|
||||
});
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setOptions(getWalletTransactionsOptions({ route }));
|
||||
}, [route, setOptions]),
|
||||
);
|
||||
|
||||
const onBarCodeRead = useCallback(
|
||||
(ret?: { data?: any }) => {
|
||||
if (!isLoading) {
|
||||
@ -147,9 +238,15 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
uri: ret?.data ? ret.data : ret,
|
||||
};
|
||||
if (wallet.chain === Chain.ONCHAIN) {
|
||||
navigate('SendDetailsRoot', { screen: 'SendDetails', params: parameters });
|
||||
navigate('SendDetailsRoot', {
|
||||
screen: 'SendDetails',
|
||||
params: parameters,
|
||||
});
|
||||
} else {
|
||||
navigate('ScanLNDInvoiceRoot', { screen: 'ScanLNDInvoice', params: parameters });
|
||||
navigate('ScanLNDInvoiceRoot', {
|
||||
screen: 'ScanLNDInvoice',
|
||||
params: parameters,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -167,7 +264,6 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
|
||||
useEffect(() => {
|
||||
// keep local display unit in sync when wallet changes (e.g., switching wallets)
|
||||
console.debug('[UnitSwitch] sync from wallet preferred unit', { walletID, preferred: wallet.preferredBalanceUnit });
|
||||
setDisplayUnit(wallet.preferredBalanceUnit);
|
||||
}, [wallet, walletID]);
|
||||
|
||||
@ -176,10 +272,6 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [walletID]);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug('[UnitSwitch] display unit state changed', { walletID, displayUnit, switching: isUnitSwitching });
|
||||
}, [walletID, displayUnit, isUnitSwitching]);
|
||||
|
||||
const sortedTransactions = useMemo(() => {
|
||||
const txs = wallet.getTransactions();
|
||||
txs.sort((a, b) => b.timestamp - a.timestamp);
|
||||
@ -303,7 +395,10 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
await wallet.fetchBtcAddress();
|
||||
toAddress = wallet.refill_addressess[0];
|
||||
} catch (Err) {
|
||||
return presentAlert({ message: (Err as Error).message, type: AlertType.Toast });
|
||||
return presentAlert({
|
||||
message: (Err as Error).message,
|
||||
type: AlertType.Toast,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -391,7 +486,10 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
|
||||
const sendButtonPress = () => {
|
||||
if (wallet.chain === Chain.OFFCHAIN) {
|
||||
return navigate('ScanLNDInvoiceRoot', { screen: 'ScanLNDInvoice', params: { walletID } });
|
||||
return navigate('ScanLNDInvoiceRoot', {
|
||||
screen: 'ScanLNDInvoice',
|
||||
params: { walletID },
|
||||
});
|
||||
}
|
||||
|
||||
if (wallet.type === WatchOnlyWallet.type && wallet.isHd() && !wallet.useWithHardwareWalletEnabled()) {
|
||||
@ -493,55 +591,136 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wallet, wallet.hideBalance, displayUnit, balance]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: any) => {
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
const combinedHeight = 180;
|
||||
if (offsetY < combinedHeight) {
|
||||
setOptions({ ...getWalletTransactionsOptions({ route }), headerTitle: undefined });
|
||||
} else {
|
||||
navigation.setOptions({
|
||||
headerTitle: `${wallet.getLabel()} ${walletBalance}`,
|
||||
const walletLabel = wallet.getLabel();
|
||||
const scrolledHeaderTitle = useCallback(() => {
|
||||
if (usesIos26AnimatedScrolledHeader) {
|
||||
return (
|
||||
<WalletTransactionsScrolledHeaderTitleAnimated opacity={scrolledHeaderOpacity} walletLabel={walletLabel} balance={walletBalance} />
|
||||
);
|
||||
}
|
||||
return <WalletTransactionsScrolledHeaderTitle walletLabel={walletLabel} balance={walletBalance} />;
|
||||
}, [walletLabel, walletBalance, scrolledHeaderOpacity]);
|
||||
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const getScrolledHeaderOptions = useCallback((): WalletTransactionsScrolledHeaderOptions => {
|
||||
const { titleInsetRight } = getScrolledHeaderTitleLayout(screenWidth);
|
||||
const routeIsLoading = route.params.isLoading ?? false;
|
||||
const scrolledHeaderIconColor = colors.foregroundColor;
|
||||
|
||||
return {
|
||||
headerTitle: scrolledHeaderTitle,
|
||||
// iOS ignores 'left'; title is positioned manually in WalletTransactionsScrolledHeaderTitle.
|
||||
...(Platform.OS === 'ios'
|
||||
? buildIos26HeaderTitleLayoutOptions(screenWidth)
|
||||
: {
|
||||
headerTitleAlign: 'left' as const,
|
||||
headerTitleContainerStyle: {
|
||||
paddingRight: titleInsetRight,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: WalletGradient.headerColorFor(wallet.type),
|
||||
},
|
||||
headerTintColor: '#ffffff',
|
||||
}),
|
||||
...(Platform.OS === 'ios'
|
||||
? {
|
||||
headerTintColor: scrolledHeaderIconColor,
|
||||
statusBarStyle: 'light',
|
||||
...(isIOS26OrHigher && !isDesktop
|
||||
? {
|
||||
headerRight: undefined,
|
||||
unstable_headerRightItems: createWalletDetailsHeaderRightItems({
|
||||
isLoading: routeIsLoading,
|
||||
walletID,
|
||||
}),
|
||||
experimental_userInterfaceStyle: dark ? ('dark' as const) : ('light' as const),
|
||||
}
|
||||
: {
|
||||
headerBlurEffect: dark ? ('dark' as const) : ('light' as const),
|
||||
headerRight: createWalletDetailsHeaderRight({
|
||||
walletID,
|
||||
isLoading: routeIsLoading,
|
||||
iconColor: scrolledHeaderIconColor,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}, [scrolledHeaderTitle, screenWidth, colors.foregroundColor, dark, route.params.isLoading, walletID, wallet.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!headerScrolledRef.current) return;
|
||||
setOptions(getScrolledHeaderOptions());
|
||||
}, [walletBalance, getScrolledHeaderOptions, setOptions]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (usesIos26AnimatedScrolledHeader) {
|
||||
headerScrolledRef.current = false;
|
||||
scrolledHeaderOpacity.value = 0;
|
||||
setOptions({
|
||||
...getWalletTransactionsOptions({ route }),
|
||||
...buildIos26HeaderTitleLayoutOptions(screenWidth),
|
||||
headerTitle: scrolledHeaderTitle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
[navigation, wallet, walletBalance, setOptions, route],
|
||||
setOptions(getWalletTransactionsOptions({ route }));
|
||||
}, [route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity, setOptions]),
|
||||
);
|
||||
|
||||
const measureHeaderHeight = useCallback(() => {
|
||||
if (!headerRef.current) {
|
||||
// If header ref is not available, use default background
|
||||
setHeaderHeight(0);
|
||||
return;
|
||||
}
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
const scrolled = offsetY >= SCROLLED_HEADER_SHOW_OFFSET;
|
||||
|
||||
headerRef.current.measure((x, y, width, height, pageX, pageY) => {
|
||||
// Check if the header is actually visible
|
||||
if (height === 0 || pageY < 0) {
|
||||
// Header is not visible, use default background
|
||||
setHeaderHeight(0);
|
||||
if (usesIos26AnimatedScrolledHeader) {
|
||||
if (scrolled === headerScrolledRef.current) return;
|
||||
headerScrolledRef.current = scrolled;
|
||||
scrolledHeaderOpacity.value = withTiming(scrolled ? 1 : 0, {
|
||||
duration: scrolled ? SCROLLED_HEADER_FADE_IN_MS : SCROLLED_HEADER_FADE_OUT_MS,
|
||||
});
|
||||
if (scrolled) {
|
||||
setOptions(getScrolledHeaderOptions());
|
||||
} else {
|
||||
setOptions({
|
||||
...getWalletTransactionsOptions({ route }),
|
||||
...buildIos26HeaderTitleLayoutOptions(screenWidth),
|
||||
headerTitle: scrolledHeaderTitle,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fullHeight = pageY + height;
|
||||
if (fullHeight > 0) {
|
||||
setHeaderHeight(fullHeight);
|
||||
if (scrolled === headerScrolledRef.current) return;
|
||||
headerScrolledRef.current = scrolled;
|
||||
|
||||
if (!scrolled) {
|
||||
setOptions({
|
||||
...getWalletTransactionsOptions({ route }),
|
||||
headerTitle: undefined,
|
||||
headerTitleAlign: undefined,
|
||||
headerTitleContainerStyle: undefined,
|
||||
headerBlurEffect: undefined,
|
||||
});
|
||||
} else {
|
||||
setOptions(getScrolledHeaderOptions());
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[getScrolledHeaderOptions, setOptions, route, screenWidth, scrolledHeaderTitle, scrolledHeaderOpacity],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(measureHeaderHeight, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [walletID, measureHeaderHeight]);
|
||||
|
||||
const ListHeaderComponent = useMemo(
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View ref={headerRef} onLayout={measureHeaderHeight}>
|
||||
<View ref={headerRef}>
|
||||
<TransactionsNavigationHeader
|
||||
headerOverlayHeight={headerOverlayHeight}
|
||||
wallet={wallet}
|
||||
onWalletUnitChange={async selectedUnit => {
|
||||
console.debug('[UnitSwitch] requested', { walletID, from: displayUnit, to: selectedUnit });
|
||||
setIsUnitSwitching(true);
|
||||
setDisplayUnit(selectedUnit);
|
||||
if ('setPreferredBalanceUnit' in wallet) {
|
||||
@ -550,22 +729,25 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
(wallet as TWallet).preferredBalanceUnit = selectedUnit;
|
||||
}
|
||||
await saveToDisk();
|
||||
console.debug('[UnitSwitch] persisted preferred unit', { walletID, unit: selectedUnit });
|
||||
setTimeout(() => {
|
||||
setIsUnitSwitching(false);
|
||||
console.debug('[UnitSwitch] complete', { walletID, unit: selectedUnit });
|
||||
}, 50);
|
||||
}}
|
||||
unit={displayUnit}
|
||||
unitSwitching={isUnitSwitching}
|
||||
onWalletBalanceVisibilityChange={async isShouldBeVisible => {
|
||||
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
|
||||
if (wallet.hideBalance && isBiometricsEnabled) {
|
||||
const unlocked = await unlockWithBiometrics();
|
||||
if (!unlocked) throw new Error('Biometrics failed');
|
||||
onWalletBalanceVisibilityChange={async shouldHideBalance => {
|
||||
try {
|
||||
const isBiometricsEnabled = await isBiometricUseCapableAndEnabled();
|
||||
if (wallet.hideBalance && !shouldHideBalance && isBiometricsEnabled) {
|
||||
if (!(await unlockWithBiometrics())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
wallet.hideBalance = shouldHideBalance;
|
||||
await saveToDisk();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle balance visibility:', error);
|
||||
}
|
||||
wallet.hideBalance = isShouldBeVisible;
|
||||
await saveToDisk();
|
||||
}}
|
||||
onManageFundsPressed={id => {
|
||||
if (wallet.type === MultisigHDWallet.type) {
|
||||
@ -591,36 +773,30 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<View style={styles.headerBottomBarSpacer}>
|
||||
<View style={stylesHook.headerBottomBar} />
|
||||
<View style={[styles.flex, styles.transactionsSection, stylesHook.backgroundContainer]}>
|
||||
<View style={styles.listHeaderTextRow}>
|
||||
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={stylesHook.backgroundContainer}>
|
||||
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
|
||||
<WatchOnlyWarning
|
||||
handleDismiss={() => {
|
||||
setIsWatchOnlyWarningVisible(false);
|
||||
wallet.isWatchOnlyWarningVisible = false;
|
||||
saveToDisk();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<>
|
||||
<View style={[styles.flex, stylesHook.backgroundContainer]}>
|
||||
<View style={styles.listHeaderTextRow}>
|
||||
<Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.list_title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={stylesHook.backgroundContainer}>
|
||||
{wallet.type === WatchOnlyWallet.type && isWatchOnlyWarningVisible && (
|
||||
<WatchOnlyWarning
|
||||
handleDismiss={() => {
|
||||
setIsWatchOnlyWarningVisible(false);
|
||||
wallet.isWatchOnlyWarningVisible = false;
|
||||
saveToDisk();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
wallet,
|
||||
displayUnit,
|
||||
isUnitSwitching,
|
||||
measureHeaderHeight,
|
||||
headerOverlayHeight,
|
||||
stylesHook.backgroundContainer,
|
||||
stylesHook.headerBottomBar,
|
||||
stylesHook.listHeaderText,
|
||||
saveToDisk,
|
||||
isBiometricUseCapableAndEnabled,
|
||||
@ -633,16 +809,18 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
headerScrolledRef.current = false;
|
||||
scrolledHeaderOpacity.value = 0;
|
||||
if (flatListRef.current) {
|
||||
flatListRef.current.scrollToOffset({ offset: 0, animated: true });
|
||||
}
|
||||
}, [walletID]);
|
||||
}, [walletID, scrolledHeaderOpacity]);
|
||||
|
||||
return (
|
||||
<View style={[styles.flex, stylesHook.backgroundContainer]}>
|
||||
<View style={[styles.refreshIndicatorBackground, stylesHook.gradientBackground]} testID="TransactionsListView" />
|
||||
<View style={[styles.flex, { backgroundColor: WalletGradient.headerColorFor(wallet.type) }]} testID="TransactionsListView">
|
||||
<FlatList<Transaction>
|
||||
ref={flatListRef}
|
||||
style={styles.flatList}
|
||||
getItemLayout={getItemLayout}
|
||||
updateCellsBatchingPeriod={50}
|
||||
onEndReachedThreshold={0.3}
|
||||
@ -653,8 +831,9 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
keyExtractor={_keyExtractor}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={10}
|
||||
removeClippedSubviews
|
||||
contentContainerStyle={stylesHook.backgroundContainer}
|
||||
removeClippedSubviews={false}
|
||||
contentContainerStyle={[styles.contentContainer, stylesHook.backgroundContainer]}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
contentInset={{ top: 0, left: 0, bottom: 90, right: 0 }}
|
||||
maxToRenderPerBatch={10}
|
||||
onScroll={handleScroll}
|
||||
@ -671,11 +850,25 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
}
|
||||
refreshControl={
|
||||
!isDesktop && !isElectrumDisabled ? (
|
||||
<RefreshControl refreshing={isLoading} onRefresh={() => refreshTransactions(true)} tintColor={colors.msSuccessCheck} />
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={() => refreshTransactions(true)}
|
||||
tintColor={Platform.OS === 'ios' ? 'transparent' : colors.msSuccessCheck}
|
||||
progressViewOffset={headerOverlayHeight}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading && Platform.OS === 'ios' && (
|
||||
<ActivityIndicator
|
||||
style={[styles.refreshSpinner, { top: headerOverlayHeight + 12, transform: [{ scale: 1.4 }] }]}
|
||||
color="#ffffff"
|
||||
size="small"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FloatButtonsBottomFade />
|
||||
<FContainer ref={walletActionButtonsRef}>
|
||||
{wallet.allowReceive() && (
|
||||
@ -684,7 +877,10 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
text={loc.receive.header}
|
||||
onPress={() => {
|
||||
if (wallet.chain === Chain.OFFCHAIN) {
|
||||
navigate('LNDCreateInvoiceRoot', { screen: 'LNDCreateInvoice', params: { walletID } });
|
||||
navigate('LNDCreateInvoiceRoot', {
|
||||
screen: 'LNDCreateInvoice',
|
||||
params: { walletID },
|
||||
});
|
||||
} else {
|
||||
navigate('ReceiveDetails', { walletID });
|
||||
}
|
||||
@ -735,22 +931,81 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
|
||||
export default WalletTransactions;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
headerBottomBarSpacer: { position: 'relative', height: 12 },
|
||||
scrollViewContent: { flex: 1, justifyContent: 'center', paddingHorizontal: 16, paddingBottom: 500 },
|
||||
activityIndicator: { marginVertical: 20 },
|
||||
listHeaderTextRow: { flex: 1, marginHorizontal: 16, flexDirection: 'row', justifyContent: 'space-between' },
|
||||
listHeaderText: { marginTop: 0, marginBottom: 16, fontWeight: 'bold', fontSize: 24 },
|
||||
refreshIndicatorBackground: {
|
||||
const scrolledHeaderTitleStyles = StyleSheet.create({
|
||||
animatedTitleWrapper: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
iosHeaderRoot: {
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iosTitleArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
container: {
|
||||
minWidth: 0,
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
walletLabel: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.15,
|
||||
alignSelf: 'stretch',
|
||||
flexShrink: 1,
|
||||
},
|
||||
balance: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
lineHeight: 18,
|
||||
marginTop: 1,
|
||||
alignSelf: 'stretch',
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
flatList: { flex: 1, backgroundColor: 'transparent' },
|
||||
transactionsSection: { marginTop: -1 },
|
||||
scrollViewContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 500,
|
||||
},
|
||||
activityIndicator: { marginVertical: 20 },
|
||||
listHeaderTextRow: {
|
||||
flex: 1,
|
||||
marginHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
listHeaderText: {
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
fontWeight: 'bold',
|
||||
fontSize: 24,
|
||||
},
|
||||
contentContainer: { flexGrow: 1 },
|
||||
refreshSpinner: { position: 'absolute', alignSelf: 'center', zIndex: 10 },
|
||||
emptyTxsContainer: { height: '10%', minHeight: '10%', flex: 1 },
|
||||
emptyTxs: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', marginVertical: 16 },
|
||||
emptyTxsLightning: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', fontWeight: '600' },
|
||||
emptyTxs: {
|
||||
fontSize: 18,
|
||||
color: '#9aa0aa',
|
||||
textAlign: 'center',
|
||||
marginVertical: 16,
|
||||
},
|
||||
emptyTxsLightning: {
|
||||
fontSize: 18,
|
||||
color: '#9aa0aa',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
},
|
||||
iconContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@ -25,6 +25,7 @@ import { useSettings } from '../../hooks/context/useSettings';
|
||||
import useMenuElements from '../../hooks/useMenuElements';
|
||||
import SafeAreaSectionList from '../../components/SafeAreaSectionList';
|
||||
import { scanQrHelper } from '../../helpers/scan-qr';
|
||||
import { isIOS26OrHigher } from '../../components/platform';
|
||||
|
||||
const WalletsListSections = { CAROUSEL: 'CAROUSEL', TRANSACTIONS: 'TRANSACTIONS' };
|
||||
|
||||
@ -552,6 +553,8 @@ const WalletsList: React.FC = () => {
|
||||
updateCellsBatchingPeriod={50}
|
||||
getItemLayout={getItemLayout}
|
||||
ignoreTopInset={true} // Ignore top inset as the screen header already handles it
|
||||
// On iOS 26+, let the section headers scroll naturally with the content rather than sticking
|
||||
stickySectionHeadersEnabled={!isIOS26OrHigher}
|
||||
{...refreshProps}
|
||||
/>
|
||||
{renderScanButton()}
|
||||
|
||||
@ -195,22 +195,24 @@ describe('BlueWallet UI Tests - no wallets', () => {
|
||||
.withTimeout(10000);
|
||||
await element(by.id('NotificationsSwitch')).tap();
|
||||
|
||||
// If notifications are not enabled on the device, an alert will appear
|
||||
// If notifications are not enabled on the device, an alert will appear.
|
||||
// On iOS 26 the glass dialog animation can be slower; use a longer timeout.
|
||||
try {
|
||||
await waitFor(element(by.text('OK')))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
.withTimeout(8000);
|
||||
await element(by.text('OK')).tap();
|
||||
} catch (_) {
|
||||
// Alert not shown, which is fine - notifications might be enabled
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
await element(by.id('NotificationsSwitch')).tap();
|
||||
|
||||
// If notifications are not enabled on the device, an alert will appear
|
||||
try {
|
||||
await waitFor(element(by.text('OK')))
|
||||
.toBeVisible()
|
||||
.withTimeout(3000);
|
||||
.withTimeout(8000);
|
||||
await element(by.text('OK')).tap();
|
||||
} catch (_) {
|
||||
// Alert not shown, which is fine - notifications might be enabled
|
||||
|
||||
@ -58,8 +58,20 @@ export async function waitForText(text, timeout = 33000) {
|
||||
await waitFor(element(by.text(text)))
|
||||
.toBeVisible()
|
||||
.withTimeout(timeout / 2);
|
||||
return true;
|
||||
} catch (err) {
|
||||
rethrowWithCallsite(err, callsite);
|
||||
// iOS 26 liquid glass: text rendered inside/over the glass header (e.g. the wallet name on
|
||||
// the transactions hero) can fail Detox's 75%-pixel toBeVisible check while still being
|
||||
// present and on-screen — same root cause as the goBack() back-button workaround. Fall back
|
||||
// to existence in the hierarchy so a glass false-negative does not fail an otherwise valid run.
|
||||
try {
|
||||
await waitFor(element(by.text(text)))
|
||||
.toExist()
|
||||
.withTimeout(3000);
|
||||
return true;
|
||||
} catch (_) {
|
||||
rethrowWithCallsite(err, callsite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -373,8 +385,14 @@ export async function goBack() {
|
||||
// and when a modal covers a stack that also has a back button, the covered
|
||||
// one can precede the visible one in match order (seen with Reduce Motion on).
|
||||
// Probe attributes and only tap an element detox reports as visible & hittable.
|
||||
//
|
||||
// iOS 26 liquid glass: the native back button reports visible=false because
|
||||
// the glass material fails Detox's 75%-pixel visibility check, yet the button
|
||||
// IS functionally hittable. We first try (visible && hittable), then fall back
|
||||
// to (hittable only) for the glass case.
|
||||
let lastErr;
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
// Pass 1: prefer visible + hittable elements
|
||||
for (const matcher of candidates) {
|
||||
for (let idx = 0; idx < 6; idx++) {
|
||||
let attrs;
|
||||
@ -393,6 +411,25 @@ export async function goBack() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pass 2: accept hittable-only elements (iOS 26 liquid glass back button)
|
||||
for (const matcher of candidates) {
|
||||
for (let idx = 0; idx < 6; idx++) {
|
||||
let attrs;
|
||||
try {
|
||||
attrs = await element(matcher).atIndex(idx).getAttributes();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
break;
|
||||
}
|
||||
if (attrs.hittable === false) continue;
|
||||
try {
|
||||
await element(matcher).atIndex(idx).tap();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user