Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@
"prettier.arrowParens": "avoid",
"javascript.updateImportsOnFileMove.enabled": "always",
"typescript.updateImportsOnFileMove.enabled": "always",
"java.configuration.updateBuildConfiguration": "disabled"
"java.configuration.updateBuildConfiguration": "disabled",
"chat.tools.terminal.autoApprove": {
"mkdir": true,
"mv": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-08
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
## Context

The Server Selection (Node Selection) screen (`src/screens/AppSettings/Node/NodeSelection.tsx`) displays two tabs — **Public Server** and **Private Electrum** — inside a single `ScreenWrapper`. The Private Electrum tab renders `AddNode`, which contains `KeeperTextInput` fields backed by native `TextInput`.

On iOS, `ScreenWrapper` avoids the status bar by computing `paddingTop: hp(15) + insets.top` via `useSafeAreaInsets()` — a JavaScript-side hook that reads from the `SafeAreaProvider` context. After switching tabs (Public → Private → Public), iOS may update the safe-area context in response to keyboard events or layout passes triggered by the text-input fields. During this window, `useSafeAreaInsets()` can transiently return `{top: 0}`, and if a React re-render captures that value the `paddingTop` collapses, causing the header to permanently overlap the status bar.

A secondary contributor is that the shared `ScrollView` (Gluestack-wrapped) retains its scroll offset and keyboard-adjusted content insets across tab switches. When switching back to the Public Server tab the scroll view can be positioned incorrectly, compounding the visual shift.

Android is unaffected because `ScreenWrapper` uses `SafeAreaView` (native insets applied in layout rather than via a JS hook) on that platform.

## Goals / Non-Goals

**Goals:**
- Eliminate the status-bar overlap on iOS after switching tabs on the Server Selection screen.
- Make `ScreenWrapper`'s iOS path use `SafeAreaView` (native inset handling), matching the existing Android path and removing dependence on the transient-zero bug in `useSafeAreaInsets()`.
- Dismiss the keyboard and reset the `ScrollView` scroll offset when the user switches tabs, so no stale scroll state carries over.

**Non-Goals:**
- Changing visual spacing or padding values on any other screen.
- Refactoring any screen other than `NodeSelection.tsx` and `ScreenWrapper.tsx`.
- Modifying `TabBar`, `AddNode`, or `WalletHeader` components.
- Redux, Realm, or MMKV changes — no data-layer work required.

## Decisions

### D1 — Switch iOS ScreenWrapper path to `SafeAreaView`

**Decision:** Replace the iOS-specific `Box` + manual `paddingTop: hp(15) + insets.top` in `ScreenWrapper.tsx` with `SafeAreaView` (from `react-native-safe-area-context`) using `edges={['top', 'left', 'right', 'bottom']}` and an extra top-padding of `hp(15)` (via `styles.container.paddingTop`), matching the existing Android `SafeAreaView` path.

**Why:** `SafeAreaView` resolves insets natively in the layout pass; it does not rely on the JS-context timing that `useSafeAreaInsets()` is subject to. This is the recommended approach in the `react-native-safe-area-context` docs for full-screen wrappers.

**Alternative considered — guard `insets.top` with a ref:** Store the first non-zero `insets.top` in a `useRef` and fall back to it on subsequent renders. Rejected: adds complexity for every screen that uses `ScreenWrapper`; doesn't fix the root timing issue.

**Alternative considered — fix only `NodeSelection.tsx`:** Add a local `SafeAreaView` or extra top padding in that one screen. Rejected: the root cause is in `ScreenWrapper` and would recur in any other tab-based screen with text inputs.

### D2 — Dismiss keyboard and reset scroll on tab switch in `NodeSelection.tsx`

**Decision:** In the `setActiveTab` handler, call `Keyboard.dismiss()` from `react-native` before updating state. Additionally create a `scrollRef` (`useRef<ScrollView>`) on the native `ScrollView` and call `scrollRef.current?.scrollTo({ y: 0, animated: false })` on tab switch.

**Why:** Ensures no stale scroll offset or keyboard-adjusted content inset carries over from the Private Electrum tab to the Public Server tab.

**Alternative considered — `keyboardShouldPersistTaps="handled"` only:** Does not dismiss the keyboard proactively; the user would still see a stale layout briefly.

## Risks / Trade-offs

- **`ScreenWrapper` change is app-wide (iOS):** Every iOS screen using `ScreenWrapper` will switch from the manual-padding approach to `SafeAreaView`. Risk is low because the effective insets are identical; the only difference is the inset source (native vs. JS). All existing `paddingTop`/`paddingHorizontal` values are preserved via `styles.container`.
- **`SafeAreaView` `edges` prop:** Using `edges={['top', 'left', 'right', 'bottom']}` is consistent with the Android path. Any screen that previously relied on getting extra bottom padding from `insets.bottom` in the old path will receive equivalent handling since `SafeAreaView` adds native bottom inset. Should be tested visually on notched and non-notched devices.

## Affected Files

| File | Change |
|---|---|
| `src/components/ScreenWrapper.tsx` | iOS path: replace `Box` + `useSafeAreaInsets` with `SafeAreaView` |
| `src/screens/AppSettings/Node/NodeSelection.tsx` | Add `Keyboard.dismiss()` + `scrollRef.scrollTo(0)` on tab switch |

## Open Questions

_None — fix scope is fully determined._
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Why

On iOS, the Server Selection screen's header permanently overlaps the system status bar after switching from the Private Electrum tab back to the Public Server tab. This breaks the UI for any user who explores node configuration options, making the screen unusable without navigating away and back.

## What Changes

- Dismiss the keyboard when the user switches tabs in `NodeSelection.tsx` to prevent iOS keyboard-driven layout distortions from persisting.
- Replace `useSafeAreaInsets()` manual padding in `ScreenWrapper.tsx` iOS path with `SafeAreaView` from `react-native-safe-area-context` for robust, layout-pass–safe inset handling (matching the existing Android path).
- Reset scroll position when switching tabs so the `ScrollView` does not retain a shifted offset from the Private Electrum tab.

## Capabilities

### New Capabilities
<!-- None — this is a bug fix with no new user-facing capabilities -->

### Modified Capabilities
<!-- No spec-level requirement changes; this is an implementation-only fix -->

## Impact

- **Affected files**: `src/screens/AppSettings/Node/NodeSelection.tsx`, `src/components/ScreenWrapper.tsx`
- **Platform**: iOS only (Android path already uses `SafeAreaView`)
- **Network**: Mainnet and testnet (the Server Selection screen is shared)
- **Hardware signers**: Not affected
- **Subscription tiers**: Not gated — all users can access node settings
- **Security/privacy**: No key material handled; no network calls introduced
- **Non-goals**: Do not change layout behaviour on Android; do not alter existing screen padding values; do not refactor unrelated screens

## Non-goals

- Changing the visual spacing or padding values on any other screen
- Fixing unrelated layout issues on other tab-based screens
- Modifying the `TabBar` component itself
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## ADDED Requirements

### Requirement: Server Selection screen SHALL maintain status-bar clearance across tab switches on iOS
On iOS, the Server Selection screen header and content SHALL remain below the system status bar (safe-area top inset) at all times, including after switching between the Public Server and Private Electrum tabs in any order and any number of times.

#### Scenario: Header stays below status bar after switching tabs
- **WHEN** the user opens the Server Selection screen on iOS
- **AND** switches to the Private Electrum tab
- **AND** switches back to the Public Server tab
- **THEN** the screen header and tab bar SHALL be positioned below the safe-area top inset with no overlap with the status bar

#### Scenario: Content stays below status bar without touching text inputs
- **WHEN** the user switches tabs without interacting with any text input
- **THEN** the layout SHALL remain identical to the initial render of the screen

#### Scenario: Content stays below status bar after keyboard interaction
- **WHEN** the user focuses a text input on the Private Electrum tab (keyboard appears)
- **AND** switches to the Public Server tab
- **THEN** the keyboard SHALL be dismissed
- **AND** the screen header SHALL be positioned correctly below the status bar with no overlap

### Requirement: Scroll position SHALL reset when switching tabs
The Server Selection `ScrollView` SHALL scroll to the top (y = 0) when the user switches between the Public Server and Private Electrum tabs.

#### Scenario: Public Server tab resets scroll position
- **WHEN** the user scrolls down in the Public Server tab
- **AND** switches to the Private Electrum tab
- **AND** switches back to the Public Server tab
- **THEN** the `ScrollView` SHALL be scrolled to y = 0

#### Scenario: Private Electrum tab resets scroll position
- **WHEN** the user scrolls down in the Private Electrum tab
- **AND** switches to the Public Server tab
- **AND** switches back to the Private Electrum tab
- **THEN** the `ScrollView` SHALL be scrolled to y = 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## 1. Fix ScreenWrapper iOS safe-area handling

- [x] 1.1 In `src/components/ScreenWrapper.tsx`, replace the iOS `Box` + `useSafeAreaInsets()` path with `SafeAreaView` (from `react-native-safe-area-context`) using `edges={['top', 'left', 'right', 'bottom']}` and preserve `paddingHorizontal` and the existing `styles.container` top/bottom padding values
- [x] 1.2 Remove the `useSafeAreaInsets` import and hook call from `ScreenWrapper.tsx` (no longer needed after 1.1)
- [x] 1.3 Verify that `SafeAreaView` is already exported by `react-native-safe-area-context` in the project (it is — already imported on the Android path)

## 2. Fix NodeSelection tab-switch layout issues

- [x] 2.1 In `src/screens/AppSettings/Node/NodeSelection.tsx`, add `import { Keyboard } from 'react-native'` and call `Keyboard.dismiss()` at the start of the `setActiveTab` callback (before `setConnectionError` and `setActiveTab`)
- [x] 2.2 Add a `scrollRef` using `useRef<ScrollView>(null)` and attach it to the `ScrollView` via the `ref` prop
- [x] 2.3 In the `setActiveTab` callback, after calling `Keyboard.dismiss()`, call `scrollRef.current?.scrollTo({ y: 0, animated: false })` to reset the scroll position on every tab switch
- [x] 2.4 Ensure `ScrollView` type import for the ref is sourced correctly (use `ScrollView` from `@gluestack-ui/themed-native-base` or cast via `ScrollView as unknown as typeof RNScrollView` if the ref type requires it)

## 3. Verify

- [ ] 3.1 Manually test on an iOS device/simulator: open Server Selection, switch to Private Electrum, switch back to Public Server — confirm header does not overlap status bar
- [ ] 3.2 Manually test: focus a text input on Private Electrum tab, switch to Public Server — confirm keyboard is dismissed and header is correctly positioned
- [ ] 3.3 Manually test on Android: confirm no visual regression on the Server Selection screen
- [ ] 3.4 Check at least one other screen that uses `ScreenWrapper` on iOS (e.g., Wallet Details) to confirm safe-area padding is unchanged
33 changes: 33 additions & 0 deletions openspec/specs/server-selection-layout/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
### Requirement: Server Selection screen SHALL maintain status-bar clearance across tab switches on iOS
On iOS, the Server Selection screen header and content SHALL remain below the system status bar (safe-area top inset) at all times, including after switching between the Public Server and Private Electrum tabs in any order and any number of times.

#### Scenario: Header stays below status bar after switching tabs
- **WHEN** the user opens the Server Selection screen on iOS
- **AND** switches to the Private Electrum tab
- **AND** switches back to the Public Server tab
- **THEN** the screen header and tab bar SHALL be positioned below the safe-area top inset with no overlap with the status bar

#### Scenario: Content stays below status bar without touching text inputs
- **WHEN** the user switches tabs without interacting with any text input
- **THEN** the layout SHALL remain identical to the initial render of the screen

#### Scenario: Content stays below status bar after keyboard interaction
- **WHEN** the user focuses a text input on the Private Electrum tab (keyboard appears)
- **AND** switches to the Public Server tab
- **THEN** the keyboard SHALL be dismissed
- **AND** the screen header SHALL be positioned correctly below the status bar with no overlap

### Requirement: Scroll position SHALL reset when switching tabs
The Server Selection `ScrollView` SHALL scroll to the top (y = 0) when the user switches between the Public Server and Private Electrum tabs.

#### Scenario: Public Server tab resets scroll position
- **WHEN** the user scrolls down in the Public Server tab
- **AND** switches to the Private Electrum tab
- **AND** switches back to the Public Server tab
- **THEN** the `ScrollView` SHALL be scrolled to y = 0

#### Scenario: Private Electrum tab resets scroll position
- **WHEN** the user scrolls down in the Private Electrum tab
- **AND** switches to the Public Server tab
- **AND** switches back to the Private Electrum tab
- **THEN** the `ScrollView` SHALL be scrolled to y = 0
35 changes: 9 additions & 26 deletions src/components/ScreenWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { Platform, StatusBarStyle, StyleSheet } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { StatusBarStyle, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Box, StatusBar, useColorMode } from '@gluestack-ui/themed-native-base';
import { hp } from 'src/constants/responsive';

Expand All @@ -16,34 +16,17 @@ function ScreenWrapper({
paddingHorizontal?: number;
}) {
const { colorMode } = useColorMode();
const insets = useSafeAreaInsets();
const computedBarStyle = barStyle ?? (colorMode === 'light' ? 'dark-content' : 'light-content');

return (
<Box backgroundColor={backgroundcolor} style={styles.wrapper}>
{Platform.OS === 'android' ? (
<SafeAreaView
edges={['top', 'left', 'right', 'bottom']}
style={[styles.container, { paddingHorizontal }]}
>
<StatusBar barStyle={computedBarStyle} backgroundColor="transparent" />
{children}
</SafeAreaView>
) : (
<Box
style={[
styles.container,
{
paddingHorizontal,
paddingTop: hp(15) + insets.top,
paddingBottom: hp(5) + insets.bottom,
},
]}
>
<StatusBar barStyle={computedBarStyle} backgroundColor="transparent" />
{children}
</Box>
)}
<SafeAreaView
edges={['top', 'left', 'right', 'bottom']}
style={[styles.container, { paddingHorizontal }]}
>
<StatusBar barStyle={computedBarStyle} backgroundColor="transparent" />
{children}
</SafeAreaView>
</Box>
);
}
Expand Down
9 changes: 6 additions & 3 deletions src/screens/AppSettings/Node/NodeSelection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box, ScrollView, useColorMode } from '@gluestack-ui/themed-native-base';
import React, { useContext, useEffect, useState } from 'react';
import { StyleSheet } from 'react-native';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { Keyboard, ScrollView as RNScrollView, StyleSheet } from 'react-native';
import ScreenWrapper from 'src/components/ScreenWrapper';
import TabBar from 'src/components/TabBar';
import { hp, wp } from 'src/constants/responsive';
Expand Down Expand Up @@ -84,6 +84,7 @@ const NodeSelection = () => {
const [port, setPort] = useState('');
const [useSSL, setUseSSL] = useState(true);
const [connectionError, setConnectionError] = useState('');
const scrollRef = useRef<RNScrollView>(null);
const isDarkMode = colorMode === 'dark';

const tabsData = [{ label: settings.publicServer }, { label: settings.privateElectrum }];
Expand Down Expand Up @@ -236,14 +237,16 @@ const NodeSelection = () => {
tabs={tabsData}
activeTab={activeTab}
setActiveTab={(tab) => {
Keyboard.dismiss();
scrollRef.current?.scrollTo({ y: 0, animated: false });
setConnectionError('');
setActiveTab(tab);
}}
/>
</Box>

<Box style={styles.tabContentContainer}>
<ScrollView>
<ScrollView ref={scrollRef as any}>
{activeTab === 0 ? (
<PublicServer
currentlySelectedNode={currentlySelectedNode}
Expand Down
Loading