fix: address code review findings for mobile app (FRE-4572)

P0 fixes:
- Replace crypto.randomUUID() with uuid v4 (not available in RN)
- Replace Platform.Version with expo-device osVersion
- Fix auth navigation types, remove unused App route

P1 fixes:
- Push notification handler respects user preferences (useRef pattern)
- Fix stale closure: use zustand subscribe + useRef for live preferences
- Add retry logging for device registration failures
- Replace emoji tab icons with @expo/vector-icons Ionicons
- Document API integration TODOs in all local-only stores

P2 fixes:
- Add __DEV__ global declaration (global.d.ts)
- Fix package.json main field to expo/AppEntry.js
- Add retry logging for push device registration
- Add z-index/elevation to LoadingOverlay
- Add visual indicator to EmptyState icon

P3 fixes:
- Type navigation with NavigationProp<RootStackParamList>
- Move getSeverityColor to theme.ts (single source of truth)
- Add useMemo for SpamShield filter computations
- Verified usesNonExemptEncryption: false is correct for expo-secure-store

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-17 10:51:14 -04:00
parent a071aa736e
commit 90a223bc79
16 changed files with 130 additions and 67 deletions

View File

@@ -2,7 +2,7 @@
"name": "@shieldai/mobile",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"dev": "expo start",
"dev:ios": "expo run:ios",

View File

@@ -25,7 +25,9 @@ export function EmptyState({ title, message }: EmptyStateProps) {
return (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<View style={styles.emptyIcon} />
<View style={styles.emptyIcon}>
<Text style={styles.emptyIconText}>📭</Text>
</View>
<View style={styles.emptyText}>
<Text style={styles.emptyTitle}>{title}</Text>
{message && <Text style={styles.emptyMessage}>{message}</Text>}
@@ -41,6 +43,8 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
elevation: 999,
},
emptyContainer: {
flex: 1,
@@ -57,6 +61,11 @@ const styles = StyleSheet.create({
borderRadius: 24,
backgroundColor: COLORS.card,
marginBottom: SPACING.md,
justifyContent: 'center',
alignItems: 'center',
},
emptyIconText: {
fontSize: 24,
},
emptyText: {
alignItems: 'center',

View File

@@ -52,6 +52,15 @@ export const SHADOWS = {
export const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.shieldai.freno.me/api/v1';
export const getSeverityColor = (severity: string): string => {
switch (severity) {
case 'critical': return COLORS.danger;
case 'high': return COLORS.warning;
case 'medium': return COLORS.accent;
default: return COLORS.textSecondary;
}
};
export const TIER_FEATURES = {
free: {
name: 'Free',

1
packages/mobile/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const __DEV__: boolean;

View File

@@ -1,19 +1,30 @@
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import * as Device from 'expo-device';
import { deviceService, notificationService } from '@shieldai/mobile-api-client';
import { useSettingsStore } from '@/store/settingsStore';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export function usePushNotifications() {
const { preferences } = useSettingsStore();
const preferencesRef = useRef(useSettingsStore.getState().preferences);
useEffect(() => {
const subscription = useSettingsStore.subscribe((state) => {
preferencesRef.current = state.preferences;
});
return subscription;
}, []);
Notifications.setNotificationHandler({
handleNotification: async () => {
const prefs = preferencesRef.current;
return {
shouldShowAlert: prefs.pushNotifications,
shouldPlaySound: prefs.pushNotifications,
shouldSetBadge: false,
};
},
});
const registerForPushNotifications = useCallback(async () => {
try {
@@ -33,13 +44,17 @@ export function usePushNotifications() {
projectId: 'shieldai-project-id',
})).data;
await deviceService.registerDevice({
platform: Platform.OS === 'ios' ? 'ios' : 'android',
pushToken: token,
modelName: Platform.OS === 'ios' ? 'iPhone' : 'Android',
osVersion: Platform.Version.toString(),
appVersion: '1.0.0',
});
try {
await deviceService.registerDevice({
platform: Platform.OS === 'ios' ? 'ios' : 'android',
pushToken: token,
modelName: Platform.OS === 'ios' ? 'iPhone' : 'Android',
osVersion: Device.osVersion || '0',
appVersion: '1.0.0',
});
} catch (deviceError) {
console.warn('Device registration failed (will retry on next launch):', deviceError);
}
return token;
} catch (error) {
@@ -51,7 +66,7 @@ export function usePushNotifications() {
useEffect(() => {
const subscription = Notifications.addNotificationReceivedListener((notification) => {
const type = notification.request.content.data?.type;
const prefs = useSettingsStore.getState().preferences;
const prefs = preferencesRef.current;
if (type === 'darkwatch_alert' && !prefs.darkwatchAlert) return;
if (type === 'spam_blocked' && !prefs.spamBlocked) return;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Text, ViewStyle, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { DashboardScreen } from '@/screens/dashboard';
import { DarkWatchScreen } from '@/screens/darkwatch';
@@ -18,25 +19,29 @@ type MainTabParamList = {
const Tab = createBottomTabNavigator<MainTabParamList>();
const iconMap: Record<string, string> = {
Dashboard: '\u{1F6E1}\u{FE0F}',
DarkWatch: '\u{1F441}\u{FE0F}',
SpamShield: '\u{1F6AB}',
VoicePrint: '\u{1F399}\u{FE0F}',
Settings: '\u{2699}\u{FE0F}',
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
Dashboard: 'shield-outline',
DarkWatch: 'eye-outline',
SpamShield: 'ban-outline',
VoicePrint: 'mic-outline',
Settings: 'settings-outline',
};
function TabIcon({ routeName, color }: { routeName: string; color: string }) {
return (
<Text style={[styles.icon, { color }]}>{iconMap[routeName] || '\u{2022}'}</Text>
);
}
const iconActiveMap: Record<string, keyof typeof Ionicons.glyphMap> = {
Dashboard: 'shield',
DarkWatch: 'eye',
SpamShield: 'ban',
VoicePrint: 'mic',
Settings: 'settings',
};
const styles = StyleSheet.create({
icon: {
fontSize: 22,
},
});
function TabIcon({ routeName, color, focused }: { routeName: string; color: string; focused: boolean }) {
const iconName = focused
? (iconActiveMap[routeName] as keyof typeof Ionicons.glyphMap)
: (iconMap[routeName] as keyof typeof Ionicons.glyphMap);
return <Ionicons name={iconName} size={24} color={color} />;
}
export function MainTabNavigator() {
return (
@@ -75,7 +80,7 @@ export function MainTabNavigator() {
options={{
headerTitle: 'Dashboard',
tabBarLabel: 'Home',
tabBarIcon: ({ color }) => <TabIcon routeName="Dashboard" color={color} />,
tabBarIcon: ({ color, focused }) => <TabIcon routeName="Dashboard" color={color} focused={focused} />,
}}
/>
<Tab.Screen
@@ -84,7 +89,7 @@ export function MainTabNavigator() {
options={{
headerTitle: 'DarkWatch',
tabBarLabel: 'DarkWatch',
tabBarIcon: ({ color }) => <TabIcon routeName="DarkWatch" color={color} />,
tabBarIcon: ({ color, focused }) => <TabIcon routeName="DarkWatch" color={color} focused={focused} />,
}}
/>
<Tab.Screen
@@ -93,7 +98,7 @@ export function MainTabNavigator() {
options={{
headerTitle: 'SpamShield',
tabBarLabel: 'SpamShield',
tabBarIcon: ({ color }) => <TabIcon routeName="SpamShield" color={color} />,
tabBarIcon: ({ color, focused }) => <TabIcon routeName="SpamShield" color={color} focused={focused} />,
}}
/>
<Tab.Screen
@@ -102,7 +107,7 @@ export function MainTabNavigator() {
options={{
headerTitle: 'VoicePrint',
tabBarLabel: 'VoicePrint',
tabBarIcon: ({ color }) => <TabIcon routeName="VoicePrint" color={color} />,
tabBarIcon: ({ color, focused }) => <TabIcon routeName="VoicePrint" color={color} focused={focused} />,
}}
/>
<Tab.Screen
@@ -111,7 +116,7 @@ export function MainTabNavigator() {
options={{
headerTitle: 'Settings',
tabBarLabel: 'Settings',
tabBarIcon: ({ color }) => <TabIcon routeName="Settings" color={color} />,
tabBarIcon: ({ color, focused }) => <TabIcon routeName="Settings" color={color} focused={focused} />,
}}
/>
</Tab.Navigator>

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native';
import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useAuthStore } from '@/store/authStore';
import { Button, Input } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
@@ -8,15 +8,11 @@ import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
type RootStackParamList = {
Login: undefined;
Register: undefined;
App: undefined;
};
type LoginScreenRouteProp = RouteProp<RootStackParamList, 'Login'>;
export function LoginScreen() {
const route = useRoute<LoginScreenRouteProp>();
const navigation = useNavigation<any>();
const { login, isLoading, error, clearError } = useAuthStore();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { login, isLoading, clearError } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

View File

@@ -1,12 +1,17 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useAuthStore } from '@/store/authStore';
import { Button, Input } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
type RootStackParamList = {
Login: undefined;
Register: undefined;
};
export function RegisterScreen() {
const navigation = useNavigation<any>();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { register, isLoading } = useAuthStore();
const [firstName, setFirstName] = useState('');

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, FlatList, TouchableOpacity, Alert, Modal } from 'react-native';
import { useDarkWatchStore } from '@/store/darkWatchStore';
import { Card, Button, Input, EmptyState } from '@/components';
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS } from '@/constants/theme';
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS, getSeverityColor } from '@/constants/theme';
import type { WatchListItem } from '@/types';
export function DarkWatchScreen() {
@@ -178,15 +178,6 @@ export function DarkWatchScreen() {
);
}
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical': return COLORS.danger;
case 'high': return COLORS.warning;
case 'medium': return COLORS.accent;
default: return COLORS.textSecondary;
}
};
const styles = StyleSheet.create({
container: {
flex: 1,

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Alert } from 'react-native';
import { useSpamShieldStore } from '@/store/spamShieldStore';
import { Card, Button, Input, StatCard, EmptyState } from '@/components';
@@ -22,6 +22,15 @@ export function SpamShieldScreen() {
const [newNumber, setNewNumber] = useState('');
const [newLabel, setNewLabel] = useState('');
const blockedCount = useMemo(
() => callHistory.filter((r) => r.isBlocked).length,
[callHistory]
);
const totalCount = useMemo(
() => callHistory.length + textHistory.length,
[callHistory, textHistory]
);
const handleAddToList = (list: 'whitelist' | 'blacklist') => {
if (!newNumber) return;
@@ -62,8 +71,8 @@ export function SpamShieldScreen() {
<ScrollView style={styles.content}>
<Card>
<StatCard title="Blocked Today" value={callHistory.filter(r => r.isBlocked).length} color={COLORS.success} />
<StatCard title="Total Blocked" value={callHistory.length + textHistory.length} color={COLORS.accent} />
<StatCard title="Blocked Today" value={blockedCount} color={COLORS.success} />
<StatCard title="Total Blocked" value={totalCount} color={COLORS.accent} />
</Card>
<View style={styles.tabs}>

View File

@@ -1,8 +1,14 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { v4 as uuidv4 } from 'uuid';
import type { WatchListItem, Exposure } from '@/types';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
*/
interface DarkWatchState {
watchList: WatchListItem[];
exposures: Exposure[];
@@ -23,7 +29,7 @@ export const useDarkWatchStore = create<DarkWatchState>()(
addWatchItem: async (item) => {
const newItem: WatchListItem = {
...item,
id: crypto.randomUUID(),
id: uuidv4(),
lastChecked: new Date().toISOString(),
};
set((state) => ({ watchList: [...state.watchList, newItem] }));

View File

@@ -3,6 +3,11 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { NotificationPreference } from '@/types';
/**
* TODO: Wire updatePreferences to notificationService.updatePreferences() for production.
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
*/
interface SettingsState {
preferences: NotificationPreference;
isBiometricEnabled: boolean;

View File

@@ -3,6 +3,11 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { SpamRecord } from '@/types';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
*/
type PhoneList = { number: string; label: string }[];
interface SpamShieldState {

View File

@@ -1,8 +1,14 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { v4 as uuidv4 } from 'uuid';
import type { VoiceProfile, VoiceAnalysis } from '@/types';
/**
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
*/
interface VoicePrintState {
profiles: VoiceProfile[];
analyses: VoiceAnalysis[];
@@ -24,7 +30,7 @@ export const useVoicePrintStore = create<VoicePrintState>()(
addProfile: async (name: string, relationship: string) => {
const newProfile: VoiceProfile = {
id: crypto.randomUUID(),
id: uuidv4(),
name,
relationship,
enrolledAt: new Date().toISOString(),

View File

@@ -18,7 +18,8 @@
"noEmit": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"incremental": true
"incremental": true,
"types": ["react-native", "node"]
},
"include": [
"src/**/*",

File diff suppressed because one or more lines are too long