diff --git a/packages/mobile/.gitignore b/packages/mobile/.gitignore new file mode 100644 index 0000000..2799b87 --- /dev/null +++ b/packages/mobile/.gitignore @@ -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 diff --git a/packages/mobile/App.tsx b/packages/mobile/App.tsx new file mode 100644 index 0000000..29c53ab --- /dev/null +++ b/packages/mobile/App.tsx @@ -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 ( + + + + {isAuthenticated ? : } + + + ); +} diff --git a/packages/mobile/app.json b/packages/mobile/app.json new file mode 100644 index 0000000..5615752 --- /dev/null +++ b/packages/mobile/app.json @@ -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" + } + } + } +} diff --git a/packages/mobile/assets/adaptive-icon.png b/packages/mobile/assets/adaptive-icon.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/packages/mobile/assets/adaptive-icon.png differ diff --git a/packages/mobile/assets/favicon.png b/packages/mobile/assets/favicon.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/packages/mobile/assets/favicon.png differ diff --git a/packages/mobile/assets/icon.png b/packages/mobile/assets/icon.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/packages/mobile/assets/icon.png differ diff --git a/packages/mobile/assets/notification-icon.png b/packages/mobile/assets/notification-icon.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/packages/mobile/assets/notification-icon.png differ diff --git a/packages/mobile/assets/splash.png b/packages/mobile/assets/splash.png new file mode 100644 index 0000000..08cd6f2 Binary files /dev/null and b/packages/mobile/assets/splash.png differ diff --git a/packages/mobile/babel.config.js b/packages/mobile/babel.config.js new file mode 100644 index 0000000..fbaf1f9 --- /dev/null +++ b/packages/mobile/babel.config.js @@ -0,0 +1,9 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + 'react-native-reanimated/plugin', + ], + }; +}; diff --git a/packages/mobile/metro.config.js b/packages/mobile/metro.config.js new file mode 100644 index 0000000..3974b8f --- /dev/null +++ b/packages/mobile/metro.config.js @@ -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; diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 83ae1b5..43d82ef 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -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" } } diff --git a/packages/mobile/src/components/Button.tsx b/packages/mobile/src/components/Button.tsx new file mode 100644 index 0000000..ce0915f --- /dev/null +++ b/packages/mobile/src/components/Button.tsx @@ -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 ( + + + {loading ? '...' : title} + + + ); +} + +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', + }, +}); diff --git a/packages/mobile/src/components/Card.tsx b/packages/mobile/src/components/Card.tsx new file mode 100644 index 0000000..152a0cb --- /dev/null +++ b/packages/mobile/src/components/Card.tsx @@ -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 ( + + {title} + {action && onActionPress && ( + + {action} + + )} + + ); +} + +interface CardProps { + title?: string; + children: React.ReactNode; + style?: ViewStyle; +} + +export function Card({ title, children, style }: CardProps) { + return ( + + {title && {title}} + {children} + + ); +} + +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, + }, +}); diff --git a/packages/mobile/src/components/Input.tsx b/packages/mobile/src/components/Input.tsx new file mode 100644 index 0000000..09d18e9 --- /dev/null +++ b/packages/mobile/src/components/Input.tsx @@ -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 { + label?: string; + error?: string; + containerStyle?: object; +} + +export function Input({ label, error, containerStyle, ...props }: InputProps) { + return ( + + {label && {label}} + + {error && {error}} + + ); +} + +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, + }, +}); diff --git a/packages/mobile/src/components/Loading.tsx b/packages/mobile/src/components/Loading.tsx new file mode 100644 index 0000000..c244562 --- /dev/null +++ b/packages/mobile/src/components/Loading.tsx @@ -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 ( + + + + ); +} + +interface EmptyStateProps { + title: string; + message?: string; +} + +export function EmptyState({ title, message }: EmptyStateProps) { + return ( + + + + + {title} + {message && {message}} + + + + ); +} + +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, + }, +}); diff --git a/packages/mobile/src/components/StatCard.tsx b/packages/mobile/src/components/StatCard.tsx new file mode 100644 index 0000000..334a73f --- /dev/null +++ b/packages/mobile/src/components/StatCard.tsx @@ -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 ( + + {title} + {value} + {subtitle && {subtitle}} + + ); +} + +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, + }, +}); diff --git a/packages/mobile/src/components/index.ts b/packages/mobile/src/components/index.ts new file mode 100644 index 0000000..b22ae78 --- /dev/null +++ b/packages/mobile/src/components/index.ts @@ -0,0 +1,5 @@ +export * from './Button'; +export * from './Input'; +export * from './StatCard'; +export * from './Card'; +export * from './Loading'; diff --git a/packages/mobile/src/constants/index.ts b/packages/mobile/src/constants/index.ts new file mode 100644 index 0000000..c81f1fd --- /dev/null +++ b/packages/mobile/src/constants/index.ts @@ -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; diff --git a/packages/mobile/src/constants/theme.ts b/packages/mobile/src/constants/theme.ts new file mode 100644 index 0000000..8eba894 --- /dev/null +++ b/packages/mobile/src/constants/theme.ts @@ -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; diff --git a/packages/mobile/src/hooks/index.ts b/packages/mobile/src/hooks/index.ts new file mode 100644 index 0000000..b3a55b8 --- /dev/null +++ b/packages/mobile/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './usePushNotifications'; +export * from './useBiometricAuth'; +export * from './useNetworkStatus'; diff --git a/packages/mobile/src/hooks/useBiometricAuth.ts b/packages/mobile/src/hooks/useBiometricAuth.ts new file mode 100644 index 0000000..501f23e --- /dev/null +++ b/packages/mobile/src/hooks/useBiometricAuth.ts @@ -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 => { + 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 => { + 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 }; +} diff --git a/packages/mobile/src/hooks/useNetworkStatus.ts b/packages/mobile/src/hooks/useNetworkStatus.ts new file mode 100644 index 0000000..8d94b79 --- /dev/null +++ b/packages/mobile/src/hooks/useNetworkStatus.ts @@ -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 }; +} diff --git a/packages/mobile/src/hooks/usePushNotifications.ts b/packages/mobile/src/hooks/usePushNotifications.ts new file mode 100644 index 0000000..412a0de --- /dev/null +++ b/packages/mobile/src/hooks/usePushNotifications.ts @@ -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 }; +} diff --git a/packages/mobile/src/navigation/AuthNavigator.tsx b/packages/mobile/src/navigation/AuthNavigator.tsx new file mode 100644 index 0000000..ecac723 --- /dev/null +++ b/packages/mobile/src/navigation/AuthNavigator.tsx @@ -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(); + +export function AuthNavigator() { + return ( + + + + + ); +} diff --git a/packages/mobile/src/navigation/MainTabNavigator.tsx b/packages/mobile/src/navigation/MainTabNavigator.tsx new file mode 100644 index 0000000..fa66ac9 --- /dev/null +++ b/packages/mobile/src/navigation/MainTabNavigator.tsx @@ -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(); + +const iconMap: Record = { + 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 ( + {iconMap[routeName] || '\u{2022}'} + ); +} + +const styles = StyleSheet.create({ + icon: { + fontSize: 22, + }, +}); + +export function MainTabNavigator() { + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + ); +} diff --git a/packages/mobile/src/navigation/index.ts b/packages/mobile/src/navigation/index.ts new file mode 100644 index 0000000..03db8c5 --- /dev/null +++ b/packages/mobile/src/navigation/index.ts @@ -0,0 +1,2 @@ +export * from './AuthNavigator'; +export * from './MainTabNavigator'; diff --git a/packages/mobile/src/screens/auth/LoginScreen.tsx b/packages/mobile/src/screens/auth/LoginScreen.tsx new file mode 100644 index 0000000..8ae20fb --- /dev/null +++ b/packages/mobile/src/screens/auth/LoginScreen.tsx @@ -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; + +export function LoginScreen() { + const route = useRoute(); + const navigation = useNavigation(); + 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 ( + + + + + ShieldAI + Your digital protection suite + + + + {formError && {formError}} + + + + + +