Fix React version mismatch, auth fallback, and UI polish

Pin React to 19.2.3 (exact version RN 0.85.3's bundled renderer
requires — 19.2.6 caused "Cannot read property 'default' of
undefined" crash at getPaperRenderer).

Auth: fall back to parsing user info from Cognito ID token when
backend API is unreachable, removing the hard dependency on
localhost:5000 for credential login.

UI fixes:
- Safe area insets on Settings screen (top edge was missing)
- Separate refresh indicator from filter-triggered loading on
  proposal queue (chips no longer trigger pull-to-refresh animation)
- Dashboard welcome shows first name instead of full email
- Dashboard/AdminDashboard remove duplicate top safe area when
  navigation header already provides it
- Service category selector uses wrapping chips instead of
  cramped SegmentedButtons
- Add ErrorBoundary to App root for crash visibility
This commit is contained in:
Adam Moussa 2026-05-20 13:34:08 -04:00
parent bd9fbb4da5
commit 686b42330c
12 changed files with 1068 additions and 874 deletions

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@
<string>Sea Haven Proposals</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
@ -20,6 +22,17 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.seahavenind.proposals</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.seahavenind.proposals</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
@ -31,35 +44,14 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.seahavenind.proposals</string>
</array>
<key>CFBundleURLName</key>
<string>com.seahavenind.proposals</string>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>Sea Haven Proposals needs camera access to capture vendor proposal documents.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sea Haven Proposals needs photo library access to attach vendor proposal images.</string>
<key>NSFaceIDUsageDescription</key>
<string>Sea Haven Proposals uses Face ID to secure your login session.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sea Haven Proposals needs photo library access to attach vendor proposal images.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIAppFonts</key>
<array>
<string>MaterialCommunityIcons.ttf</string>
@ -82,9 +74,19 @@
<string>FontAwesome6_Brands.ttf</string>
<string>MaterialIcons.ttf</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CFBundleIconName</key>
<string>AppIcon</string>
</dict>
</plist>

View file

@ -7,6 +7,7 @@
"": {
"name": "proposal-system-mobile",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@proposal-system/api-contracts": "file:../shared/api-contracts",
"@react-native-async-storage/async-storage": "^2.1.0",
@ -18,7 +19,7 @@
"@tanstack/react-query": "^5.100.10",
"amazon-cognito-identity-js": "^6.3.0",
"axios": "^1.16.0",
"react": "^19.0.0",
"react": "19.2.3",
"react-native": "^0.85.3",
"react-native-app-auth": "^8.0.0",
"react-native-document-picker": "^9.3.0",
@ -5835,9 +5836,9 @@
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"

View file

@ -20,7 +20,7 @@
"@tanstack/react-query": "^5.100.10",
"amazon-cognito-identity-js": "^6.3.0",
"axios": "^1.16.0",
"react": "^19.0.0",
"react": "19.2.3",
"react-native": "^0.85.3",
"react-native-app-auth": "^8.0.0",
"react-native-document-picker": "^9.3.0",

View file

@ -1,10 +1,10 @@
import React, { useEffect } from 'react';
import React, { useEffect, Component, ErrorInfo, ReactNode } from 'react';
import { Provider as ReduxProvider, useDispatch } from 'react-redux';
import { PaperProvider } from 'react-native-paper';
import { QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import NetInfo from '@react-native-community/netinfo';
import { Alert } from 'react-native';
import { Alert, Text, View } from 'react-native';
import { store } from './store';
import { theme } from './theme';
@ -15,6 +15,26 @@ import { setUser, setLoading } from './store/slices/authSlice';
import { processOfflineQueue } from './hooks/useOfflineDraft';
import { proposalsApi, CreateProposalRequest } from './lib/api/proposals';
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) { return { error }; }
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 32, backgroundColor: '#fff' }}>
<Text style={{ fontSize: 18, fontWeight: 'bold', color: 'red', marginBottom: 8 }}>App Error</Text>
<Text style={{ fontSize: 14, color: '#333' }}>{this.state.error.message}</Text>
<Text style={{ fontSize: 12, color: '#666', marginTop: 8 }}>{this.state.error.stack?.slice(0, 500)}</Text>
</View>
);
}
return this.props.children;
}
}
function AuthBootstrap({ children }: { children: React.ReactNode }) {
const dispatch = useDispatch();
@ -61,16 +81,18 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
export default function App() {
return (
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<PaperProvider theme={theme}>
<SafeAreaProvider>
<AuthBootstrap>
<RootNavigator />
</AuthBootstrap>
</SafeAreaProvider>
</PaperProvider>
</QueryClientProvider>
</ReduxProvider>
<ErrorBoundary>
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<PaperProvider theme={theme}>
<SafeAreaProvider>
<AuthBootstrap>
<RootNavigator />
</AuthBootstrap>
</SafeAreaProvider>
</PaperProvider>
</QueryClientProvider>
</ReduxProvider>
</ErrorBoundary>
);
}

View file

@ -21,6 +21,7 @@ export function usePaginatedList<T>(
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [err, setErr] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -37,8 +38,7 @@ export function usePaginatedList<T>(
};
}, [search]);
const reload = useCallback(() => {
setLoading(true);
const fetchData = useCallback(() => {
setErr('');
fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams })
.then(({ items, totalCount: total }) => {
@ -46,13 +46,22 @@ export function usePaginatedList<T>(
setTotalCount(total);
})
.catch((e: Error) => setErr(e.message || 'Failed to load'))
.finally(() => setLoading(false));
.finally(() => {
setLoading(false);
setRefreshing(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchFn, debouncedSearch, page, pageSize, extraKey]);
useEffect(() => {
reload();
}, [reload]);
setLoading(true);
fetchData();
}, [fetchData]);
const reload = useCallback(() => {
setRefreshing(true);
fetchData();
}, [fetchData]);
return useMemo(
() => ({
@ -65,9 +74,10 @@ export function usePaginatedList<T>(
setPageSize,
totalCount,
loading,
refreshing,
err,
reload,
}),
[rows, search, page, pageSize, totalCount, loading, err, reload],
[rows, search, page, pageSize, totalCount, loading, refreshing, err, reload],
);
}

View file

@ -9,6 +9,22 @@ import {
StoredUser,
} from '../storage';
function parseUserFromIdToken(idToken: string): StoredUser {
const base64Url = idToken.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
const groups: string[] = payload['cognito:groups'] || [];
const role = groups.includes('admins') || groups.includes('sysadmins')
? 'Admin'
: 'Dispatcher';
return {
id: payload.sub,
email: payload.email,
displayName: payload.name || payload.email,
role: role as StoredUser['role'],
};
}
const cognitoConfig = {
clientId: Config.COGNITO_CLIENT_ID,
redirectUrl: Config.COGNITO_REDIRECT_URI,
@ -53,7 +69,12 @@ export const authApi = {
};
await tokenStorage.save(tokens);
const profile = await authApi.getMe();
let profile: StoredUser;
try {
profile = await authApi.getMe();
} catch {
profile = parseUserFromIdToken(cognitoTokens.idToken);
}
await userStorage.save(profile);
return profile;
},

View file

@ -1,4 +1,3 @@
import type { CognitoUserSession } from 'amazon-cognito-identity-js';
import Config from '../../config';
export interface CognitoTokens {
@ -8,7 +7,8 @@ export interface CognitoTokens {
expiresAt: string;
}
function extractTokens(session: CognitoUserSession): CognitoTokens {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractTokens(session: any): CognitoTokens {
const accessToken = session.getAccessToken();
return {
accessToken: accessToken.getJwtToken(),
@ -22,7 +22,6 @@ export async function authenticateWithCredentials(
email: string,
password: string,
): Promise<CognitoTokens> {
// Lazy-load to keep amazon-cognito-identity-js out of the startup module graph
const {
CognitoUserPool,
CognitoUser,

View file

@ -8,7 +8,7 @@ export function SettingsScreen() {
const { user, logout } = useAuth();
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}>
<Card style={styles.card} mode="elevated">
<Card.Content>

View file

@ -30,6 +30,7 @@ export function ProposalQueueScreen() {
search,
setSearch,
loading,
refreshing,
reload,
} = usePaginatedList<ProposalListItem>(
(params) =>
@ -90,7 +91,7 @@ export function ProposalQueueScreen() {
<ProposalCard proposal={item} onPress={() => handlePress(item)} />
)}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={reload} />
<RefreshControl refreshing={refreshing} onRefresh={reload} />
}
contentContainerStyle={rows.length === 0 ? styles.empty : styles.list}
ListEmptyComponent={

View file

@ -52,7 +52,7 @@ export function DashboardScreen() {
}
>
<Text variant="headlineSmall" style={styles.welcome}>
Welcome, {user?.displayName?.split(' ')[0] || 'there'}
Welcome, {user?.displayName?.includes('@') ? user.displayName.split('@')[0] : user?.displayName?.split(' ')[0] || 'there'}
</Text>
{stats && (

View file

@ -11,6 +11,7 @@ import {
TextInput,
Button,
Text,
Chip,
SegmentedButtons,
Snackbar,
} from 'react-native-paper';
@ -146,16 +147,19 @@ export function NewProposalScreen() {
<Text variant="labelLarge" style={styles.label}>
Service Category
</Text>
<SegmentedButtons
value={draft.serviceCategory}
onValueChange={(v) => updateDraft({ serviceCategory: v })}
buttons={SERVICE_CATEGORIES.map((c) => ({
value: c,
label: c,
}))}
style={styles.segmented}
density="small"
/>
<View style={styles.chipRow}>
{SERVICE_CATEGORIES.map((c) => (
<Chip
key={c}
selected={draft.serviceCategory === c}
showSelectedOverlay
onPress={() => updateDraft({ serviceCategory: c })}
style={styles.chip}
>
{c}
</Chip>
))}
</View>
<Text variant="labelLarge" style={styles.label}>
Priority
@ -247,6 +251,15 @@ const styles = StyleSheet.create({
segmented: {
marginBottom: 16,
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
},
chip: {
marginBottom: 0,
},
hint: {
color: '#9E9E9E',
marginBottom: 12,