Compare commits

...

40 Commits

Author SHA1 Message Date
Ivan Vershigora
1f69b205c1
tst: tolerate iOS 26 glass false-negative in waitForText
On iOS 26 the liquid-glass header makes Detox's 75%-pixel toBeVisible check
report the wallet name on the transactions hero as not visible, even though
it is present and on-screen — the same root cause as the goBack() back-button
workaround. waitForText now falls back to toExist when toBeVisible fails, so a
glass false-negative no longer fails an otherwise valid run.

Fixes the 4 BIP84 e2e tests (wallet-details rename, manage UTXO, both purge
flows). Verified locally against the ios.release build on iPhone 17 / iOS 26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:36:34 +01:00
Ivan Vershigora
8c84f703df
Revert "fix: use real header height for wallet tx hero overlay on iOS 26"
This reverts commit 3e5989f60d.
2026-06-17 15:13:45 +01:00
Ivan Vershigora
ae22fd4986
Revert "fix: floor wallet tx hero overlay height so label clears iOS 26 bar"
This reverts commit 4dfbf9b672.
2026-06-17 15:13:45 +01:00
ncoelho
8bebe748e8 tests failure 2026-06-17 15:39:22 +02:00
Ivan Vershigora
4dfbf9b672
fix: floor wallet tx hero overlay height so label clears iOS 26 bar
useHeaderHeight() under-reports on first mount in release/Fabric builds —
the native header-height event is missed on appearing screens, so it stays
at the legacy 44pt-based default and the hero label renders under the
taller iOS 26 glass navigation bar (Detox toBeVisible occlusion). Floor
the overlay with a deterministic safe-area-inset + bar height (54pt on
iOS 26), taking the max with the dynamic value so it stays correct when
the native height does report. Measured on iPhone 17 / iOS 26.3.1:
inset 62 + bar 54 = 116; label now renders clear at y=148. Verified the
full rename flow (the failing e2e path) on-device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:17:52 +01:00
Ivan Vershigora
3e5989f60d
fix: use real header height for wallet tx hero overlay on iOS 26
The hero header overlay height was hardcoded to insets.top + 44pt, which
matches the legacy navigation-bar height. On iOS 26 the liquid-glass bar
is taller, so the wallet label rendered under the navigation bar and was
occluded — failing Detox `toBeVisible` and looking cramped. Use
useHeaderHeight() from @react-navigation/elements, which reports the true
native bar height per iOS version. Verified on iPhone 17 / iOS 26.3.1:
the label now sits clear below the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:14:40 +01:00
Ivan Vershigora
ac1f1b5969
fix: remove useless debug 2026-06-17 00:14:40 +01:00
Ivan
be036805d1
Merge branch 'master' into feat-ios26 2026-06-16 16:19:02 +01:00
Ivan Vershigora
f0d7e89534
fix: bump patches 2026-06-16 16:17:47 +01:00
Marcos Rodriguez
8739bb3c8e
Merge branch 'master' into feat-ios26 2026-06-14 12:03:20 -05:00
Marcos Rodriguez Vélez
9683bac8f0
Merge branch 'master' into feat-ios26 2026-05-28 13:58:58 -05:00
ncoelho
61f6b1fcc5 Merge branch 'master' into feat-ios26
Resolve TransactionsNavigationHeader conflict: keep master manage-funds
layout with branch bottomBar overlap and enableAndroidRipple. Restore
white hero header chrome on iOS 26+ after merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 22:29:35 +02:00
ncoelho
206545b286 fix bar 2026-05-26 08:33:45 +02:00
ncoelho
b5cf1c3438 fix bar 2026-05-26 07:42:50 +02:00
ncoelho
d344c30806 fix bar 2026-05-26 07:39:30 +02:00
ncoelho
d44382dc7c fix bar 2026-05-25 22:10:08 +02:00
ncoelho
761cb9740c fix bar 2026-05-25 21:19:55 +02:00
ncoelho
c8e96ef786 fix bar 2026-05-25 20:29:12 +02:00
ncoelho
671a0b0ab2 Merge branch 'feat-ios26' of https://github.com/BlueWallet/BlueWallet into feat-ios26 2026-05-25 19:47:36 +02:00
ncoelho
adcc927f94 fix tests 2026-05-25 19:47:20 +02:00
Nuno
0c57b12832
Merge branch 'master' into feat-ios26 2026-05-25 19:41:02 +02:00
ncoelho
23a9170fc5 fix tests 2026-05-25 19:40:22 +02:00
ncoelho
9027641d3f fix tx list 2026-05-25 19:22:37 +02:00
ncoelho
1d424709be fix lint 2026-05-22 19:53:55 +02:00
ncoelho
7dcb7c5773 fix lint 2026-05-22 19:17:32 +02:00
Nuno
590109453a
Merge branch 'master' into feat-ios26 2026-05-22 17:04:08 +02:00
ncoelho
3b1f59bab2 fix: wallettransactions on ios26 2026-05-22 16:59:30 +02:00
Nuno
52a9687399
Merge branch 'master' into feat-ios26 2026-05-22 12:53:18 +02:00
Nuno
7b2e2ff0a4
Merge branch 'master' into feat-ios26 2026-05-20 22:03:47 +02:00
Nuno
02109024b4
Merge branch 'master' into feat-ios26 2026-05-19 18:04:33 +02:00
ncoelho
5eefcea9fc fix: lint 2026-05-19 18:04:14 +02:00
ncoelho
fb0c50c3cb fix: main button 2026-05-19 18:02:01 +02:00
ncoelho
8c836a59b6 Merge branch 'master' into feat-ios26
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	navigation/DetailViewScreensStack.tsx
#	screen/wallets/WalletsList.tsx
2026-05-19 17:14:25 +02:00
li0nd3v
07596096ab fix styles 2026-05-04 19:09:39 +02:00
li0nd3v
d898b128b8 conflicts 2026-05-04 18:31:48 +02:00
li0nd3v
34dc0be2fc Merge branch 'master' into feat-ios26
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 18:29:16 +02:00
Nuno
3db81d2d91
Merge branch 'master' into feat-ios26 2026-04-28 14:58:49 +02:00
li0nd3v
825ee9a419 fix: tests 2026-04-28 14:58:30 +02:00
li0nd3v
31aa6a7212 fix: review comments 2026-04-28 14:52:37 +02:00
li0nd3v
1ce2dcc1cd feat: iOS 26 glass 2026-04-27 18:37:10 +02:00
15 changed files with 929 additions and 349 deletions

View File

@ -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

View File

@ -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',

View File

@ -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}

View File

@ -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',

View File

@ -245,8 +245,6 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIDesignRequiresCompatibility</key>
<true/>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>

View File

@ -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}

View File

@ -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 (preiOS 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({

View 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;
}

View File

@ -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`).

View 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) {

View File

@ -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

View File

@ -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',

View File

@ -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()}

View File

@ -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

View File

@ -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);
}