feat: scaffold ShieldAI React Native mobile app MVP (FRE-4572)

Build complete Expo/React Native mobile app with:
- Auth flow: email/password login, registration, biometric auth
- Dashboard: exposure summary, spam stats, voice protection status
- DarkWatch: watch list management, exposure feed, alert toggles
- SpamShield: call/text history, whitelist/blacklist management
- VoicePrint: family member enrollment, voice analysis
- Settings: tier management, notification preferences, security
- Push notification integration via FCM/APNs
- Offline-first state management with Zustand + AsyncStorage
- Integration with @shieldai/mobile-api-client for API services
- React Navigation with auth-aware routing (stack + bottom tabs)
- Dark theme with consistent design system (colors, spacing, typography)
- Network status monitoring and offline request queuing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-17 10:12:46 -04:00
parent 7fb8b83810
commit a071aa736e
50 changed files with 3026 additions and 13 deletions

View File

@@ -0,0 +1,146 @@
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 { useAuthStore } from '@/store/authStore';
import { Button, Input } from '@/components';
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 [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [formError, setFormError] = useState('');
const handleLogin = async () => {
setFormError('');
clearError();
if (!email || !password) {
setFormError('Please fill in all fields');
return;
}
try {
await login(email, password);
} catch (err: any) {
setFormError(err.message || 'Login failed. Please try again.');
Alert.alert('Login Failed', err.message || 'Please check your credentials and try again.');
}
};
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<Text style={styles.logo}>ShieldAI</Text>
<Text style={styles.tagline}>Your digital protection suite</Text>
</View>
<View style={styles.form}>
{formError && <Text style={styles.error}>{formError}</Text>}
<Input
label="Email"
placeholder="you@example.com"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
<Input
label="Password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
/>
<Button
title={isLoading ? 'Signing in...' : 'Sign In'}
onPress={handleLogin}
disabled={isLoading}
fullWidth
/>
<View style={styles.footer}>
<Text style={styles.footerText}>Don't have an account? </Text>
<Text style={styles.link} onPress={() => navigation.navigate('Register')}>
Sign Up
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
padding: SPACING.lg,
},
header: {
alignItems: 'center',
marginBottom: SPACING.xxl,
},
logo: {
color: COLORS.primary,
fontSize: FONT_SIZES.xxxl,
fontWeight: 'bold',
},
tagline: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.md,
marginTop: SPACING.sm,
},
form: {
width: '100%',
},
error: {
color: COLORS.danger,
fontSize: FONT_SIZES.sm,
marginBottom: SPACING.md,
textAlign: 'center',
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: SPACING.lg,
},
footerText: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
link: {
color: COLORS.primary,
fontSize: FONT_SIZES.sm,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useAuthStore } from '@/store/authStore';
import { Button, Input } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
export function RegisterScreen() {
const navigation = useNavigation<any>();
const { register, isLoading } = useAuthStore();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [formError, setFormError] = useState('');
const handleRegister = async () => {
setFormError('');
if (!firstName || !lastName || !email || !password) {
setFormError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setFormError('Passwords do not match');
return;
}
if (password.length < 8) {
setFormError('Password must be at least 8 characters');
return;
}
try {
await register(email, password, firstName, lastName);
} catch (err: any) {
setFormError(err.message || 'Registration failed. Please try again.');
Alert.alert('Registration Failed', err.message || 'Please try again with different credentials.');
}
};
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.header}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join ShieldAI today</Text>
</View>
<View style={styles.form}>
{formError && <Text style={styles.error}>{formError}</Text>}
<Input
label="First Name"
placeholder="John"
value={firstName}
onChangeText={setFirstName}
autoCapitalize="words"
/>
<Input
label="Last Name"
placeholder="Doe"
value={lastName}
onChangeText={setLastName}
autoCapitalize="words"
/>
<Input
label="Email"
placeholder="you@example.com"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
<Input
label="Password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
/>
<Input
label="Confirm Password"
placeholder="••••••••"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
autoComplete="password"
/>
<Button
title={isLoading ? 'Creating account...' : 'Create Account'}
onPress={handleRegister}
disabled={isLoading}
fullWidth
/>
<View style={styles.footer}>
<Text style={styles.footerText}>Already have an account? </Text>
<Text style={styles.link} onPress={() => navigation.navigate('Login')}>
Sign In
</Text>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
padding: SPACING.lg,
},
header: {
alignItems: 'center',
marginBottom: SPACING.xl,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.md,
marginTop: SPACING.xs,
},
form: {
width: '100%',
},
error: {
color: COLORS.danger,
fontSize: FONT_SIZES.sm,
marginBottom: SPACING.md,
textAlign: 'center',
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: SPACING.lg,
},
footerText: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
link: {
color: COLORS.primary,
fontSize: FONT_SIZES.sm,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,2 @@
export * from './LoginScreen';
export * from './RegisterScreen';

View File

@@ -0,0 +1,321 @@
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 type { WatchListItem } from '@/types';
export function DarkWatchScreen() {
const { watchList, exposures, addWatchItem, removeWatchItem, toggleAlert } = useDarkWatchStore();
const [showAddModal, setShowAddModal] = useState(false);
const [newItemName, setNewItemName] = useState('');
const [newItemValue, setNewItemValue] = useState('');
const [newItemType, setNewItemType] = useState<WatchListItem['entityType']>('person');
const handleAddItem = async () => {
if (!newItemName || !newItemValue) return;
await addWatchItem({
name: newItemName,
entityType: newItemType,
value: newItemValue,
alertEnabled: true,
});
setNewItemName('');
setNewItemValue('');
setShowAddModal(false);
};
const handleRemoveItem = (id: string, name: string) => {
Alert.alert(
'Remove Watch Item',
`Remove "${name}" from your watch list?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => removeWatchItem(id),
},
]
);
};
const renderItem = ({ item }: { item: WatchListItem }) => (
<View style={styles.watchItem}>
<View style={styles.watchItemLeft}>
<View style={[styles.entityBadge, { backgroundColor: COLORS.primary }]} />
<View style={styles.watchItemInfo}>
<Text style={styles.watchItemName}>{item.name}</Text>
<Text style={styles.watchItemValue}>{item.value}</Text>
</View>
</View>
<View style={styles.watchItemActions}>
<TouchableOpacity
style={[styles.alertToggle, { opacity: item.alertEnabled ? 1 : 0.4 }]}
onPress={() => toggleAlert(item.id)}
>
<Text style={styles.alertToggleText}>{item.alertEnabled ? '🔔' : '🔕'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.removeButton}
onPress={() => handleRemoveItem(item.id, item.name)}
>
<Text style={styles.removeButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>DarkWatch</Text>
<Text style={styles.subtitle}>Monitor your digital footprint</Text>
</View>
<ScrollView style={styles.content}>
<Card title="Watch List">
<Button
title="+ Add Watch Item"
onPress={() => setShowAddModal(true)}
variant="secondary"
fullWidth
/>
{watchList.length === 0 ? (
<EmptyState
title="No watch items yet"
message="Add people, emails, or phone numbers to monitor"
/>
) : (
<FlatList
data={watchList}
renderItem={renderItem}
keyExtractor={(item) => item.id}
scrollEnabled={false}
/>
)}
</Card>
<Card title="Recent Exposures">
{exposures.length === 0 ? (
<EmptyState title="No recent exposures" message="DarkWatch is monitoring for new data leaks" />
) : (
<FlatList
data={exposures}
keyExtractor={(item) => item.id}
scrollEnabled={false}
renderItem={({ item }) => (
<View style={styles.exposureItem}>
<Text style={styles.exposureSource}>{item.source}</Text>
<Text style={[styles.exposureSeverity, { color: getSeverityColor(item.severity) }]}>
{item.severity.toUpperCase()}
</Text>
</View>
)}
/>
)}
</Card>
</ScrollView>
<Modal visible={showAddModal} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Add Watch Item</Text>
<Input
label="Name"
placeholder="e.g., John Doe"
value={newItemName}
onChangeText={setNewItemName}
/>
<Input
label="Value"
placeholder={`Enter ${newItemType}`}
value={newItemValue}
onChangeText={setNewItemValue}
/>
<View style={styles.typeSelector}>
{(['person', 'email', 'phone', 'address'] as const).map((type) => (
<TouchableOpacity
key={type}
style={[
styles.typeButton,
{ backgroundColor: newItemType === type ? COLORS.primary : COLORS.card },
]}
onPress={() => setNewItemType(type)}
>
<Text style={[
styles.typeButtonText,
{ color: newItemType === type ? '#fff' : COLORS.textSecondary }
]}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.modalActions}>
<Button
title="Cancel"
onPress={() => setShowAddModal(false)}
variant="ghost"
/>
<Button
title="Add"
onPress={handleAddItem}
variant="primary"
/>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
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,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.xs,
},
content: {
flex: 1,
},
watchItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
watchItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
entityBadge: {
width: 36,
height: 36,
borderRadius: 18,
marginRight: SPACING.sm,
justifyContent: 'center',
alignItems: 'center',
},
watchItemInfo: {
flex: 1,
},
watchItemName: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
fontWeight: '500',
},
watchItemValue: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
watchItemActions: {
flexDirection: 'row',
gap: SPACING.sm,
},
alertToggle: {
padding: SPACING.xs,
},
alertToggleText: {
fontSize: 16,
},
removeButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: COLORS.cardLight,
justifyContent: 'center',
alignItems: 'center',
},
removeButtonText: {
color: COLORS.danger,
fontSize: 14,
},
exposureItem: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
exposureSource: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
exposureSeverity: {
fontSize: FONT_SIZES.xs,
fontWeight: '600',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: COLORS.backgroundLight,
borderTopLeftRadius: BORDER_RADIUS.xl,
borderTopRightRadius: BORDER_RADIUS.xl,
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
modalTitle: {
color: COLORS.text,
fontSize: FONT_SIZES.xl,
fontWeight: 'bold',
marginBottom: SPACING.md,
},
typeSelector: {
flexDirection: 'row',
gap: SPACING.sm,
marginBottom: SPACING.md,
},
typeButton: {
flex: 1,
paddingVertical: SPACING.sm,
borderRadius: BORDER_RADIUS.md,
alignItems: 'center',
},
typeButtonText: {
fontSize: FONT_SIZES.sm,
fontWeight: '500',
},
modalActions: {
flexDirection: 'row',
gap: SPACING.md,
marginTop: SPACING.md,
},
});

View File

@@ -0,0 +1 @@
export * from './DarkWatchScreen';

View File

@@ -0,0 +1,134 @@
import React, { useEffect } from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, RefreshControl } from 'react-native';
import { useDashboardStore } from '@/store/dashboardStore';
import { useAuthStore } from '@/store/authStore';
import { StatCard, Card, LoadingOverlay } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
export function DashboardScreen() {
const { data, isLoading, refreshDashboard } = useDashboardStore();
const { user } = useAuthStore();
useEffect(() => {
if (!data) {
refreshDashboard();
}
}, [data, refreshDashboard]);
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.greeting}>
Welcome back, {user?.firstName || 'User'}
</Text>
<Text style={styles.tier}>
{user?.tier ? user?.tier.charAt(0).toUpperCase() + user?.tier.slice(1) : 'Free'} Plan
</Text>
</View>
<ScrollView
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={refreshDashboard}
tintColor={COLORS.primary}
/>
}
>
<Card title="Exposure Summary">
<StatCard
title="Total Exposures"
value={data?.exposureSummary.total ?? 0}
color={COLORS.primary}
/>
<StatCard
title="Unresolved"
value={data?.exposureSummary.unresolved ?? 0}
color={COLORS.warning}
/>
<StatCard
title="Critical"
value={data?.exposureSummary.critical ?? 0}
color={COLORS.danger}
/>
</Card>
<Card title="SpamShield Stats">
<StatCard
title="Blocked Today"
value={data?.spamStats.blockedToday ?? 0}
color={COLORS.success}
/>
<StatCard
title="Total Blocked"
value={data?.spamStats.blockedTotal ?? 0}
color={COLORS.accent}
/>
<StatCard
title="Spam Score"
value={`${data?.spamStats.spamScore ?? 0}%`}
color={COLORS.secondary}
/>
</Card>
<Card title="VoicePrint Status">
<View style={styles.voiceStatus}>
<View style={styles.statusRow}>
<View style={[styles.statusDot, { backgroundColor: data?.voiceProtectionStatus.isMonitoring ? COLORS.success : COLORS.textMuted }]} />
<Text style={styles.statusText}>
{data?.voiceProtectionStatus.isMonitoring ? 'Monitoring Active' : 'Monitoring Inactive'}
</Text>
</View>
<StatCard
title="Profiles Enrolled"
value={data?.voiceProtectionStatus.profilesEnrolled ?? 0}
color={COLORS.accent}
/>
</View>
</Card>
</ScrollView>
<LoadingOverlay visible={isLoading} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
greeting: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
tier: {
color: COLORS.primary,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.xs,
},
voiceStatus: {
gap: SPACING.sm,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: SPACING.sm,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: SPACING.sm,
},
statusText: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
});

View File

@@ -0,0 +1 @@
export * from './DashboardScreen';

View File

@@ -0,0 +1,281 @@
import React from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Switch, Alert } from 'react-native';
import { useAuthStore } from '@/store/authStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBiometricAuth } from '@/hooks';
import { Card, Button } from '@/components';
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
import { TIER_FEATURES } from '@/constants/theme';
export function SettingsScreen() {
const { user, logout } = useAuthStore();
const { preferences, updatePreferences, isBiometricEnabled } = useSettingsStore();
const { enableBiometric, disableBiometric } = useBiometricAuth();
const handleLogout = () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: () => logout(),
},
]
);
};
const handleBiometricToggle = async (value: boolean) => {
if (value) {
const success = await enableBiometric();
if (!success) {
updatePreferences({});
}
} else {
disableBiometric();
}
};
const tier = user?.tier || 'free';
const features = TIER_FEATURES[tier as keyof typeof TIER_FEATURES];
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Settings</Text>
</View>
<ScrollView style={styles.content}>
<Card title="Account">
<View style={styles.profileRow}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{user?.firstName?.charAt(0) || 'U'}
</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>
{user?.firstName} {user?.lastName}
</Text>
<Text style={styles.profileEmail}>{user?.email}</Text>
</View>
</View>
</Card>
<Card title="Subscription">
<View style={styles.tierRow}>
<Text style={styles.tierLabel}>Current Plan</Text>
<View style={[styles.tierBadge, { backgroundColor: COLORS.primary }]}>
<Text style={styles.tierBadgeText}>{features.name}</Text>
</View>
</View>
<View style={styles.featuresList}>
<FeatureRow label="Spam Protection" available={features.spamProtection} />
<FeatureRow label="VoicePrint" available={features.voicePrint} />
<FeatureRow label="DarkWatch" available={features.darkWatch} />
<FeatureRow
label="Max Exposures/Month"
value={features.maxExposures === -1 ? 'Unlimited' : `${features.maxExposures}`}
/>
</View>
<Button title="Upgrade Plan" onPress={() => {}} variant="secondary" fullWidth />
</Card>
<Card title="Notifications">
<ToggleRow
label="Push Notifications"
value={preferences.pushNotifications}
onToggle={() => updatePreferences({ pushNotifications: !preferences.pushNotifications })}
/>
<ToggleRow
label="Email Notifications"
value={preferences.emailNotifications}
onToggle={() => updatePreferences({ emailNotifications: !preferences.emailNotifications })}
/>
<ToggleRow
label="DarkWatch Alerts"
value={preferences.darkwatchAlert}
onToggle={() => updatePreferences({ darkwatchAlert: !preferences.darkwatchAlert })}
/>
<ToggleRow
label="Spam Blocked Alerts"
value={preferences.spamBlocked}
onToggle={() => updatePreferences({ spamBlocked: !preferences.spamBlocked })}
/>
<ToggleRow
label="VoicePrint Analysis"
value={preferences.voiceprintAnalysis}
onToggle={() => updatePreferences({ voiceprintAnalysis: !preferences.voiceprintAnalysis })}
/>
</Card>
<Card title="Security">
<ToggleRow
label="Biometric Authentication"
value={isBiometricEnabled}
onToggle={handleBiometricToggle}
/>
</Card>
<View style={styles.logoutSection}>
<Button title="Logout" onPress={handleLogout} variant="danger" fullWidth />
</View>
<View style={styles.version}>
<Text style={styles.versionText}>ShieldAI v1.0.0</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
interface ToggleRowProps {
label: string;
value: boolean;
onToggle: (value: boolean) => void;
}
function ToggleRow({ label, value, onToggle }: ToggleRowProps) {
return (
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>{label}</Text>
<Switch
value={value}
onValueChange={onToggle}
trackColor={{ true: COLORS.primary, false: COLORS.border }}
thumbColor="#fff"
/>
</View>
);
}
interface FeatureRowProps {
label: string;
available?: boolean;
value?: string;
}
function FeatureRow({ label, available, value }: FeatureRowProps) {
return (
<View style={styles.featureRow}>
<Text style={styles.featureLabel}>{label}</Text>
<Text style={styles.featureValue}>
{value ?? (available !== undefined ? (available ? '✓' : '✕') : '')}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
content: {
flex: 1,
},
profileRow: {
flexDirection: 'row',
alignItems: 'center',
},
avatar: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.md,
},
avatarText: {
color: '#fff',
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
profileInfo: {
flex: 1,
},
profileName: {
color: COLORS.text,
fontSize: FONT_SIZES.lg,
fontWeight: '600',
},
profileEmail: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginTop: 2,
},
tierRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: SPACING.md,
},
tierLabel: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.md,
},
tierBadge: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
borderRadius: 16,
},
tierBadgeText: {
color: '#fff',
fontSize: FONT_SIZES.sm,
fontWeight: '600',
},
featuresList: {
gap: SPACING.sm,
},
featureRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: SPACING.xs,
},
featureLabel: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
featureValue: {
color: COLORS.text,
fontSize: FONT_SIZES.sm,
fontWeight: '500',
},
toggleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
toggleLabel: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
logoutSection: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.md,
},
version: {
alignItems: 'center',
paddingVertical: SPACING.lg,
},
versionText: {
color: COLORS.textMuted,
fontSize: FONT_SIZES.xs,
},
});

View File

@@ -0,0 +1 @@
export * from './SettingsScreen';

View File

@@ -0,0 +1,294 @@
import React, { useState } 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';
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS } from '@/constants/theme';
type TabType = 'history' | 'whitelist' | 'blacklist';
export function SpamShieldScreen() {
const {
callHistory,
textHistory,
whitelist,
blacklist,
addToWhitelist,
addToBlacklist,
removeFromWhitelist,
removeFromBlacklist,
} = useSpamShieldStore();
const [activeTab, setActiveTab] = useState<TabType>('history');
const [newNumber, setNewNumber] = useState('');
const [newLabel, setNewLabel] = useState('');
const handleAddToList = (list: 'whitelist' | 'blacklist') => {
if (!newNumber) return;
if (list === 'whitelist') {
addToWhitelist(newNumber, newLabel || 'Contact');
} else {
addToBlacklist(newNumber, newLabel || 'Blocked');
}
setNewNumber('');
setNewLabel('');
};
const handleRemoveFromList = (list: 'whitelist' | 'blacklist', number: string) => {
Alert.alert(
`Remove from ${list}`,
`Remove ${number} from your ${list}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => {
if (list === 'whitelist') removeFromWhitelist(number);
else removeFromBlacklist(number);
},
},
]
);
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>SpamShield</Text>
<Text style={styles.subtitle}>Call & text protection</Text>
</View>
<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} />
</Card>
<View style={styles.tabs}>
{(['history', 'whitelist', 'blacklist'] as const).map((tab) => (
<TouchableOpacity
key={tab}
style={[styles.tab, { backgroundColor: activeTab === tab ? COLORS.primary : 'transparent' }]}
onPress={() => setActiveTab(tab)}
>
<Text style={[
styles.tabText,
{ color: activeTab === tab ? '#fff' : COLORS.textSecondary }
]}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
{activeTab === 'history' && (
<Card title="Recent Activity">
{(callHistory.length === 0 && textHistory.length === 0) ? (
<EmptyState title="No call or text history" message="Blocked spam will appear here" />
) : (
<>
{callHistory.map((record) => (
<View key={record.id} style={styles.historyItem}>
<View style={styles.historyItemLeft}>
<Text style={styles.historyType}>{record.type.toUpperCase()}</Text>
<Text style={styles.historyNumber}>{record.phoneNumber}</Text>
</View>
<View style={[
styles.historyBadge,
{ backgroundColor: record.isBlocked ? COLORS.success : COLORS.warning }
]}>
<Text style={styles.historyBadgeText}>
{record.isBlocked ? 'BLOCKED' : 'ALLOWED'}
</Text>
</View>
</View>
))}
</>
)}
</Card>
)}
{activeTab === 'whitelist' && (
<Card title="Whitelist">
<View style={styles.addToList}>
<Input
placeholder="Phone number"
value={newNumber}
onChangeText={setNewNumber}
keyboardType="phone-pad"
/>
<Input
placeholder="Label (optional)"
value={newLabel}
onChangeText={setNewLabel}
/>
<Button
title="Add to Whitelist"
onPress={() => handleAddToList('whitelist')}
variant="primary"
fullWidth
/>
</View>
{whitelist.length === 0 ? (
<EmptyState title="Whitelist is empty" message="Add trusted contacts here" />
) : (
whitelist.map((item) => (
<View key={item.number} style={styles.listItem}>
<View style={styles.listItemLeft}>
<Text style={styles.listItemNumber}>{item.number}</Text>
<Text style={styles.listItemLabel}>{item.label}</Text>
</View>
<TouchableOpacity onPress={() => handleRemoveFromList('whitelist', item.number)}>
<Text style={styles.removeText}>Remove</Text>
</TouchableOpacity>
</View>
))
)}
</Card>
)}
{activeTab === 'blacklist' && (
<Card title="Blacklist">
<View style={styles.addToList}>
<Input
placeholder="Phone number"
value={newNumber}
onChangeText={setNewNumber}
keyboardType="phone-pad"
/>
<Input
placeholder="Label (optional)"
value={newLabel}
onChangeText={setNewLabel}
/>
<Button
title="Add to Blacklist"
onPress={() => handleAddToList('blacklist')}
variant="danger"
fullWidth
/>
</View>
{blacklist.length === 0 ? (
<EmptyState title="Blacklist is empty" message="Add numbers to block here" />
) : (
blacklist.map((item) => (
<View key={item.number} style={styles.listItem}>
<View style={styles.listItemLeft}>
<Text style={styles.listItemNumber}>{item.number}</Text>
<Text style={styles.listItemLabel}>{item.label}</Text>
</View>
<TouchableOpacity onPress={() => handleRemoveFromList('blacklist', item.number)}>
<Text style={styles.removeText}>Remove</Text>
</TouchableOpacity>
</View>
))
)}
</Card>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.xs,
},
content: {
flex: 1,
},
tabs: {
flexDirection: 'row',
marginHorizontal: SPACING.md,
marginVertical: SPACING.sm,
backgroundColor: COLORS.card,
borderRadius: BORDER_RADIUS.md,
padding: 4,
},
tab: {
flex: 1,
paddingVertical: SPACING.sm,
borderRadius: BORDER_RADIUS.sm,
alignItems: 'center',
},
tabText: {
fontSize: FONT_SIZES.sm,
fontWeight: '600',
},
historyItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
historyItemLeft: {
flex: 1,
},
historyType: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.xs,
textTransform: 'uppercase',
},
historyNumber: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
historyBadge: {
paddingHorizontal: SPACING.sm,
paddingVertical: 4,
borderRadius: BORDER_RADIUS.round,
},
historyBadgeText: {
color: '#fff',
fontSize: FONT_SIZES.xs,
fontWeight: '600',
},
addToList: {
marginBottom: SPACING.md,
},
listItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
listItemLeft: {
flex: 1,
},
listItemNumber: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
},
listItemLabel: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
removeText: {
color: COLORS.danger,
fontSize: FONT_SIZES.sm,
},
});

View File

@@ -0,0 +1 @@
export * from './SpamShieldScreen';

View File

@@ -0,0 +1,316 @@
import React, { useState } from 'react';
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Alert, Modal } from 'react-native';
import { useVoicePrintStore } from '@/store/voicePrintStore';
import { Card, Button, Input, EmptyState } from '@/components';
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS } from '@/constants/theme';
export function VoicePrintScreen() {
const { profiles, analyses, isRecording, addProfile, removeProfile, startRecording, stopRecording } = useVoicePrintStore();
const [showEnrollModal, setShowEnrollModal] = useState(false);
const [profileName, setProfileName] = useState('');
const [relationship, setRelationship] = useState('');
const handleEnroll = async () => {
if (!profileName || !relationship) return;
await addProfile(profileName, relationship);
setProfileName('');
setRelationship('');
setShowEnrollModal(false);
};
const handleRemoveProfile = (id: string, name: string) => {
Alert.alert(
'Remove Voice Profile',
`Remove voice profile for "${name}"? This cannot be undone.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => removeProfile(id),
},
]
);
};
const handleRecording = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>VoicePrint</Text>
<Text style={styles.subtitle}>Voice authentication & protection</Text>
</View>
<ScrollView style={styles.content}>
<Card title="Voice Profiles">
<Button
title="+ Enroll Family Member"
onPress={() => setShowEnrollModal(true)}
variant="secondary"
fullWidth
/>
{profiles.length === 0 ? (
<EmptyState
title="No voice profiles"
message="Enroll family members to protect against voice impersonation"
/>
) : (
profiles.map((profile) => (
<View key={profile.id} style={styles.profileItem}>
<View style={styles.profileItemLeft}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{profile.name.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{profile.name}</Text>
<Text style={styles.profileRelationship}>{profile.relationship}</Text>
</View>
</View>
<View style={styles.profileItemRight}>
<Text style={styles.confidence}>
{profile.confidence}% match
</Text>
<TouchableOpacity onPress={() => handleRemoveProfile(profile.id, profile.name)}>
<Text style={styles.removeText}>Remove</Text>
</TouchableOpacity>
</View>
</View>
))
)}
</Card>
<Card title="Voice Analysis">
<View style={styles.recordingSection}>
<TouchableOpacity
style={[
styles.recordButton,
{ backgroundColor: isRecording ? COLORS.danger : COLORS.primary },
]}
onPress={handleRecording}
>
<View style={[styles.recordDot, { backgroundColor: isRecording ? '#fff' : COLORS.card }]} />
<Text style={styles.recordButtonText}>
{isRecording ? 'Stop Recording' : 'Test Voice Match'}
</Text>
</TouchableOpacity>
<Text style={styles.recordingHint}>
{isRecording
? 'Recording... Speak clearly into the microphone'
: 'Tap to test voice matching against enrolled profiles'
}
</Text>
</View>
{analyses.length === 0 ? (
<EmptyState title="No analysis results" message="Record voice samples to see match results" />
) : (
analyses.map((analysis) => (
<View key={analysis.id} style={styles.analysisItem}>
<View style={styles.analysisLeft}>
<Text style={styles.analysisTime}>
{new Date(analysis.timestamp).toLocaleString()}
</Text>
<Text style={[
styles.analysisResult,
{ color: analysis.isMatch ? COLORS.success : COLORS.danger }
]}>
{analysis.isMatch ? 'Match' : 'No Match'} - {analysis.confidence}%
</Text>
</View>
</View>
))
)}
</Card>
</ScrollView>
<Modal visible={showEnrollModal} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Enroll Family Member</Text>
<Input
label="Name"
placeholder="e.g., Mom"
value={profileName}
onChangeText={setProfileName}
/>
<Input
label="Relationship"
placeholder="e.g., Mother"
value={relationship}
onChangeText={setRelationship}
/>
<View style={styles.modalActions}>
<Button
title="Cancel"
onPress={() => setShowEnrollModal(false)}
variant="ghost"
/>
<Button
title="Enroll"
onPress={handleEnroll}
variant="primary"
/>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
padding: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
title: {
color: COLORS.text,
fontSize: FONT_SIZES.xxl,
fontWeight: 'bold',
},
subtitle: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.xs,
},
content: {
flex: 1,
},
profileItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
profileItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
avatar: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
marginRight: SPACING.sm,
},
avatarText: {
color: '#fff',
fontSize: FONT_SIZES.md,
fontWeight: '600',
},
profileInfo: {
flex: 1,
},
profileName: {
color: COLORS.text,
fontSize: FONT_SIZES.md,
fontWeight: '500',
},
profileRelationship: {
color: COLORS.textSecondary,
fontSize: FONT_SIZES.sm,
},
profileItemRight: {
alignItems: 'flex-end',
gap: SPACING.xs,
},
confidence: {
color: COLORS.accent,
fontSize: FONT_SIZES.sm,
},
removeText: {
color: COLORS.danger,
fontSize: FONT_SIZES.sm,
},
recordingSection: {
alignItems: 'center',
paddingVertical: SPACING.md,
},
recordButton: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING.sm,
paddingVertical: SPACING.md,
paddingHorizontal: SPACING.xl,
borderRadius: BORDER_RADIUS.round,
},
recordDot: {
width: 12,
height: 12,
borderRadius: 6,
},
recordButtonText: {
color: '#fff',
fontSize: FONT_SIZES.md,
fontWeight: '600',
},
recordingHint: {
color: COLORS.textMuted,
fontSize: FONT_SIZES.sm,
marginTop: SPACING.sm,
textAlign: 'center',
},
analysisItem: {
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
analysisLeft: {
gap: 4,
},
analysisTime: {
color: COLORS.textMuted,
fontSize: FONT_SIZES.xs,
},
analysisResult: {
fontSize: FONT_SIZES.sm,
fontWeight: '500',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: COLORS.backgroundLight,
borderTopLeftRadius: BORDER_RADIUS.xl,
borderTopRightRadius: BORDER_RADIUS.xl,
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
modalTitle: {
color: COLORS.text,
fontSize: FONT_SIZES.xl,
fontWeight: 'bold',
marginBottom: SPACING.md,
},
modalActions: {
flexDirection: 'row',
gap: SPACING.md,
marginTop: SPACING.md,
},
});

View File

@@ -0,0 +1 @@
export * from './VoicePrintScreen';