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:
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
1
packages/mobile/src/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare const __DEV__: boolean;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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] }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user