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
11 changes: 8 additions & 3 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ import * as Font from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
import { ErrorBoundary } from './src/components/common/ErrorBoundary';
import { initializeLogging } from './src/config/logging';
import { AuthProvider, useAdaptiveTheme } from './src/hooks';
import { AuthProvider, useAdaptiveTheme, useAppLifecycle } from './src/hooks';
import AppNavigator from './src/navigation/AppNavigator';
import { setupNotificationNavigation } from './src/navigation/linking';
import { apiClient } from './src/services/api';
import { crashReportingService } from './src/services/cashReporting';
import { crashReportingService } from './src/services/crashReporting';
import { mobileAuthService } from './src/services/mobileAuth';
import {
addNotificationReceivedListener,
getLastNotificationResponse,
removeNotificationListener,
} from './src/services/pushNotifications';
import { requestQueue } from './src/services/requestQueue';
import { requestQueue } from './src/services/api/requestQueue';
import socketService from './src/services/socket';
import syncService from './src/services/syncService';
import { useAppStore } from './src/store';
Expand Down Expand Up @@ -59,6 +59,10 @@ const App = () => {
const theme = useAppStore((state) => state.theme);
useAdaptiveTheme();

// Pause animations, stop timers, and defer syncs when app is backgrounded;
// resume everything when it returns to the foreground.
useAppLifecycle();

const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const [appIsReady, setAppIsReady] = React.useState(false);

Expand Down Expand Up @@ -153,6 +157,7 @@ const App = () => {
return () => {
socketService.disconnect();
syncService.stopAutoSync();
requestQueue.stopMonitoring();
notificationCleanup();
removeNotificationListener(subscription);
// Clean up the unhandled rejection handler
Expand Down
36 changes: 31 additions & 5 deletions src/components/ui/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { Animated, DimensionValue, StyleSheet, View, ViewStyle } from 'react-native';
import { Animated, AppState, AppStateStatus, DimensionValue, StyleSheet, View, ViewStyle } from 'react-native';
import { useAdaptiveFrameRate } from '../../hooks/useAdaptiveFrameRate';

/**
Expand All @@ -26,15 +26,19 @@ export const Skeleton: React.FC<SkeletonProps> = ({
style,
}) => {
const pulseAnim = useRef(new Animated.Value(0.3)).current;
const animationRef = useRef<Animated.CompositeAnimation | null>(null);
const { durationMultiplier } = useAdaptiveFrameRate();

useEffect(() => {
const startAnimation = () => {
// Stop any existing animation before starting a new one
animationRef.current?.stop();

const sharedAnimationConfig = {
duration: 1000 * durationMultiplier,
useNativeDriver: true,
};

Animated.loop(
animationRef.current = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
...sharedAnimationConfig,
Expand All @@ -45,8 +49,30 @@ export const Skeleton: React.FC<SkeletonProps> = ({
toValue: 0.3,
}),
])
).start();
}, [pulseAnim, durationMultiplier]);
);
animationRef.current.start();
};

useEffect(() => {
startAnimation();

const handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === 'active') {
startAnimation();
} else {
// background or inactive — pause the shimmer
animationRef.current?.stop();
}
};

const subscription = AppState.addEventListener('change', handleAppStateChange);

return () => {
animationRef.current?.stop();
subscription.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [durationMultiplier]);

const skeletonStyle: ViewStyle = {
width: width,
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ export { useOptimizedVideoGestures, OptimizedVideoGesturesView } from './useOpti

export * from './useDebounce';
export * from './usePredictivePreload';
export * from './useAppLifecycle';
58 changes: 58 additions & 0 deletions src/hooks/useAppLifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { requestQueue } from '../services/api/requestQueue';
import socketService from '../services/socket';
import syncService from '../services/syncService';
import { appLogger } from '../utils/logger';

/**
* Centralises background/foreground lifecycle management.
*
* When the app moves to the background or becomes inactive:
* • The auto-sync interval is stopped.
* • The request-queue monitoring interval is stopped.
* • The WebSocket connection is disconnected.
*
* When the app returns to the foreground:
* • Auto-sync is restarted.
* • Request-queue monitoring is resumed (using the previously stored api client).
* • The WebSocket is reconnected.
*
* Skeleton shimmer animations are paused/resumed independently inside the
* Skeleton component itself via its own AppState listener.
*/
export function useAppLifecycle(): void {
const appStateRef = useRef<AppStateStatus>(AppState.currentState);

useEffect(() => {
const handleAppStateChange = (nextState: AppStateStatus) => {
const prevState = appStateRef.current;
appStateRef.current = nextState;

const goingToBackground =
prevState === 'active' && (nextState === 'background' || nextState === 'inactive');
const returningToForeground =
(prevState === 'background' || prevState === 'inactive') && nextState === 'active';

if (goingToBackground) {
appLogger.infoSync('[Lifecycle] App backgrounded — pausing timers & socket');
syncService.stopAutoSync();
requestQueue.stopMonitoring();
socketService.disconnect();
}

if (returningToForeground) {
appLogger.infoSync('[Lifecycle] App foregrounded — resuming timers & socket');
syncService.startAutoSync();
requestQueue.resumeMonitoring();
socketService.connect();
}
};

const subscription = AppState.addEventListener('change', handleAppStateChange);

return () => {
subscription.remove();
};
}, []);
}
33 changes: 31 additions & 2 deletions src/services/api/requestQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class RequestQueue {
private readonly MAX_RETRIES = 3;
private isProcessing = false;
private listeners: ((count: number) => void)[] = [];
private monitoringIntervalId: ReturnType<typeof setInterval> | null = null;
private apiClientRef: any = null;

/**
* Add a failed request to the queue
Expand Down Expand Up @@ -118,17 +120,44 @@ class RequestQueue {
}

/**
* Start monitoring network and processing queue
* Start monitoring network and processing queue.
* Safe to call multiple times — no-ops if already running.
*/
startMonitoring(apiClient: any): void {
const interval = setInterval(async () => {
if (this.monitoringIntervalId !== null) {
return; // already running
}
this.apiClientRef = apiClient;
this.monitoringIntervalId = setInterval(async () => {
await this.processQueue(apiClient);
}, 10000); // Check every 10 seconds

// Also process immediately if online
this.processQueue(apiClient);
}

/**
* Stop network-monitoring interval.
* Called when the app moves to the background to save battery.
*/
stopMonitoring(): void {
if (this.monitoringIntervalId !== null) {
clearInterval(this.monitoringIntervalId);
this.monitoringIntervalId = null;
logger.info('Request queue monitoring stopped');
}
}

/**
* Resume monitoring after returning to the foreground.
* Uses the apiClient that was passed to the last startMonitoring call.
*/
resumeMonitoring(): void {
if (this.apiClientRef) {
this.startMonitoring(this.apiClientRef);
}
}

/**
* Get pending requests count
*/
Expand Down
71 changes: 71 additions & 0 deletions tests/components/Skeleton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,74 @@ describe('Skeleton', () => {
});
});
});

// ── AppState lifecycle ────────────────────────────────────────────────────────

import { AppState } from 'react-native';

describe('Skeleton — AppState animation lifecycle', () => {
// Grab the mocked Animated internals provided by jest.setup.js
const { Animated: MockAnimated } = require('react-native');

let stopMock: jest.Mock;
let loopInstance: { start: jest.Mock; stop: jest.Mock };

beforeEach(() => {
jest.clearAllMocks();

// Each Animated.loop() call returns a fresh controllable handle
stopMock = jest.fn();
loopInstance = { start: jest.fn(), stop: stopMock };
MockAnimated.loop.mockReturnValue(loopInstance);
});

it('starts the animation on mount', () => {
render(<Skeleton />);
expect(MockAnimated.loop).toHaveBeenCalled();
expect(loopInstance.start).toHaveBeenCalled();
});

it('stops the animation when the app goes to background', () => {
render(<Skeleton />);

// Simulate AppState → background
const [[, handler]] = (AppState.addEventListener as jest.Mock).mock.calls;
handler('background');

expect(stopMock).toHaveBeenCalled();
});

it('stops the animation when the app becomes inactive', () => {
render(<Skeleton />);

const [[, handler]] = (AppState.addEventListener as jest.Mock).mock.calls;
handler('inactive');

expect(stopMock).toHaveBeenCalled();
});

it('restarts the animation when the app returns to the foreground', () => {
render(<Skeleton />);
const callsBefore = MockAnimated.loop.mock.calls.length;

const [[, handler]] = (AppState.addEventListener as jest.Mock).mock.calls;
handler('background');
handler('active');

// A new loop should have been created when returning to active
expect(MockAnimated.loop.mock.calls.length).toBeGreaterThan(callsBefore);
expect(loopInstance.start).toHaveBeenCalledTimes(2); // mount + foreground
});

it('stops the animation and removes the AppState listener on unmount', () => {
const mockRemove = jest.fn();
(AppState.addEventListener as jest.Mock).mockReturnValue({ remove: mockRemove });

const { unmount } = render(<Skeleton />);
unmount();

expect(stopMock).toHaveBeenCalled();
expect(mockRemove).toHaveBeenCalled();
});
});

Loading