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:
20
packages/mobile/.gitignore
vendored
Normal file
20
packages/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
.expo-shared/
|
||||
dist/
|
||||
ios/
|
||||
android/
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
*.pub
|
||||
.jscache
|
||||
*.log
|
||||
*.pid
|
||||
*.tgz
|
||||
*.npm
|
||||
*.lock
|
||||
.DS_Store
|
||||
.env
|
||||
29
packages/mobile/App.tsx
Normal file
29
packages/mobile/App.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { AuthNavigator } from '@/navigation/AuthNavigator';
|
||||
import { MainTabNavigator } from '@/navigation/MainTabNavigator';
|
||||
import { usePushNotifications } from '@/hooks';
|
||||
import '@/services/api';
|
||||
|
||||
export default function App() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { registerForPushNotifications } = usePushNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
registerForPushNotifications();
|
||||
}
|
||||
}, [isAuthenticated, registerForPushNotifications]);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<NavigationContainer>
|
||||
<StatusBar style="light" />
|
||||
{isAuthenticated ? <MainTabNavigator /> : <AuthNavigator />}
|
||||
</NavigationContainer>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
66
packages/mobile/app.json
Normal file
66
packages/mobile/app.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "ShieldAI",
|
||||
"slug": "shieldai",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "shieldai",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0e1a"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.frenocorp.shieldai",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "ShieldAI uses Face ID to securely access your account.",
|
||||
"NSCameraUsageDescription": "ShieldAI needs camera access for VoicePrint enrollment.",
|
||||
"NSMicrophoneUsageDescription": "ShieldAI needs microphone access for voice analysis."
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0a0e1a"
|
||||
},
|
||||
"package": "com.frenocorp.shieldai",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"VIBRATE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-secure-store",
|
||||
"expo-local-authentication",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/notification-icon.png",
|
||||
"color": "#4f8cff",
|
||||
"sounds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "shieldai-project-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
packages/mobile/assets/adaptive-icon.png
Normal file
BIN
packages/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/mobile/assets/favicon.png
Normal file
BIN
packages/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/mobile/assets/icon.png
Normal file
BIN
packages/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/mobile/assets/notification-icon.png
Normal file
BIN
packages/mobile/assets/notification-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/mobile/assets/splash.png
Normal file
BIN
packages/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
9
packages/mobile/babel.config.js
Normal file
9
packages/mobile/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
'react-native-reanimated/plugin',
|
||||
],
|
||||
};
|
||||
};
|
||||
8
packages/mobile/metro.config.js
Normal file
8
packages/mobile/metro.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
config.resolver.sourceExts = ['js', 'jsx', 'ts', 'tsx', 'json'];
|
||||
config.watchFolders = [__dirname];
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,22 +1,61 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"version": "0.1.0",
|
||||
"name": "@shieldai/mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src/"
|
||||
"dev": "expo start",
|
||||
"dev:ios": "expo run:ios",
|
||||
"dev:android": "expo run:android",
|
||||
"build": "expo build",
|
||||
"build:ios": "expo build:ios",
|
||||
"build:android": "expo build:android",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"clean": "rm -rf node_modules .expo .expo-shared"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.8.14",
|
||||
"@shieldsai/shared-auth": "workspace:*",
|
||||
"@shieldsai/shared-ui": "workspace:*",
|
||||
"@shieldsai/shared-utils": "workspace:*"
|
||||
"@shieldai/mobile-api-client": "workspace:*",
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"@react-navigation/stack": "^6.3.0",
|
||||
"expo": "~51.0.0",
|
||||
"expo-av": "~14.0.0",
|
||||
|
||||
"expo-constants": "~16.0.0",
|
||||
"expo-crypto": "~13.0.0",
|
||||
"expo-device": "~6.0.0",
|
||||
"expo-file-system": "~17.0.0",
|
||||
"expo-image-picker": "~15.0.0",
|
||||
"expo-linking": "~6.3.0",
|
||||
"expo-local-authentication": "~14.0.0",
|
||||
"expo-notifications": "~0.28.0",
|
||||
"expo-secure-store": "~13.0.0",
|
||||
"expo-status-bar": "~1.12.0",
|
||||
"expo-updates": "~0.18.0",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-gesture-handler": "~2.16.0",
|
||||
"react-native-reanimated": "~3.10.0",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "3.31.0",
|
||||
"react-native-svg": "15.2.0",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"@types/node": "^25.6.0"
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-test-renderer": "^18.0.0",
|
||||
"babel-preset-expo": "^11.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-native": "^4.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
65
packages/mobile/src/components/Button.tsx
Normal file
65
packages/mobile/src/components/Button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { COLORS, BORDER_RADIUS, FONT_SIZES } from '@/constants/theme';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
}: ButtonProps) {
|
||||
const variantColors = {
|
||||
primary: { bg: COLORS.primary, text: '#fff' },
|
||||
secondary: { bg: COLORS.secondary, text: '#fff' },
|
||||
danger: { bg: COLORS.danger, text: '#fff' },
|
||||
ghost: { bg: 'transparent', text: COLORS.primary },
|
||||
};
|
||||
|
||||
const colors = variantColors[variant];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: colors.bg, opacity: disabled || loading ? 0.5 : 1 },
|
||||
fullWidth && styles.fullWidth,
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.text, { color: colors.text }]}>
|
||||
{loading ? '...' : title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginVertical: 4,
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
text: {
|
||||
fontSize: FONT_SIZES.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
69
packages/mobile/src/components/Card.tsx
Normal file
69
packages/mobile/src/components/Card.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View, ViewStyle } from 'react-native';
|
||||
import { COLORS, BORDER_RADIUS, FONT_SIZES, SPACING } from '@/constants/theme';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
action?: string;
|
||||
onActionPress?: () => void;
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, action, onActionPress }: SectionHeaderProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{action && onActionPress && (
|
||||
<Text style={styles.action} onPress={onActionPress}>
|
||||
{action}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
export function Card({ title, children, style }: CardProps) {
|
||||
return (
|
||||
<View style={[styles.card, style]}>
|
||||
{title && <Text style={styles.cardTitle}>{title}</Text>}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING.md,
|
||||
paddingVertical: SPACING.sm,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: '600',
|
||||
},
|
||||
action: {
|
||||
color: COLORS.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: COLORS.card,
|
||||
borderRadius: BORDER_RADIUS.lg,
|
||||
padding: SPACING.md,
|
||||
marginHorizontal: SPACING.md,
|
||||
marginVertical: SPACING.xs,
|
||||
},
|
||||
cardTitle: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
fontWeight: '600',
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
});
|
||||
51
packages/mobile/src/components/Input.tsx
Normal file
51
packages/mobile/src/components/Input.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, TextInput, TextInputProps, Text, View } from 'react-native';
|
||||
import { COLORS, BORDER_RADIUS, FONT_SIZES } from '@/constants/theme';
|
||||
|
||||
interface InputProps extends Omit<TextInputProps, 'style'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
containerStyle?: object;
|
||||
}
|
||||
|
||||
export function Input({ label, error, containerStyle, ...props }: InputProps) {
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
error ? { borderColor: COLORS.danger } : null,
|
||||
]}
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
{...props}
|
||||
/>
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: COLORS.backgroundLight,
|
||||
borderColor: COLORS.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
padding: 12,
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
},
|
||||
error: {
|
||||
color: COLORS.danger,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
73
packages/mobile/src/components/Loading.tsx
Normal file
73
packages/mobile/src/components/Loading.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, ActivityIndicator, Text } from 'react-native';
|
||||
import { COLORS, SPACING } from '@/constants/theme';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<ActivityIndicator size="large" color={COLORS.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, message }: EmptyStateProps) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyContent}>
|
||||
<View style={styles.emptyIcon} />
|
||||
<View style={styles.emptyText}>
|
||||
<Text style={styles.emptyTitle}>{title}</Text>
|
||||
{message && <Text style={styles.emptyMessage}>{message}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.xl,
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: COLORS.card,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
emptyText: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyTitle: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyMessage: {
|
||||
color: COLORS.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
45
packages/mobile/src/components/StatCard.tsx
Normal file
45
packages/mobile/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, color = COLORS.primary, subtitle }: StatCardProps) {
|
||||
return (
|
||||
<View style={[styles.card, { borderLeftColor: color }]}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={[styles.value, { color }]}>{value}</Text>
|
||||
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.card,
|
||||
borderRadius: 8,
|
||||
padding: SPACING.md,
|
||||
marginBottom: SPACING.sm,
|
||||
borderLeftWidth: 3,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginBottom: SPACING.xs,
|
||||
},
|
||||
value: {
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.textMuted,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginTop: SPACING.xs,
|
||||
},
|
||||
});
|
||||
5
packages/mobile/src/components/index.ts
Normal file
5
packages/mobile/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './Button';
|
||||
export * from './Input';
|
||||
export * from './StatCard';
|
||||
export * from './Card';
|
||||
export * from './Loading';
|
||||
11
packages/mobile/src/constants/index.ts
Normal file
11
packages/mobile/src/constants/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const TAB_ORDER = ['Dashboard', 'DarkWatch', 'SpamShield', 'VoicePrint', 'Settings'] as const;
|
||||
|
||||
export const AUTH_STORAGE_KEY = '@shieldai_auth';
|
||||
export const BIOMETRIC_ENABLED_KEY = '@shieldai_biometric';
|
||||
export const ONBOARDING_COMPLETE_KEY = '@shieldai_onboarding';
|
||||
|
||||
export const NOTIFICATION_TYPES = {
|
||||
DARKWATCH_ALERT: 'darkwatch_alert',
|
||||
SPAM_BLOCKED: 'spam_blocked',
|
||||
VOICEPRINT_ANALYSIS: 'voiceprint_analysis',
|
||||
} as const;
|
||||
84
packages/mobile/src/constants/theme.ts
Normal file
84
packages/mobile/src/constants/theme.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export const COLORS = {
|
||||
primary: '#4f8cff',
|
||||
primaryDark: '#3a6fd8',
|
||||
secondary: '#6c5ce7',
|
||||
accent: '#00cec9',
|
||||
success: '#00b894',
|
||||
warning: '#fdcb6e',
|
||||
danger: '#ff6b6b',
|
||||
background: '#0a0e1a',
|
||||
backgroundLight: '#111827',
|
||||
card: '#1a2035',
|
||||
cardLight: '#243049',
|
||||
text: '#e8eaf0',
|
||||
textSecondary: '#8b95a8',
|
||||
textMuted: '#5a6577',
|
||||
border: '#2a3550',
|
||||
overlay: 'rgba(0, 0, 0, 0.6)',
|
||||
} as const;
|
||||
|
||||
export const SPACING = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
} as const;
|
||||
|
||||
export const FONT_SIZES = {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
xxl: 24,
|
||||
xxxl: 32,
|
||||
} as const;
|
||||
|
||||
export const BORDER_RADIUS = {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
round: 999,
|
||||
} as const;
|
||||
|
||||
export const SHADOWS = {
|
||||
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
|
||||
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 },
|
||||
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8 },
|
||||
} as const;
|
||||
|
||||
export const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.shieldai.freno.me/api/v1';
|
||||
|
||||
export const TIER_FEATURES = {
|
||||
free: {
|
||||
name: 'Free',
|
||||
maxExposures: 5,
|
||||
spamProtection: false,
|
||||
voicePrint: false,
|
||||
darkWatch: false,
|
||||
},
|
||||
basic: {
|
||||
name: 'Basic',
|
||||
maxExposures: 50,
|
||||
spamProtection: true,
|
||||
voicePrint: false,
|
||||
darkWatch: true,
|
||||
},
|
||||
premium: {
|
||||
name: 'Premium',
|
||||
maxExposures: 200,
|
||||
spamProtection: true,
|
||||
voicePrint: true,
|
||||
darkWatch: true,
|
||||
},
|
||||
enterprise: {
|
||||
name: 'Enterprise',
|
||||
maxExposures: -1,
|
||||
spamProtection: true,
|
||||
voicePrint: true,
|
||||
darkWatch: true,
|
||||
},
|
||||
} as const;
|
||||
3
packages/mobile/src/hooks/index.ts
Normal file
3
packages/mobile/src/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './usePushNotifications';
|
||||
export * from './useBiometricAuth';
|
||||
export * from './useNetworkStatus';
|
||||
53
packages/mobile/src/hooks/useBiometricAuth.ts
Normal file
53
packages/mobile/src/hooks/useBiometricAuth.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from 'react';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
|
||||
export function useBiometricAuth() {
|
||||
const { isBiometricEnabled } = useSettingsStore();
|
||||
|
||||
const authenticate = useCallback(async (): Promise<boolean> => {
|
||||
if (!isBiometricEnabled) return true;
|
||||
|
||||
try {
|
||||
const isAvailable = await LocalAuthentication.hasHardwareAsync();
|
||||
if (!isAvailable) return true;
|
||||
|
||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
if (!isEnrolled) return true;
|
||||
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: 'Authenticate with biometrics',
|
||||
fallbackLabel: 'Use passcode',
|
||||
cancelLabel: 'Cancel',
|
||||
});
|
||||
|
||||
return result.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [isBiometricEnabled]);
|
||||
|
||||
const enableBiometric = useCallback(async (): Promise<boolean> => {
|
||||
const isAvailable = await LocalAuthentication.hasHardwareAsync();
|
||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
|
||||
if (!isAvailable || !isEnrolled) return false;
|
||||
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: 'Enable biometric authentication',
|
||||
fallbackLabel: 'Use passcode',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
useSettingsStore.getState().toggleBiometric(true);
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}, []);
|
||||
|
||||
const disableBiometric = useCallback(() => {
|
||||
useSettingsStore.getState().toggleBiometric(false);
|
||||
}, []);
|
||||
|
||||
return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled };
|
||||
}
|
||||
18
packages/mobile/src/hooks/useNetworkStatus.ts
Normal file
18
packages/mobile/src/hooks/useNetworkStatus.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
|
||||
export function useNetworkStatus() {
|
||||
const [isConnected, setIsConnected] = useState(true);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
setIsConnected(!!state.isConnected);
|
||||
setIsOnline(!!state.isInternetReachable);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return { isConnected, isOnline, isOffline: !isOnline };
|
||||
}
|
||||
65
packages/mobile/src/hooks/usePushNotifications.ts
Normal file
65
packages/mobile/src/hooks/usePushNotifications.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
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 registerForPushNotifications = useCallback(async () => {
|
||||
try {
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
|
||||
if (existingStatus !== 'granted') {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
|
||||
if (finalStatus !== 'granted') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = (await Notifications.getExpoPushTokenAsync({
|
||||
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',
|
||||
});
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('Failed to register for push notifications:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Notifications.addNotificationReceivedListener((notification) => {
|
||||
const type = notification.request.content.data?.type;
|
||||
const prefs = useSettingsStore.getState().preferences;
|
||||
|
||||
if (type === 'darkwatch_alert' && !prefs.darkwatchAlert) return;
|
||||
if (type === 'spam_blocked' && !prefs.spamBlocked) return;
|
||||
if (type === 'voiceprint_analysis' && !prefs.voiceprintAnalysis) return;
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
return { registerForPushNotifications };
|
||||
}
|
||||
24
packages/mobile/src/navigation/AuthNavigator.tsx
Normal file
24
packages/mobile/src/navigation/AuthNavigator.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { LoginScreen, RegisterScreen } from '@/screens/auth';
|
||||
|
||||
type AuthStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
};
|
||||
|
||||
const Stack = createStackNavigator<AuthStackParamList>();
|
||||
|
||||
export function AuthNavigator() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
cardStyle: { backgroundColor: '#0a0e1a' },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
119
packages/mobile/src/navigation/MainTabNavigator.tsx
Normal file
119
packages/mobile/src/navigation/MainTabNavigator.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Text, ViewStyle, StyleSheet } from 'react-native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { DashboardScreen } from '@/screens/dashboard';
|
||||
import { DarkWatchScreen } from '@/screens/darkwatch';
|
||||
import { SpamShieldScreen } from '@/screens/spamshield';
|
||||
import { VoicePrintScreen } from '@/screens/voiceprint';
|
||||
import { SettingsScreen } from '@/screens/settings';
|
||||
import { COLORS, FONT_SIZES } from '@/constants/theme';
|
||||
|
||||
type MainTabParamList = {
|
||||
Dashboard: undefined;
|
||||
DarkWatch: undefined;
|
||||
SpamShield: undefined;
|
||||
VoicePrint: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
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}',
|
||||
};
|
||||
|
||||
function TabIcon({ routeName, color }: { routeName: string; color: string }) {
|
||||
return (
|
||||
<Text style={[styles.icon, { color }]}>{iconMap[routeName] || '\u{2022}'}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
icon: {
|
||||
fontSize: 22,
|
||||
},
|
||||
});
|
||||
|
||||
export function MainTabNavigator() {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
headerTintColor: COLORS.text,
|
||||
headerTitleStyle: {
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: COLORS.backgroundLight,
|
||||
borderTopColor: COLORS.border,
|
||||
borderTopWidth: 1,
|
||||
height: 60,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
} as ViewStyle,
|
||||
tabBarActiveTintColor: COLORS.primary,
|
||||
tabBarInactiveTintColor: COLORS.textMuted,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
tabBarIconStyle: {
|
||||
marginTop: 4,
|
||||
},
|
||||
tabBarShowLabel: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Dashboard"
|
||||
component={DashboardScreen}
|
||||
options={{
|
||||
headerTitle: 'Dashboard',
|
||||
tabBarLabel: 'Home',
|
||||
tabBarIcon: ({ color }) => <TabIcon routeName="Dashboard" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="DarkWatch"
|
||||
component={DarkWatchScreen}
|
||||
options={{
|
||||
headerTitle: 'DarkWatch',
|
||||
tabBarLabel: 'DarkWatch',
|
||||
tabBarIcon: ({ color }) => <TabIcon routeName="DarkWatch" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="SpamShield"
|
||||
component={SpamShieldScreen}
|
||||
options={{
|
||||
headerTitle: 'SpamShield',
|
||||
tabBarLabel: 'SpamShield',
|
||||
tabBarIcon: ({ color }) => <TabIcon routeName="SpamShield" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="VoicePrint"
|
||||
component={VoicePrintScreen}
|
||||
options={{
|
||||
headerTitle: 'VoicePrint',
|
||||
tabBarLabel: 'VoicePrint',
|
||||
tabBarIcon: ({ color }) => <TabIcon routeName="VoicePrint" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
headerTitle: 'Settings',
|
||||
tabBarLabel: 'Settings',
|
||||
tabBarIcon: ({ color }) => <TabIcon routeName="Settings" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
2
packages/mobile/src/navigation/index.ts
Normal file
2
packages/mobile/src/navigation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AuthNavigator';
|
||||
export * from './MainTabNavigator';
|
||||
146
packages/mobile/src/screens/auth/LoginScreen.tsx
Normal file
146
packages/mobile/src/screens/auth/LoginScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
173
packages/mobile/src/screens/auth/RegisterScreen.tsx
Normal file
173
packages/mobile/src/screens/auth/RegisterScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
2
packages/mobile/src/screens/auth/index.ts
Normal file
2
packages/mobile/src/screens/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './LoginScreen';
|
||||
export * from './RegisterScreen';
|
||||
321
packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx
Normal file
321
packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/darkwatch/index.ts
Normal file
1
packages/mobile/src/screens/darkwatch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DarkWatchScreen';
|
||||
134
packages/mobile/src/screens/dashboard/DashboardScreen.tsx
Normal file
134
packages/mobile/src/screens/dashboard/DashboardScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/dashboard/index.ts
Normal file
1
packages/mobile/src/screens/dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DashboardScreen';
|
||||
281
packages/mobile/src/screens/settings/SettingsScreen.tsx
Normal file
281
packages/mobile/src/screens/settings/SettingsScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/settings/index.ts
Normal file
1
packages/mobile/src/screens/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SettingsScreen';
|
||||
294
packages/mobile/src/screens/spamshield/SpamShieldScreen.tsx
Normal file
294
packages/mobile/src/screens/spamshield/SpamShieldScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/spamshield/index.ts
Normal file
1
packages/mobile/src/screens/spamshield/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SpamShieldScreen';
|
||||
316
packages/mobile/src/screens/voiceprint/VoicePrintScreen.tsx
Normal file
316
packages/mobile/src/screens/voiceprint/VoicePrintScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/voiceprint/index.ts
Normal file
1
packages/mobile/src/screens/voiceprint/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './VoicePrintScreen';
|
||||
8
packages/mobile/src/services/api.ts
Normal file
8
packages/mobile/src/services/api.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApiClient } from '@shieldai/mobile-api-client';
|
||||
import { API_URL } from '@/constants/theme';
|
||||
|
||||
createApiClient({
|
||||
baseURL: API_URL,
|
||||
timeout: 30000,
|
||||
debug: __DEV__,
|
||||
});
|
||||
1
packages/mobile/src/services/index.ts
Normal file
1
packages/mobile/src/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './api';
|
||||
108
packages/mobile/src/store/authStore.ts
Normal file
108
packages/mobile/src/store/authStore.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { User } from '@/types';
|
||||
import { authService } from '@shieldai/mobile-api-client';
|
||||
import { AUTH_STORAGE_KEY } from '@/constants';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, firstName: string, lastName: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
setUser: (user: User | null) => void;
|
||||
}
|
||||
|
||||
const toAppUser = (apiUser: any): User => ({
|
||||
id: apiUser.id,
|
||||
email: apiUser.email,
|
||||
firstName: apiUser.firstName || '',
|
||||
lastName: apiUser.lastName || '',
|
||||
tier: (apiUser.tier as User['tier']) || 'free',
|
||||
createdAt: apiUser.createdAt || new Date().toISOString(),
|
||||
});
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { user: apiUser, tokens } = await authService.login({ email, password });
|
||||
set({
|
||||
user: toAppUser(apiUser),
|
||||
accessToken: tokens.accessToken,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
set({
|
||||
error: err.message || 'Login failed',
|
||||
isLoading: false,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (email: string, password: string, firstName: string, lastName: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { user: apiUser, tokens } = await authService.register({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
set({
|
||||
user: toAppUser(apiUser),
|
||||
accessToken: tokens.accessToken,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
set({
|
||||
error: err.message || 'Registration failed',
|
||||
isLoading: false,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} finally {
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
}),
|
||||
{
|
||||
name: AUTH_STORAGE_KEY,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
accessToken: state.accessToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
60
packages/mobile/src/store/darkWatchStore.ts
Normal file
60
packages/mobile/src/store/darkWatchStore.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { WatchListItem, Exposure } from '@/types';
|
||||
|
||||
interface DarkWatchState {
|
||||
watchList: WatchListItem[];
|
||||
exposures: Exposure[];
|
||||
isLoading: boolean;
|
||||
addWatchItem: (item: Omit<WatchListItem, 'id' | 'lastChecked'>) => Promise<void>;
|
||||
removeWatchItem: (id: string) => void;
|
||||
toggleAlert: (id: string) => void;
|
||||
refreshExposures: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useDarkWatchStore = create<DarkWatchState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
watchList: [],
|
||||
exposures: [],
|
||||
isLoading: false,
|
||||
|
||||
addWatchItem: async (item) => {
|
||||
const newItem: WatchListItem = {
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
set((state) => ({ watchList: [...state.watchList, newItem] }));
|
||||
},
|
||||
|
||||
removeWatchItem: (id) => {
|
||||
set((state) => ({
|
||||
watchList: state.watchList.filter((item) => item.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleAlert: (id) => {
|
||||
set((state) => ({
|
||||
watchList: state.watchList.map((item) =>
|
||||
item.id === id ? { ...item, alertEnabled: !item.alertEnabled } : item
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
refreshExposures: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
set({ isLoading: false });
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_darkwatch',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
46
packages/mobile/src/store/dashboardStore.ts
Normal file
46
packages/mobile/src/store/dashboardStore.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { DashboardData } from '@/types';
|
||||
|
||||
interface DashboardState {
|
||||
data: DashboardData | null;
|
||||
isLoading: boolean;
|
||||
lastUpdated: string | null;
|
||||
refreshDashboard: () => Promise<void>;
|
||||
setData: (data: DashboardData) => void;
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
lastUpdated: null,
|
||||
|
||||
refreshDashboard: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const mockData: DashboardData = {
|
||||
exposureSummary: { total: 0, unresolved: 0, critical: 0 },
|
||||
spamStats: { blockedToday: 0, blockedTotal: 0, spamScore: 0 },
|
||||
voiceProtectionStatus: { isMonitoring: false, profilesEnrolled: 0, lastAnalysis: '' },
|
||||
};
|
||||
set({
|
||||
data: mockData,
|
||||
isLoading: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setData: (data) => set({ data, lastUpdated: new Date().toISOString() }),
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_dashboard',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
40
packages/mobile/src/store/settingsStore.ts
Normal file
40
packages/mobile/src/store/settingsStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { NotificationPreference } from '@/types';
|
||||
|
||||
interface SettingsState {
|
||||
preferences: NotificationPreference;
|
||||
isBiometricEnabled: boolean;
|
||||
updatePreferences: (prefs: Partial<NotificationPreference>) => void;
|
||||
toggleBiometric: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const defaultPreferences: NotificationPreference = {
|
||||
emailNotifications: true,
|
||||
pushNotifications: true,
|
||||
darkwatchAlert: true,
|
||||
spamBlocked: true,
|
||||
voiceprintAnalysis: true,
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
preferences: defaultPreferences,
|
||||
isBiometricEnabled: false,
|
||||
|
||||
updatePreferences: (prefs) => {
|
||||
set((state) => ({
|
||||
preferences: { ...state.preferences, ...prefs },
|
||||
}));
|
||||
},
|
||||
|
||||
toggleBiometric: (enabled) => set({ isBiometricEnabled: enabled }),
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_settings',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
58
packages/mobile/src/store/spamShieldStore.ts
Normal file
58
packages/mobile/src/store/spamShieldStore.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { SpamRecord } from '@/types';
|
||||
|
||||
type PhoneList = { number: string; label: string }[];
|
||||
|
||||
interface SpamShieldState {
|
||||
callHistory: SpamRecord[];
|
||||
textHistory: SpamRecord[];
|
||||
whitelist: PhoneList;
|
||||
blacklist: PhoneList;
|
||||
isLoading: false;
|
||||
addToWhitelist: (number: string, label: string) => void;
|
||||
addToBlacklist: (number: string, label: string) => void;
|
||||
removeFromWhitelist: (number: string) => void;
|
||||
removeFromBlacklist: (number: string) => void;
|
||||
}
|
||||
|
||||
export const useSpamShieldStore = create<SpamShieldState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
callHistory: [],
|
||||
textHistory: [],
|
||||
whitelist: [],
|
||||
blacklist: [],
|
||||
isLoading: false,
|
||||
|
||||
addToWhitelist: (number, label) => {
|
||||
set((state) => ({
|
||||
whitelist: [...state.whitelist, { number, label }],
|
||||
}));
|
||||
},
|
||||
|
||||
addToBlacklist: (number, label) => {
|
||||
set((state) => ({
|
||||
blacklist: [...state.blacklist, { number, label }],
|
||||
}));
|
||||
},
|
||||
|
||||
removeFromWhitelist: (number) => {
|
||||
set((state) => ({
|
||||
whitelist: state.whitelist.filter((item) => item.number !== number),
|
||||
}));
|
||||
},
|
||||
|
||||
removeFromBlacklist: (number) => {
|
||||
set((state) => ({
|
||||
blacklist: state.blacklist.filter((item) => item.number !== number),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_spamshield',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
50
packages/mobile/src/store/voicePrintStore.ts
Normal file
50
packages/mobile/src/store/voicePrintStore.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { VoiceProfile, VoiceAnalysis } from '@/types';
|
||||
|
||||
interface VoicePrintState {
|
||||
profiles: VoiceProfile[];
|
||||
analyses: VoiceAnalysis[];
|
||||
isRecording: boolean;
|
||||
isLoading: boolean;
|
||||
addProfile: (name: string, relationship: string) => Promise<void>;
|
||||
removeProfile: (id: string) => void;
|
||||
startRecording: () => void;
|
||||
stopRecording: () => void;
|
||||
}
|
||||
|
||||
export const useVoicePrintStore = create<VoicePrintState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
profiles: [],
|
||||
analyses: [],
|
||||
isRecording: false,
|
||||
isLoading: false,
|
||||
|
||||
addProfile: async (name: string, relationship: string) => {
|
||||
const newProfile: VoiceProfile = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
relationship,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
confidence: 0,
|
||||
};
|
||||
set((state) => ({ profiles: [...state.profiles, newProfile] }));
|
||||
},
|
||||
|
||||
removeProfile: (id) => {
|
||||
set((state) => ({
|
||||
profiles: state.profiles.filter((p) => p.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
startRecording: () => set({ isRecording: true }),
|
||||
stopRecording: () => set({ isRecording: false }),
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_voiceprint',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
79
packages/mobile/src/types/index.ts
Normal file
79
packages/mobile/src/types/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export type Tier = 'free' | 'basic' | 'premium' | 'enterprise';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
tier: Tier;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Exposure {
|
||||
id: string;
|
||||
source: string;
|
||||
data: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
discoveredAt: string;
|
||||
isResolved: boolean;
|
||||
}
|
||||
|
||||
export interface SpamRecord {
|
||||
id: string;
|
||||
type: 'call' | 'text';
|
||||
phoneNumber: string;
|
||||
timestamp: string;
|
||||
isBlocked: boolean;
|
||||
spamScore: number;
|
||||
}
|
||||
|
||||
export interface WatchListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
entityType: 'person' | 'email' | 'phone' | 'address';
|
||||
value: string;
|
||||
alertEnabled: boolean;
|
||||
lastChecked: string;
|
||||
}
|
||||
|
||||
export interface VoiceProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
relationship: string;
|
||||
enrolledAt: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface VoiceAnalysis {
|
||||
id: string;
|
||||
profileId: string;
|
||||
isMatch: boolean;
|
||||
confidence: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
emailNotifications: boolean;
|
||||
pushNotifications: boolean;
|
||||
darkwatchAlert: boolean;
|
||||
spamBlocked: boolean;
|
||||
voiceprintAnalysis: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
exposureSummary: {
|
||||
total: number;
|
||||
unresolved: number;
|
||||
critical: number;
|
||||
};
|
||||
spamStats: {
|
||||
blockedToday: number;
|
||||
blockedTotal: number;
|
||||
spamScore: number;
|
||||
};
|
||||
voiceProtectionStatus: {
|
||||
isMonitoring: boolean;
|
||||
profilesEnrolled: number;
|
||||
lastAnalysis: string;
|
||||
};
|
||||
}
|
||||
32
packages/mobile/tsconfig.json
Normal file
32
packages/mobile/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react-native",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": ["es2019"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"incremental": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"App.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"build",
|
||||
".expo"
|
||||
]
|
||||
}
|
||||
1
packages/mobile/tsconfig.tsbuildinfo
Normal file
1
packages/mobile/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user