Skip to content

Commit 1d1dbcc

Browse files
mrmoney10010-designmrmoney10010-design
andauthored
feat: implement network status banner and wrong network detection (#540)
Co-authored-by: mrmoney10010-design <mrmoney10010@gmail.com>
1 parent 3e3ea80 commit 1d1dbcc

20 files changed

Lines changed: 469 additions & 294 deletions

__mocks__/@stellar/stellar-sdk.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export const Networks = {
5656
};
5757

5858
export const Server = jest.fn(() => ({
59+
root: jest.fn(async () => ({
60+
network_passphrase: 'Test SDF Network ; September 2015',
61+
})),
5962
loadAccount: jest.fn(async () => ({ sequence: '0', balances: [] })),
6063
submitTransaction: jest.fn(async () => ({ hash: 'mockhash' })),
6164
payments: jest.fn(() => ({

__tests__/NetworkStatusBanner.test.tsx

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
* NetworkStatusBanner – component tests
33
*
44
* Acceptance criteria covered:
5-
* AC-NB1 – Banner is not rendered when networkErrorType is 'none'.
5+
* AC-NB1 – Banner is not rendered when state is 'online'.
66
* AC-NB2 – Banner is rendered for 'offline'.
77
* AC-NB3 – Banner is rendered for 'service-unavailable'.
8-
* AC-NB4 – Retry button calls onRetry when tapped.
9-
* AC-NB5 – Retry button is disabled while isRetrying is true.
10-
* AC-NB6 – App does not crash when rendered with any valid props.
8+
* AC-NB4 – Banner is rendered for 'wrong-network'.
9+
* AC-NB5 – Retry button calls onRetry when tapped.
10+
* AC-NB6 – Retry button is disabled while isRetrying is true.
11+
* AC-NB7 – App does not crash when rendered with any valid state.
1112
*/
1213

1314
import React from 'react';
@@ -16,22 +17,19 @@ import { render, fireEvent } from '@testing-library/react-native';
1617
jest.mock('lucide-react-native', () => ({
1718
WifiOff: () => null,
1819
AlertTriangle: () => null,
20+
CloudOff: () => null,
1921
RefreshCw: () => null,
2022
}));
2123

2224
import { NetworkStatusBanner } from '../src/components/NetworkStatusBanner';
2325

24-
const OFFLINE_MESSAGE = 'No internet connection. Check your network and try again.';
25-
const SERVICE_MESSAGE = 'Stellar Testnet services appear unavailable. Please try again shortly.';
26+
// ─── AC-NB1: Hidden when online ────────────────────────────────────────────
2627

27-
// ─── AC-NB1: Hidden when no error ────────────────────────────────────────────
28-
29-
describe('AC-NB1 – not rendered when networkErrorType is none', () => {
30-
it('returns null when type is "none"', () => {
28+
describe('AC-NB1 – not rendered when state is online', () => {
29+
it('returns null when state is "online"', () => {
3130
const { queryByTestId } = render(
3231
<NetworkStatusBanner
33-
networkErrorType="none"
34-
message=""
32+
state="online"
3533
onRetry={jest.fn()}
3634
/>
3735
);
@@ -42,44 +40,56 @@ describe('AC-NB1 – not rendered when networkErrorType is none', () => {
4240
// ─── AC-NB2: Offline banner ───────────────────────────────────────────────────
4341

4442
describe('AC-NB2 – offline banner', () => {
45-
it('renders the banner and message when type is "offline"', () => {
43+
it('renders the banner and message when state is "offline"', () => {
4644
const { getByTestId, getByText } = render(
4745
<NetworkStatusBanner
48-
networkErrorType="offline"
49-
message={OFFLINE_MESSAGE}
46+
state="offline"
5047
onRetry={jest.fn()}
5148
/>
5249
);
5350
expect(getByTestId('network-status-banner')).toBeTruthy();
54-
expect(getByText(OFFLINE_MESSAGE)).toBeTruthy();
51+
expect(getByText(/You are offline/i)).toBeTruthy();
5552
});
5653
});
5754

5855
// ─── AC-NB3: Service-unavailable banner ──────────────────────────────────────
5956

6057
describe('AC-NB3 – service-unavailable banner', () => {
61-
it('renders the banner and message when type is "service-unavailable"', () => {
58+
it('renders the banner and message when state is "service-unavailable"', () => {
59+
const { getByTestId, getByText } = render(
60+
<NetworkStatusBanner
61+
state="service-unavailable"
62+
onRetry={jest.fn()}
63+
/>
64+
);
65+
expect(getByTestId('network-status-banner')).toBeTruthy();
66+
expect(getByText(/Stellar network services are unavailable/i)).toBeTruthy();
67+
});
68+
});
69+
70+
// ─── AC-NB4: Wrong-network banner ────────────────────────────────────────────
71+
72+
describe('AC-NB4 – wrong-network banner', () => {
73+
it('renders the banner and message when state is "wrong-network"', () => {
6274
const { getByTestId, getByText } = render(
6375
<NetworkStatusBanner
64-
networkErrorType="service-unavailable"
65-
message={SERVICE_MESSAGE}
76+
state="wrong-network"
6677
onRetry={jest.fn()}
6778
/>
6879
);
6980
expect(getByTestId('network-status-banner')).toBeTruthy();
70-
expect(getByText(SERVICE_MESSAGE)).toBeTruthy();
81+
expect(getByText(/Connected to the wrong blockchain network/i)).toBeTruthy();
7182
});
7283
});
7384

74-
// ─── AC-NB4: Retry callback ───────────────────────────────────────────────────
85+
// ─── AC-NB5: Retry callback ───────────────────────────────────────────────────
7586

76-
describe('AC-NB4 – retry button calls onRetry', () => {
87+
describe('AC-NB5 – retry button calls onRetry', () => {
7788
it('calls onRetry when the retry button is pressed', () => {
7889
const onRetry = jest.fn();
7990
const { getByTestId } = render(
8091
<NetworkStatusBanner
81-
networkErrorType="offline"
82-
message={OFFLINE_MESSAGE}
92+
state="offline"
8393
onRetry={onRetry}
8494
/>
8595
);
@@ -88,35 +98,32 @@ describe('AC-NB4 – retry button calls onRetry', () => {
8898
});
8999
});
90100

91-
// ─── AC-NB5: Retry disabled while retrying ───────────────────────────────────
101+
// ─── AC-NB6: Retry disabled while retrying ───────────────────────────────────
92102

93-
describe('AC-NB5 – retry button is disabled while isRetrying', () => {
103+
describe('AC-NB6 – retry button is disabled while isRetrying', () => {
94104
it('renders retry button as disabled when isRetrying is true', () => {
95105
const onRetry = jest.fn();
96106
const { getByTestId } = render(
97107
<NetworkStatusBanner
98-
networkErrorType="offline"
99-
message={OFFLINE_MESSAGE}
108+
state="offline"
100109
onRetry={onRetry}
101110
isRetrying={true}
102111
/>
103112
);
104113
const retryBtn = getByTestId('network-status-retry');
105-
// The button should not respond to presses when disabled
106114
fireEvent.press(retryBtn);
107115
expect(onRetry).not.toHaveBeenCalled();
108116
});
109117
});
110118

111-
// ─── AC-NB6: Does not crash ───────────────────────────────────────────────────
119+
// ─── AC-NB7: Does not crash ───────────────────────────────────────────────────
112120

113-
describe('AC-NB6 – does not crash with any valid props', () => {
121+
describe('AC-NB7 – does not crash with any valid state', () => {
114122
it('renders without crashing for "offline"', () => {
115123
expect(() =>
116124
render(
117125
<NetworkStatusBanner
118-
networkErrorType="offline"
119-
message={OFFLINE_MESSAGE}
126+
state="offline"
120127
onRetry={jest.fn()}
121128
/>
122129
)
@@ -127,20 +134,29 @@ describe('AC-NB6 – does not crash with any valid props', () => {
127134
expect(() =>
128135
render(
129136
<NetworkStatusBanner
130-
networkErrorType="service-unavailable"
131-
message={SERVICE_MESSAGE}
137+
state="service-unavailable"
138+
onRetry={jest.fn()}
139+
/>
140+
)
141+
).not.toThrow();
142+
});
143+
144+
it('renders without crashing for "wrong-network"', () => {
145+
expect(() =>
146+
render(
147+
<NetworkStatusBanner
148+
state="wrong-network"
132149
onRetry={jest.fn()}
133150
/>
134151
)
135152
).not.toThrow();
136153
});
137154

138-
it('renders without crashing for "none"', () => {
155+
it('renders without crashing for "online"', () => {
139156
expect(() =>
140157
render(
141158
<NetworkStatusBanner
142-
networkErrorType="none"
143-
message=""
159+
state="online"
144160
onRetry={jest.fn()}
145161
/>
146162
)

app/(tabs)/_layout.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import { Tabs } from "expo-router";
33
import { Home, History, PiggyBank, Settings } from "lucide-react-native";
44
import { useTheme } from "../../src/hooks/useTheme";
55
import { useNetworkState } from "../../src/hooks/useNetworkState";
6-
import { NetworkStateBanner } from "../../src/components/NetworkStateBanner";
6+
import { NetworkStatusBanner } from "../../src/components/NetworkStatusBanner";
77

88
export default function TabsLayout() {
99
const { colors } = useTheme();
1010
const { state: networkState, retry } = useNetworkState();
1111

1212
return (
1313
<View style={{ flex: 1, backgroundColor: colors.background }}>
14-
<NetworkStateBanner state={networkState} onRetry={retry} />
14+
<NetworkStatusBanner state={networkState} onRetry={retry} />
1515
<Tabs
1616
screenOptions={{
1717
headerStyle: {
@@ -36,14 +36,14 @@ export default function TabsLayout() {
3636
name="index"
3737
options={{
3838
title: "Home",
39-
tabBarIcon: ({ color, size }) => <Home color={color} size={size} />,
39+
tabBarIcon: ({ color, size }: { color: string; size: number }) => <Home color={color} size={size} />,
4040
}}
4141
/>
4242
<Tabs.Screen
4343
name="history"
4444
options={{
4545
title: "Activity",
46-
tabBarIcon: ({ color, size }) => (
46+
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
4747
<History color={color} size={size} />
4848
),
4949
}}
@@ -52,7 +52,7 @@ export default function TabsLayout() {
5252
name="vault"
5353
options={{
5454
title: "Vault",
55-
tabBarIcon: ({ color, size }) => (
55+
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
5656
<PiggyBank color={color} size={size} />
5757
),
5858
}}
@@ -61,7 +61,7 @@ export default function TabsLayout() {
6161
name="settings"
6262
options={{
6363
title: "Settings",
64-
tabBarIcon: ({ color, size }) => (
64+
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
6565
<Settings color={color} size={size} />
6666
),
6767
}}

app/(tabs)/history.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { useWalletStore, TransactionRecord } from '../../src/store/walletStore';
1515
import { RADIUS, SIZES, ThemeColors } from '../../src/constants/theme';
1616
import { useTheme } from '../../src/hooks/useTheme';
1717
import { TransactionListItem } from '../../src/components/TransactionListItem';
18-
import { NetworkStateBanner } from '../../src/components/NetworkStateBanner';
18+
import { NetworkStatusBanner } from '../../src/components/NetworkStatusBanner';
1919
import { EmptyState } from '../../src/components/EmptyState';
2020
import { WalletEmptyState } from '../../src/components/WalletEmptyState';
2121
import { LoadingState } from '../../src/components/LoadingState';
@@ -40,13 +40,19 @@ type FilterType = (typeof FILTERS)[number]['value'];
4040
* Footer rendered below the list while loading more items or when the
4141
* end-of-list has been reached.
4242
*/
43-
const ListFooter: React.FC<{
43+
const ListFooter = ({
44+
isLoadingMore,
45+
hasMoreTransactions,
46+
hasTransactions,
47+
colors,
48+
styles,
49+
}: {
4450
isLoadingMore: boolean;
4551
hasMoreTransactions: boolean;
4652
hasTransactions: boolean;
4753
colors: ThemeColors;
4854
styles: ReturnType<typeof createStyles>;
49-
}> = ({ isLoadingMore, hasMoreTransactions, hasTransactions, colors, styles }) => {
55+
}) => {
5056
if (!hasTransactions) return null;
5157

5258
if (isLoadingMore) {
@@ -74,11 +80,15 @@ const ListFooter: React.FC<{
7480
/**
7581
* Shown when there are no transactions and the screen is not loading.
7682
*/
77-
const ActivityEmptyState: React.FC<{
83+
const ActivityEmptyState = ({
84+
colors,
85+
styles,
86+
onReceivePress,
87+
}: {
7888
colors: ThemeColors;
7989
styles: ReturnType<typeof createStyles>;
8090
onReceivePress: () => void;
81-
}> = ({ colors, styles, onReceivePress }) => (
91+
}) => (
8292
<View style={styles.emptyState} testID="empty-state">
8393
<EmptyState
8494
icon={<Clock color={colors.textMuted} size={48} />}
@@ -135,7 +145,7 @@ export default function HistoryScreen() {
135145
}
136146

137147
const filteredTransactions = useMemo(() => {
138-
return transactions.filter(tx => {
148+
return transactions.filter((tx: TransactionRecord) => {
139149
if (filter === 'all') return true;
140150

141151
const isSent = tx.from === publicKey;
@@ -165,7 +175,7 @@ export default function HistoryScreen() {
165175
transaction={item}
166176
currentPublicKey={publicKey}
167177
variant="card"
168-
onPress={(tx) => router.push(`/transaction/${tx.id}`)}
178+
onPress={(tx: TransactionRecord) => router.push(`/transaction/${tx.id}`)}
169179
/>
170180
),
171181
[publicKey, router]
@@ -233,7 +243,7 @@ export default function HistoryScreen() {
233243
onEndReachedThreshold={0.2}
234244
ListHeaderComponent={
235245
<>
236-
<NetworkStateBanner
246+
<NetworkStatusBanner
237247
state={networkState}
238248
onRetry={() => { refreshWalletData(); retry(); }}
239249
isRetrying={isLoading}
@@ -347,7 +357,7 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({
347357
filterChip: {
348358
paddingHorizontal: SIZES.md,
349359
paddingVertical: SIZES.sm,
350-
borderRadius: RADIUS.full,
360+
borderRadius: RADIUS.round,
351361
borderWidth: 1,
352362
borderColor: colors.border,
353363
backgroundColor: colors.surface,

app/(tabs)/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useTheme } from '../../src/hooks/useTheme';
77
import { Button } from '../../src/components/Button';
88
import { FundButton } from '../../src/components/FundButton';
99
import { TransactionListItem } from '../../src/components/TransactionListItem';
10-
import { NetworkStateBanner } from '../../src/components/NetworkStateBanner';
10+
import { NetworkStatusBanner } from '../../src/components/NetworkStatusBanner';
1111
import { WalletEmptyState } from '../../src/components/WalletEmptyState';
1212
import { BalanceDisplay } from '../../src/components/BalanceDisplay';
1313
import { FundingStatusBanner } from '../../src/components/FundingStatusBanner';
@@ -80,7 +80,7 @@ export default function HomeScreen() {
8080
/>
8181
}
8282
>
83-
<NetworkStateBanner
83+
<NetworkStatusBanner
8484
state={networkState}
8585
onRetry={handleRetry}
8686
isRetrying={isLoading}

app/(tabs)/vault.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
2727
import { VaultReceiptModal } from "../../src/components/VaultReceiptModal";
2828
import { isActionSupported, getActionUnsupportedReason, getActionUnsupportedDetail } from '../../src/utils/vaultCapabilities';
2929
import { useNetworkState } from '../../src/hooks/useNetworkState';
30-
import { NetworkStateBanner } from '../../src/components/NetworkStateBanner';
30+
import { NetworkStatusBanner } from '../../src/components/NetworkStatusBanner';
3131
import { WithdrawalPreview } from '../../src/features/vault/WithdrawalPreview';
3232
import { DepositPreview } from '../../src/features/vault/DepositPreview';
33+
import type { VaultLock } from '../../src/types';
3334

3435
const LOCK_PERIOD_SECONDS = 30 * 24 * 60 * 60; // 30 days
3536
const VAULT_INTRO_SEEN_KEY = '@pocketpay_vault_intro_seen';
@@ -45,11 +46,11 @@ export default function VaultScreen() {
4546
const { state: networkState, disableWriteActions: networkDisabled, retry: retryNetwork } = useNetworkState({ error: walletError });
4647
const {
4748
balance,
48-
locks,
49+
locks: _unused_locks,
4950
isConfigured,
5051
contractId,
5152
isLoadingBalance,
52-
isLoadingLocks,
53+
isLoadingLocks: _unused_isLoadingLocks,
5354
isSubmitting,
5455
balanceError,
5556
vaultError,
@@ -276,7 +277,7 @@ export default function VaultScreen() {
276277
onClose={() => setReceiptVisible(false)}
277278
/>
278279

279-
<NetworkStateBanner
280+
<NetworkStatusBanner
280281
state={networkState}
281282
onRetry={() => {
282283
retryNetwork();

0 commit comments

Comments
 (0)