feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,154 @@
// Smart App City — Main App Entry Point
// React Native + Expo + React Navigation
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { View, Text } from 'react-native';
import { useAuthStore } from '../stores/authStore';
import { Colors, Spacing, Typography, BorderRadius } from '../theme/colors';
// Auth Screens
import LoginScreen from '../screens/auth/LoginScreen';
import RegisterScreen from '../screens/auth/RegisterScreen';
import ForgotPasswordScreen from '../screens/auth/ForgotPasswordScreen';
// Main Screens
import HomeScreen from '../screens/dashboard/HomeScreen';
import MapScreen from '../screens/gis/MapScreen';
import MarketplaceScreen from '../screens/marketplace/MarketplaceScreen';
import ChatScreen from '../screens/chat/ChatScreen';
import ProfileScreen from '../screens/profile/ProfileScreen';
// Icons (using simple text icons for now — replace with react-native-vector-icons later)
const TabIcon = ({ label, focused }: { label: string; focused: boolean }) => (
<Text style={{
fontSize: 11,
fontWeight: focused ? '700' : '400',
color: focused ? Colors.primary[500] : Colors.neutral500,
}}>
{label}
</Text>
);
const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
// ─── Main Tab Navigator ─────────────────────────────────────────────────────
function MainTabs() {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: Colors.white,
borderTopWidth: 1,
borderTopColor: Colors.neutral200,
height: 60,
paddingBottom: 8,
paddingTop: 4,
},
tabBarActiveTintColor: Colors.primary[500],
tabBarInactiveTintColor: Colors.neutral500,
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '600',
},
}}
>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{
tabBarIcon: ({ focused }) => <TabIcon label="🏠" focused={focused} />,
tabBarLabel: 'Accueil',
}}
/>
<Tab.Screen
name="Map"
component={MapScreen}
options={{
tabBarIcon: ({ focused }) => <TabIcon label="🗺️" focused={focused} />,
tabBarLabel: 'Carte',
}}
/>
<Tab.Screen
name="Marketplace"
component={MarketplaceScreen}
options={{
tabBarIcon: ({ focused }) => <TabIcon label="🛒" focused={focused} />,
tabBarLabel: 'Market',
}}
/>
<Tab.Screen
name="Chat"
component={ChatScreen}
options={{
tabBarIcon: ({ focused }) => <TabIcon label="🤖" focused={focused} />,
tabBarLabel: 'AI Chat',
}}
/>
<Tab.Screen
name="Profile"
component={ProfileScreen}
options={{
tabBarIcon: ({ focused }) => <TabIcon label="👤" focused={focused} />,
tabBarLabel: 'Profil',
}}
/>
</Tab.Navigator>
);
}
// ─── Auth Navigator ──────────────────────────────────────────────────────────
function AuthStack() {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
<Stack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
</Stack.Navigator>
);
}
// ─── Root Navigator ──────────────────────────────────────────────────────────
export default function App() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
if (isLoading) {
return (
<View style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: Colors.primary[500],
}}>
<Text style={{
color: Colors.white,
fontSize: Typography.sizes.xl,
fontWeight: Typography.weights.bold,
}}>
Smart App City
</Text>
</View>
);
}
return (
<NavigationContainer>
<StatusBar style={isAuthenticated ? 'dark' : 'light'} />
{isAuthenticated ? <MainTabs /> : <AuthStack />}
</NavigationContainer>
);
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const AlertCard = () => {
return (
<View>
<Text>AlertCard</Text>
</View>
);
};
export default AlertCard;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const SensorCard = () => {
return (
<View>
<Text>SensorCard</Text>
</View>
);
};
export default SensorCard;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const StatsCard = () => {
return (
<View>
<Text>StatsCard</Text>
</View>
);
};
export default StatsCard;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const ZoneCard = () => {
return (
<View>
<Text>ZoneCard</Text>
</View>
);
};
export default ZoneCard;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const BarChart = () => {
return (
<View>
<Text>BarChart</Text>
</View>
);
};
export default BarChart;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const GaugeChart = () => {
return (
<View>
<Text>GaugeChart</Text>
</View>
);
};
export default GaugeChart;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const LineChart = () => {
return (
<View>
<Text>LineChart</Text>
</View>
);
};
export default LineChart;

View File

@@ -0,0 +1,93 @@
// Smart App City — Reusable Button Component
import React from 'react';
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
ViewStyle,
TextStyle,
} from 'react-native';
import { Colors, BorderRadius, Typography, Spacing, Shadows } from '../../../src/theme/colors';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
icon?: string;
style?: ViewStyle;
textStyle?: TextStyle;
fullWidth?: boolean;
}
export default function Button({
title,
onPress,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
icon,
style,
textStyle,
fullWidth = false,
}: ButtonProps) {
const bgColor = {
primary: Colors.primary[500],
secondary: Colors.neutral100,
danger: Colors.danger,
success: Colors.success,
ghost: 'transparent',
}[variant];
const textColor = {
primary: Colors.white,
secondary: Colors.neutral700,
danger: Colors.white,
success: Colors.white,
ghost: Colors.primary[500],
}[variant];
const height = { sm: 36, md: 44, lg: 52 }[size];
const fontSize = { sm: Typography.sizes.sm, md: Typography.sizes.base, lg: Typography.sizes.md }[size];
return (
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: bgColor, height, borderRadius: BorderRadius.md },
fullWidth && { width: '100%' },
disabled && { opacity: 0.5 },
style,
]}
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator color={textColor} />
) : (
<Text style={[styles.text, { color: textColor, fontSize }, textStyle]}>
{icon && `${icon} `}{title}
</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: Spacing.base,
...Shadows.sm,
},
text: {
fontWeight: Typography.weights.semibold,
textAlign: 'center',
},
});

View File

@@ -0,0 +1,50 @@
// Smart App City — Reusable Card Component
import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
import { Colors, BorderRadius, Shadows, Spacing } from '../../../src/theme/colors';
interface CardProps {
children: React.ReactNode;
style?: ViewStyle;
variant?: 'default' | 'elevated' | 'outlined';
padding?: 'none' | 'sm' | 'md' | 'lg';
}
export default function Card({ children, style, variant = 'default', padding = 'md' }: CardProps) {
return (
<View
style={[
styles.card,
variant === 'elevated' && styles.elevated,
variant === 'outlined' && styles.outlined,
padding === 'none' && { padding: 0 },
padding === 'sm' && { padding: Spacing.sm },
padding === 'lg' && { padding: Spacing.base },
style,
]}
>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: Colors.white,
borderRadius: BorderRadius.lg,
padding: Spacing.base,
...Shadows.sm,
borderWidth: 1,
borderColor: Colors.neutral100,
},
elevated: {
...Shadows.md,
borderWidth: 0,
},
outlined: {
borderWidth: 1,
borderColor: Colors.neutral200,
shadowOpacity: 0,
elevation: 0,
},
});

View File

@@ -0,0 +1,71 @@
// Smart App City — Error Boundary
import React, { Component, ReactNode } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Colors, Typography, Spacing, BorderRadius } from '../../../src/theme/colors';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<View style={styles.container}>
<Text style={styles.emoji}></Text>
<Text style={styles.title}>Oups !</Text>
<Text style={styles.message}>
Une erreur inattendue est survenue.
</Text>
{this.state.error && (
<Text style={styles.errorText}>{this.state.error.message}</Text>
)}
<TouchableOpacity style={styles.button} onPress={this.handleReset}>
<Text style={styles.buttonText}>Réessayer</Text>
</TouchableOpacity>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: Colors.neutral50,
},
emoji: { fontSize: 48, marginBottom: Spacing.base },
title: { fontSize: Typography.sizes.xl, fontWeight: Typography.weights.bold, color: Colors.neutral900, marginBottom: Spacing.sm },
message: { fontSize: Typography.sizes.base, color: Colors.neutral600, textAlign: 'center', marginBottom: Spacing.base },
errorText: { fontSize: Typography.sizes.sm, color: Colors.danger, textAlign: 'center', marginBottom: Spacing.lg },
button: {
backgroundColor: Colors.primary[500],
borderRadius: BorderRadius.md,
paddingHorizontal: Spacing.xl,
paddingVertical: Spacing.base,
},
buttonText: { color: Colors.white, fontWeight: Typography.weights.bold, fontSize: Typography.sizes.base },
});

View File

@@ -0,0 +1,46 @@
// Smart App City — Section Header Component
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Colors, Typography, Spacing } from '../../../src/theme/colors';
interface Props {
title: string;
onSeeAll?: () => void;
seeAllLabel?: string;
rightElement?: React.ReactNode;
}
export default function SectionHeader({ title, onSeeAll, seeAllLabel = 'Voir tout →', rightElement }: Props) {
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
{rightElement}
{onSeeAll && (
<TouchableOpacity onPress={onSeeAll}>
<Text style={styles.seeAll}>{seeAllLabel}</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: Spacing.base,
paddingBottom: Spacing.sm,
},
title: {
fontSize: Typography.sizes.md,
fontWeight: Typography.weights.bold,
color: Colors.neutral900,
flex: 1,
},
seeAll: {
fontSize: Typography.sizes.sm,
color: Colors.primary[500],
fontWeight: Typography.weights.medium,
},
});

View File

@@ -0,0 +1,32 @@
// Smart App City — Loading Spinner
import React from 'react';
import { View, ActivityIndicator, Text, StyleSheet } from 'react-native';
import { Colors, Typography, Spacing } from '../../../src/theme/colors';
interface Props {
message?: string;
size?: 'small' | 'large';
}
export default function LoadingSpinner({ message, size = 'large' }: Props) {
return (
<View style={styles.container}>
<ActivityIndicator size={size} color={Colors.primary[500]} />
{message && <Text style={styles.message}>{message}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.xl,
},
message: {
marginTop: Spacing.base,
fontSize: Typography.sizes.base,
color: Colors.neutral500,
},
});

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const MapView = () => {
return (
<View>
<Text>MapView</Text>
</View>
);
};
export default MapView;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const MarkerPopup = () => {
return (
<View>
<Text>MarkerPopup</Text>
</View>
);
};
export default MarkerPopup;

View File

@@ -0,0 +1,23 @@
// Smart App City — useAlerts Hook
import { useIoTStore, Alert } from '../stores/iotStore';
export function useAlerts() {
const alerts = useIoTStore((s) => s.alerts);
const acknowledgeAlert = useIoTStore((s) => s.acknowledgeAlert);
const activeAlerts = alerts.filter((a) => !a.acknowledged);
const criticalAlerts = activeAlerts.filter((a) => a.severity === 'critical' || a.severity === 'high');
const alertsBySensor = (sensorId: string) =>
alerts.filter((a) => a.sensorId === sensorId);
return {
alerts,
activeAlerts,
criticalAlerts,
acknowledgeAlert,
alertsBySensor,
hasCritical: criticalAlerts.length > 0,
unreadCount: activeAlerts.length,
};
}

View File

@@ -0,0 +1,22 @@
// Smart App City — useNotifications Hook
import { useNotificationStore, AppNotification } from '../stores/notificationStore';
export function useNotifications() {
const notifications = useNotificationStore((s) => s.notifications);
const unreadCount = useNotificationStore((s) => s.unreadCount);
const markAsRead = useNotificationStore((s) => s.markAsRead);
const markAllAsRead = useNotificationStore((s) => s.markAllAsRead);
const removeNotification = useNotificationStore((s) => s.removeNotification);
const clearAll = useNotificationStore((s) => s.clearAll);
const addNotification = useNotificationStore((s) => s.addNotification);
return {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
removeNotification,
clearAll,
addNotification,
};
}

View File

@@ -0,0 +1,51 @@
// Smart App City — useLocation Hook
import { useState, useEffect } from 'react';
import * as Location from 'expo-location';
interface LocationState {
latitude: number;
longitude: number;
accuracy: number | null;
city: string;
isLoading: boolean;
error: string | null;
}
const DEFAULT_LOCATION = {
latitude: 14.6161, // Fort-de-France
longitude: -61.0588,
city: 'Fort-de-France',
};
export function useLocation() {
const [state, setState] = useState<LocationState>({
...DEFAULT_LOCATION,
accuracy: null,
isLoading: false,
error: null,
});
const requestLocation = async () => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setState((prev) => ({ ...prev, isLoading: false, error: 'Permission refusée' }));
return;
}
const loc = await Location.getCurrentPositionAsync({});
setState({
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
accuracy: loc.coords.accuracy,
city: DEFAULT_LOCATION.city,
isLoading: false,
error: null,
});
} catch (err: any) {
setState((prev) => ({ ...prev, isLoading: false, error: err.message }));
}
};
return { ...state, requestLocation };
}

View File

@@ -0,0 +1,47 @@
// Smart App City — useSensors Hook
import { useState, useEffect, useCallback } from 'react';
import { useIoTStore, Sensor } from '../stores/iotStore';
export function useSensors() {
const sensors = useIoTStore((s) => s.sensors);
const isLoading = useIoTStore((s) => s.isLoading);
const error = useIoTStore((s) => s.error);
const refresh = useIoTStore((s) => s.refresh);
const updateSensorValue = useIoTStore((s) => s.updateSensorValue);
const [selectedType, setSelectedType] = useState<string | null>(null);
const filteredSensors = selectedType
? sensors.filter((s) => s.type === selectedType)
: sensors;
const sensorsByType = {
temperature: sensors.filter((s) => s.type === 'temperature'),
humidity: sensors.filter((s) => s.type === 'humidity'),
air_quality: sensors.filter((s) => s.type === 'air_quality'),
noise: sensors.filter((s) => s.type === 'noise'),
traffic: sensors.filter((s) => s.type === 'traffic'),
energy: sensors.filter((s) => s.type === 'energy'),
};
const alertSensors = sensors.filter((s) => s.status === 'alert' || s.status === 'warning');
return {
sensors: filteredSensors,
allSensors: sensors,
sensorsByType,
alertSensors,
isLoading,
error,
refresh,
selectedType,
setSelectedType,
updateSensorValue,
};
}
export function useSensorDetail(sensorId: string | null) {
const sensors = useIoTStore((s) => s.sensors);
const sensor = sensors.find((s) => s.id === sensorId) ?? null;
return sensor;
}

View File

@@ -0,0 +1,144 @@
// Smart App City — Forgot Password Screen
import React, { useState } from 'react';
import {
View, Text, TextInput, TouchableOpacity,
StyleSheet, Alert, ActivityIndicator,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../src/theme/colors';
type Props = { navigation: NativeStackNavigationProp<any> };
export default function ForgotPasswordScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!email.trim()) {
Alert.alert('Erreur', 'Veuillez entrer votre email');
return;
}
setLoading(true);
// Simulate API call
await new Promise((r) => setTimeout(r, 1500));
setLoading(false);
setSent(true);
};
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
<Text style={styles.backText}> Retour</Text>
</TouchableOpacity>
<View style={styles.iconContainer}>
<Text style={styles.iconEmoji}>🔑</Text>
</View>
<Text style={styles.title}>Mot de passe oublié</Text>
<Text style={styles.subtitle}>
{sent
? `Un email de réinitialisation a été envoyé à ${email}`
: 'Entrez votre email pour recevoir un lien de réinitialisation'}
</Text>
</View>
<View style={styles.form}>
{!sent ? (
<>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<View style={styles.inputWrapper}>
<Text style={styles.inputIcon}>📧</Text>
<TextInput
style={styles.input}
placeholder="votre@email.com"
placeholderTextColor={Colors.neutral400}
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
editable={!loading}
/>
</View>
</View>
<TouchableOpacity
style={[styles.submitBtn, loading && { opacity: 0.6 }]}
onPress={handleSubmit}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={Colors.white} />
) : (
<Text style={styles.submitText}>Envoyer le lien</Text>
)}
</TouchableOpacity>
</>
) : (
<View style={styles.successContainer}>
<Text style={styles.successIcon}></Text>
<Text style={styles.successTitle}>Email envoyé !</Text>
<Text style={styles.successText}>
Vérifiez votre boîte de réception et suivez les instructions.
</Text>
<TouchableOpacity
style={styles.backLoginBtn}
onPress={() => navigation.navigate('Login')}
>
<Text style={styles.backLoginText}>Retour à la connexion</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white },
header: {
paddingTop: 50, paddingBottom: 40, paddingHorizontal: Spacing.xl,
backgroundColor: Colors.primary[500],
borderBottomLeftRadius: BorderRadius.xxl,
borderBottomRightRadius: BorderRadius.xxl,
alignItems: 'center',
},
backBtn: { alignSelf: 'flex-start', marginBottom: Spacing.lg },
backText: { color: Colors.white, fontSize: Typography.sizes.base },
iconContainer: {
width: 72, height: 72, borderRadius: BorderRadius.xl,
backgroundColor: 'rgba(255,255,255,0.15)',
justifyContent: 'center', alignItems: 'center',
marginBottom: Spacing.base,
},
iconEmoji: { fontSize: 36 },
title: { fontSize: Typography.sizes.xl, fontWeight: Typography.weights.bold, color: Colors.white, marginBottom: Spacing.sm },
subtitle: { fontSize: Typography.sizes.base, color: 'rgba(255,255,255,0.8)', textAlign: 'center', lineHeight: 22 },
form: { padding: Spacing.xl, flex: 1 },
inputGroup: { marginBottom: Spacing.lg },
label: { fontSize: Typography.sizes.sm, fontWeight: Typography.weights.semibold, color: Colors.neutral700, marginBottom: Spacing.xs },
inputWrapper: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.neutral50, borderRadius: BorderRadius.md,
borderWidth: 1, borderColor: Colors.neutral200,
paddingHorizontal: Spacing.base, height: 48,
},
inputIcon: { fontSize: 16, marginRight: Spacing.sm },
input: { flex: 1, fontSize: Typography.sizes.base, color: Colors.neutral900, height: 48 },
submitBtn: {
backgroundColor: Colors.primary[500], borderRadius: BorderRadius.md,
height: 50, justifyContent: 'center', alignItems: 'center', ...Shadows.md,
},
submitText: { color: Colors.white, fontSize: Typography.sizes.md, fontWeight: Typography.weights.bold },
successContainer: { alignItems: 'center', paddingTop: Spacing.xl },
successIcon: { fontSize: 48, marginBottom: Spacing.base },
successTitle: { fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.neutral900, marginBottom: Spacing.sm },
successText: { fontSize: Typography.sizes.base, color: Colors.neutral600, textAlign: 'center', marginBottom: Spacing.xl },
backLoginBtn: {
backgroundColor: Colors.primary[50], borderRadius: BorderRadius.md,
paddingVertical: Spacing.base, paddingHorizontal: Spacing.xl,
borderWidth: 1, borderColor: Colors.primary[200],
},
backLoginText: { color: Colors.primary[500], fontWeight: Typography.weights.bold },
});

View File

@@ -0,0 +1,340 @@
// Smart App City — Login Screen
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useAuthStore } from '../../stores/authStore';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../src/theme/colors';
type LoginScreenProps = {
navigation: NativeStackNavigationProp<any>;
};
export default function LoginScreen({ navigation }: LoginScreenProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const login = useAuthStore((state) => state.login);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
try {
clearError();
await login(email.trim(), password);
} catch (err: any) {
Alert.alert('Erreur de connexion', error ?? 'Identifiants incorrects');
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{/* Header gradient */}
<View style={styles.header}>
<View style={styles.logoContainer}>
<Text style={styles.logoEmoji}>🏙</Text>
</View>
<Text style={styles.title}>Smart App City</Text>
<Text style={styles.subtitle}>Martinique Digital Twin</Text>
</View>
{/* Form */}
<View style={styles.formContainer}>
<Text style={styles.formTitle}>Connexion</Text>
{/* Email */}
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<View style={styles.inputWrapper}>
<Text style={styles.inputIcon}>📧</Text>
<TextInput
style={styles.input}
placeholder="votre@email.com"
placeholderTextColor={Colors.neutral400}
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
editable={!isLoading}
/>
</View>
</View>
{/* Password */}
<View style={styles.inputGroup}>
<Text style={styles.label}>Mot de passe</Text>
<View style={styles.inputWrapper}>
<Text style={styles.inputIcon}>🔒</Text>
<TextInput
style={[styles.input, { flex: 1 }]}
placeholder="••••••••"
placeholderTextColor={Colors.neutral400}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoCapitalize="none"
editable={!isLoading}
/>
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
<Text style={styles.toggleIcon}>{showPassword ? '🙈' : '👁️'}</Text>
</TouchableOpacity>
</View>
</View>
{/* Forgot password */}
<TouchableOpacity
onPress={() => navigation.navigate('ForgotPassword')}
style={styles.forgotRow}
>
<Text style={styles.forgotText}>Mot de passe oublié ?</Text>
</TouchableOpacity>
{/* Error */}
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}> {error}</Text>
</View>
)}
{/* Login button */}
<TouchableOpacity
style={[styles.loginButton, isLoading && styles.loginButtonDisabled]}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={Colors.white} />
) : (
<Text style={styles.loginButtonText}>Se connecter</Text>
)}
</TouchableOpacity>
{/* Divider */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>ou</Text>
<View style={styles.dividerLine} />
</View>
{/* Social buttons */}
<View style={styles.socialRow}>
<TouchableOpacity style={styles.socialButton}>
<Text style={styles.socialIcon}>🔵</Text>
<Text style={styles.socialText}>Google</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.socialButton}>
<Text style={styles.socialIcon}>🍎</Text>
<Text style={styles.socialText}>Apple</Text>
</TouchableOpacity>
</View>
{/* Register link */}
<View style={styles.registerRow}>
<Text style={styles.registerText}>Pas encore de compte ? </Text>
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
<Text style={styles.registerLink}>S'inscrire</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.white,
},
scrollContent: {
flexGrow: 1,
},
header: {
paddingTop: 60,
paddingBottom: 40,
paddingHorizontal: Spacing.xl,
backgroundColor: Colors.primary[500],
borderBottomLeftRadius: BorderRadius.xxl,
borderBottomRightRadius: BorderRadius.xxl,
alignItems: 'center',
...Shadows.lg,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: BorderRadius.xl,
backgroundColor: 'rgba(255,255,255,0.15)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
logoEmoji: {
fontSize: 40,
},
title: {
fontSize: Typography.sizes.xxl,
fontWeight: Typography.weights.bold,
color: Colors.white,
marginBottom: Spacing.xs,
},
subtitle: {
fontSize: Typography.sizes.base,
color: 'rgba(255,255,255,0.8)',
},
formContainer: {
flex: 1,
padding: Spacing.xl,
marginTop: Spacing.lg,
},
formTitle: {
fontSize: Typography.sizes.xl,
fontWeight: Typography.weights.bold,
color: Colors.neutral900,
marginBottom: Spacing.xl,
},
inputGroup: {
marginBottom: Spacing.base,
},
label: {
fontSize: Typography.sizes.sm,
fontWeight: Typography.weights.semibold,
color: Colors.neutral700,
marginBottom: Spacing.xs,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Colors.neutral50,
borderRadius: BorderRadius.md,
borderWidth: 1,
borderColor: Colors.neutral200,
paddingHorizontal: Spacing.base,
height: 48,
},
inputIcon: {
fontSize: 16,
marginRight: Spacing.sm,
},
input: {
flex: 1,
fontSize: Typography.sizes.base,
color: Colors.neutral900,
height: 48,
},
toggleIcon: {
fontSize: 16,
padding: Spacing.xs,
},
forgotRow: {
alignSelf: 'flex-end',
marginBottom: Spacing.lg,
},
forgotText: {
fontSize: Typography.sizes.sm,
color: Colors.primary[500],
fontWeight: Typography.weights.medium,
},
errorContainer: {
backgroundColor: '#FFEBEE',
borderRadius: BorderRadius.md,
padding: Spacing.base,
marginBottom: Spacing.base,
},
errorText: {
fontSize: Typography.sizes.sm,
color: Colors.danger,
},
loginButton: {
backgroundColor: Colors.primary[500],
borderRadius: BorderRadius.md,
height: 50,
justifyContent: 'center',
alignItems: 'center',
...Shadows.md,
},
loginButtonDisabled: {
opacity: 0.6,
},
loginButtonText: {
color: Colors.white,
fontSize: Typography.sizes.md,
fontWeight: Typography.weights.bold,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: Spacing.xl,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: Colors.neutral200,
},
dividerText: {
fontSize: Typography.sizes.sm,
color: Colors.neutral500,
paddingHorizontal: Spacing.base,
},
socialRow: {
flexDirection: 'row',
gap: Spacing.base,
marginBottom: Spacing.xl,
},
socialButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.neutral50,
borderRadius: BorderRadius.md,
borderWidth: 1,
borderColor: Colors.neutral200,
height: 48,
gap: Spacing.sm,
},
socialIcon: {
fontSize: 16,
},
socialText: {
fontSize: Typography.sizes.base,
fontWeight: Typography.weights.medium,
color: Colors.neutral700,
},
registerRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
registerText: {
fontSize: Typography.sizes.base,
color: Colors.neutral600,
},
registerLink: {
fontSize: Typography.sizes.base,
fontWeight: Typography.weights.bold,
color: Colors.primary[500],
},
});

View File

@@ -0,0 +1,217 @@
// Smart App City — Register Screen
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useAuthStore } from '../../stores/authStore';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../src/theme/colors';
type Props = { navigation: NativeStackNavigationProp<any> };
export default function RegisterScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const register = useAuthStore((s) => s.register);
const isLoading = useAuthStore((s) => s.isLoading);
const error = useAuthStore((s) => s.error);
const clearError = useAuthStore((s) => s.clearError);
const handleRegister = async () => {
if (!email.trim() || !password.trim() || !firstName.trim() || !lastName.trim()) {
Alert.alert('Erreur', 'Veuillez remplir tous les champs');
return;
}
if (password !== confirmPassword) {
Alert.alert('Erreur', 'Les mots de passe ne correspondent pas');
return;
}
if (password.length < 8) {
Alert.alert('Erreur', 'Le mot de passe doit contenir au moins 8 caractères');
return;
}
try {
clearError();
await register({ email: email.trim(), password, firstName: firstName.trim(), lastName: lastName.trim() });
} catch (err: any) {
Alert.alert('Erreur', error ?? 'Inscription échouée');
}
};
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn}>
<Text style={styles.backText}> Retour</Text>
</TouchableOpacity>
<Text style={styles.title}>Créer un compte</Text>
<Text style={styles.subtitle}>Rejoignez Smart App City</Text>
</View>
{/* Form */}
<View style={styles.form}>
<View style={styles.row}>
<View style={[styles.inputGroup, { flex: 1, marginRight: Spacing.sm }]}>
<Text style={styles.label}>Prénom</Text>
<View style={styles.inputWrapper}>
<TextInput
style={styles.input}
placeholder="Jean"
placeholderTextColor={Colors.neutral400}
value={firstName}
onChangeText={setFirstName}
autoCapitalize="words"
editable={!isLoading}
/>
</View>
</View>
<View style={[styles.inputGroup, { flex: 1, marginLeft: Spacing.sm }]}>
<Text style={styles.label}>Nom</Text>
<View style={styles.inputWrapper}>
<TextInput
style={styles.input}
placeholder="Dupont"
placeholderTextColor={Colors.neutral400}
value={lastName}
onChangeText={setLastName}
autoCapitalize="words"
editable={!isLoading}
/>
</View>
</View>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<View style={styles.inputWrapper}>
<Text style={styles.inputIcon}>📧</Text>
<TextInput
style={styles.input}
placeholder="votre@email.com"
placeholderTextColor={Colors.neutral400}
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
editable={!isLoading}
/>
</View>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Mot de passe</Text>
<View style={styles.inputWrapper}>
<Text style={styles.inputIcon}>🔒</Text>
<TextInput
style={styles.input}
placeholder="Min. 8 caractères"
placeholderTextColor={Colors.neutral400}
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
editable={!isLoading}
/>
</View>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Confirmer le mot de passe</Text>
<View style={styles.inputWrapper}>
<Text style={styles.inputIcon}>🔒</Text>
<TextInput
style={styles.input}
placeholder="Retapez le mot de passe"
placeholderTextColor={Colors.neutral400}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
autoCapitalize="none"
editable={!isLoading}
/>
</View>
</View>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}> {error}</Text>
</View>
)}
<TouchableOpacity
style={[styles.submitBtn, isLoading && { opacity: 0.6 }]}
onPress={handleRegister}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={Colors.white} />
) : (
<Text style={styles.submitText}>S'inscrire</Text>
)}
</TouchableOpacity>
<View style={styles.loginRow}>
<Text style={styles.loginText}>Déjà un compte ? </Text>
<TouchableOpacity onPress={() => navigation.navigate('Login')}>
<Text style={styles.loginLink}>Se connecter</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.white },
scrollContent: { flexGrow: 1 },
header: {
paddingTop: 50,
paddingBottom: 30,
paddingHorizontal: Spacing.xl,
backgroundColor: Colors.primary[500],
borderBottomLeftRadius: BorderRadius.xxl,
borderBottomRightRadius: BorderRadius.xxl,
},
backBtn: { marginBottom: Spacing.base },
backText: { color: Colors.white, fontSize: Typography.sizes.base },
title: { fontSize: Typography.sizes.xl, fontWeight: Typography.weights.bold, color: Colors.white, marginBottom: Spacing.xs },
subtitle: { fontSize: Typography.sizes.base, color: 'rgba(255,255,255,0.8)' },
form: { padding: Spacing.xl },
row: { flexDirection: 'row' },
inputGroup: { marginBottom: Spacing.base },
label: { fontSize: Typography.sizes.sm, fontWeight: Typography.weights.semibold, color: Colors.neutral700, marginBottom: Spacing.xs },
inputWrapper: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.neutral50, borderRadius: BorderRadius.md,
borderWidth: 1, borderColor: Colors.neutral200,
paddingHorizontal: Spacing.base, height: 48,
},
inputIcon: { fontSize: 16, marginRight: Spacing.sm },
input: { flex: 1, fontSize: Typography.sizes.base, color: Colors.neutral900, height: 48 },
errorContainer: { backgroundColor: '#FFEBEE', borderRadius: BorderRadius.md, padding: Spacing.base, marginBottom: Spacing.base },
errorText: { fontSize: Typography.sizes.sm, color: Colors.danger },
submitBtn: {
backgroundColor: Colors.primary[500], borderRadius: BorderRadius.md,
height: 50, justifyContent: 'center', alignItems: 'center',
marginTop: Spacing.sm, ...Shadows.md,
},
submitText: { color: Colors.white, fontSize: Typography.sizes.md, fontWeight: Typography.weights.bold },
loginRow: { flexDirection: 'row', justifyContent: 'center', marginTop: Spacing.xl },
loginText: { fontSize: Typography.sizes.base, color: Colors.neutral600 },
loginLink: { fontSize: Typography.sizes.base, fontWeight: Typography.weights.bold, color: Colors.primary[500] },
});

View File

@@ -0,0 +1,244 @@
// Smart App City — AI Chat Screen
import React, { useState, useRef, useEffect } from 'react';
import {
View, Text, TextInput, TouchableOpacity,
ScrollView, StyleSheet, KeyboardAvoidingView, Platform,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../src/theme/colors';
type Props = { navigation: NativeStackNavigationProp<any> };
interface Message {
id: string;
text: string;
isBot: boolean;
timestamp: Date;
}
const QUICK_PROMPTS = [
{ id: '1', text: '🌤️ Météo aujourd\'hui', prompt: 'Quelle est la météo à Martinique aujourd\'hui ?' },
{ id: '2', text: '🚌 Prochain bus', prompt: 'Quel est le prochain bus pour Fort-de-France ?' },
{ id: '3', text: '⚡ Consommation énergie', prompt: 'Quelle est ma consommation d\'énergie cette semaine ?' },
{ id: '4', text: '🚨 Alertes en cours', prompt: 'Y a-t-il des alertes en cours à Martinique ?' },
];
const BOT_RESPONSES: Record<string, string> = {
'🌤️ Météo': '☀️ Aujourd\'hui à Fort-de-France : 28°C, ensoleillé avec quelques nuages. Humidité 72%, vent 12 km/h NE. Indice UV 8 — pensez à la crème solaire !',
'🚌 Prochain bus': '🚌 Le prochain bus L1 (Centre-Ville → Schoelcher) arrive dans 8 min. Le L3 (Aimé Césaires) dans 12 min. Source : CFTA temps réel.',
'⚡ Consommation': '⚡ Votre consommation cette semaine : 45 kWh, soit 12% de moins que la semaine dernière. Estimation mensuelle : 180 kWh. Bon travail !',
'🚨 Alertes': '🚨 1 alerte en cours : Qualité de l\'air dégradée à Schoelcher (AQI 85). Évitez les efforts prolongés en extérieur. Pas d\'alerte météo.',
};
export default function ChatScreen({ navigation }: Props) {
const [messages, setMessages] = useState<Message[]>([
{
id: '0',
text: 'Bonjour ! Je suis Smart City IA 🤖\n\nJe peux vous aider avec :\n• 🌤️ Météo en temps réel\n• 🚌 Transports\n• ⚡ Énergie\n• 🚨 Alertes\n\nComment puis-je vous aider ?',
isBot: true,
timestamp: new Date(),
},
]);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const scrollRef = useRef<ScrollView>(null);
useEffect(() => {
scrollRef.current?.scrollToEnd({ animated: true });
}, [messages]);
const sendMessage = (text: string) => {
if (!text.trim()) return;
const userMsg: Message = {
id: Date.now().toString(),
text: text.trim(),
isBot: false,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsTyping(true);
// Simulate bot response
setTimeout(() => {
const response = getBotResponse(text);
const botMsg: Message = {
id: (Date.now() + 1).toString(),
text: response,
isBot: true,
timestamp: new Date(),
};
setMessages((prev) => [...prev, botMsg]);
setIsTyping(false);
}, 1000 + Math.random() * 1000);
};
const getBotResponse = (text: string): string => {
const lower = text.toLowerCase();
for (const [key, response] of Object.entries(BOT_RESPONSES)) {
if (lower.includes(key.slice(2).toLowerCase())) return response;
}
if (lower.includes('météo') || lower.includes('weather')) return BOT_RESPONSES['🌤️ Météo'];
if (lower.includes('bus') || lower.includes('transport')) return BOT_RESPONSES['🚌 Prochain bus'];
if (lower.includes('énergie') || lower.includes('energie') || lower.includes('consommation')) return BOT_RESPONSES['⚡ Consommation'];
if (lower.includes('alerte') || lower.includes('danger')) return BOT_RESPONSES['🚨 Alertes'];
return 'Je comprends votre question. Laissez-moi consulter les données en temps réel... 🔍\n\nPour l\'instant, voici les services disponibles : météo, transports, énergie, et alertes. Essayez l\'un des raccourcis ci-dessous !';
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.botAvatar}>
<Text style={styles.botEmoji}>🤖</Text>
</View>
<View>
<Text style={styles.title}>Smart City IA</Text>
<View style={styles.statusRow}>
<View style={styles.statusDot} />
<Text style={styles.statusText}>En ligne</Text>
</View>
</View>
</View>
{/* Quick prompts */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.promptsScroll}>
{QUICK_PROMPTS.map((p) => (
<TouchableOpacity key={p.id} style={styles.promptChip} onPress={() => sendMessage(p.prompt)}>
<Text style={styles.promptText}>{p.text}</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Messages */}
<ScrollView ref={scrollRef} style={styles.messagesList} showsVerticalScrollIndicator={false}>
{messages.map((msg) => (
<View key={msg.id} style={[styles.messageRow, msg.isBot ? styles.botRow : styles.userRow]}>
{msg.isBot && <View style={styles.botAvatarSmall}><Text style={styles.botEmojiSmall}>🤖</Text></View>}
<View style={[styles.messageBubble, msg.isBot ? styles.botBubble : styles.userBubble]}>
<Text style={[styles.messageText, msg.isBot ? styles.botText : styles.userText]}>{msg.text}</Text>
<Text style={[styles.messageTime, msg.isBot ? styles.botTime : styles.userTime]}>
{msg.timestamp.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
</View>
))}
{isTyping && (
<View style={[styles.messageRow, styles.botRow]}>
<View style={styles.botAvatarSmall}><Text style={styles.botEmojiSmall}>🤖</Text></View>
<View style={[styles.messageBubble, styles.botBubble]}>
<Text style={styles.typingText}>Écrit...</Text>
</View>
</View>
)}
<View style={{ height: 20 }} />
</ScrollView>
{/* Input */}
<View style={styles.inputBar}>
<TextInput
style={styles.input}
placeholder="Posez votre question..."
placeholderTextColor={Colors.neutral400}
value={input}
onChangeText={setInput}
multiline
maxLength={500}
/>
<TouchableOpacity
style={[styles.sendBtn, !input.trim() && { opacity: 0.4 }]}
onPress={() => sendMessage(input)}
disabled={!input.trim() || isTyping}
>
<Text style={styles.sendIcon}></Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.primary[500],
paddingTop: 50, paddingBottom: Spacing.base,
paddingHorizontal: Spacing.base,
gap: Spacing.base,
},
botAvatar: {
width: 44, height: 44, borderRadius: BorderRadius.lg,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center', alignItems: 'center',
},
botEmoji: { fontSize: 24 },
title: { fontSize: Typography.sizes.md, fontWeight: Typography.weights.bold, color: Colors.white },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 },
statusDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: Colors.success },
statusText: { fontSize: Typography.sizes.xs, color: 'rgba(255,255,255,0.8)' },
promptsScroll: { padding: Spacing.base },
promptChip: {
backgroundColor: Colors.white, borderRadius: BorderRadius.full,
paddingHorizontal: Spacing.base, paddingVertical: Spacing.sm,
marginRight: Spacing.sm, borderWidth: 1, borderColor: Colors.neutral200,
},
promptText: { fontSize: Typography.sizes.sm, color: Colors.neutral700 },
messagesList: { flex: 1, paddingHorizontal: Spacing.base },
messageRow: { flexDirection: 'row', marginBottom: Spacing.sm, gap: Spacing.sm },
botRow: { justifyContent: 'flex-start' },
userRow: { justifyContent: 'flex-end' },
botAvatarSmall: {
width: 28, height: 28, borderRadius: BorderRadius.md,
backgroundColor: Colors.primary[100],
justifyContent: 'center', alignItems: 'center',
},
botEmojiSmall: { fontSize: 14 },
messageBubble: {
maxWidth: '75%', borderRadius: BorderRadius.lg,
padding: Spacing.base, ...Shadows.sm,
},
botBubble: {
backgroundColor: Colors.white,
borderBottomLeftRadius: 4,
},
userBubble: {
backgroundColor: Colors.primary[500],
borderBottomRightRadius: 4,
},
messageText: { fontSize: Typography.sizes.base, lineHeight: 20 },
botText: { color: Colors.neutral900 },
userText: { color: Colors.white },
messageTime: { fontSize: 9, marginTop: 4 },
botTime: { color: Colors.neutral400 },
userTime: { color: 'rgba(255,255,255,0.6)' },
typingText: { color: Colors.neutral400, fontStyle: 'italic' },
inputBar: {
flexDirection: 'row', alignItems: 'flex-end',
backgroundColor: Colors.white,
paddingHorizontal: Spacing.base,
paddingVertical: Spacing.sm,
borderTopWidth: 1, borderTopColor: Colors.neutral200,
gap: Spacing.sm,
},
input: {
flex: 1, backgroundColor: Colors.neutral50,
borderRadius: BorderRadius.full,
paddingHorizontal: Spacing.base,
paddingVertical: Spacing.sm,
fontSize: Typography.sizes.base,
color: Colors.neutral900,
maxHeight: 100,
},
sendBtn: {
width: 40, height: 40, borderRadius: 20,
backgroundColor: Colors.primary[500],
justifyContent: 'center', alignItems: 'center',
},
sendIcon: { color: Colors.white, fontSize: 18 },
});

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const DashboardScreen = () => {
return (
<View>
<Text>DashboardScreen</Text>
</View>
);
};
export default DashboardScreen;

View File

@@ -0,0 +1,334 @@
// Smart App City — Home Dashboard Screen
// Météo, capteurs IoT, quick actions, alertes
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, RefreshControl, Dimensions,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../src/theme/colors';
const { width } = Dimensions.get('window');
type Props = { navigation: NativeStackNavigationProp<any> };
// ─── Mock data ───────────────────────────────────────────────────────────────
const MOCK_METRICS = [
{ id: 'temp', label: 'Température', value: '28°', unit: 'C', trend: 'up', color: Colors.ocean[500], icon: '🌡️' },
{ id: 'humidity', label: 'Humidité', value: '72', unit: '%', trend: 'down', color: Colors.primary[500], icon: '💧' },
{ id: 'aqi', label: 'Qualité Air', value: '42', unit: 'AQI', trend: 'up', color: Colors.success, icon: '🌬️' },
{ id: 'noise', label: 'Bruit', value: '55', unit: 'dB', trend: 'down', color: Colors.indigo[500], icon: '🔊' },
];
const MOCK_SENSORS = [
{ id: 's1', name: 'Capteur Centre-Ville', location: 'Fort-de-France', value: 28.3, unit: '°C', type: 'Température', icon: '🌡️', status: 'ok' },
{ id: 's2', name: 'Capteur Port', location: 'Baie de Fort-de-France', value: 72, unit: '%', type: 'Humidité', icon: '💧', status: 'ok' },
{ id: 's3', name: 'Capteur Qualité Air', location: 'Schoelcher', value: 42, unit: 'AQI', type: 'Air', icon: '🌬️', status: 'warning' },
{ id: 's4', name: 'Capteur Bruit', location: 'Centre-ville', value: 68, unit: 'dB', type: 'Son', icon: '🔊', status: 'alert' },
];
const QUICK_ACTIONS = [
{ id: 'signal', label: 'Signaler', icon: '🚨', color: Colors.danger, screen: 'Alerts' },
{ id: 'transport', label: 'Transport', icon: '🚌', color: Colors.primary[500], screen: 'Map' },
{ id: 'energie', label: 'Énergie', icon: '⚡', color: Colors.warning, screen: 'Sensors' },
{ id: 'sante', label: 'Santé', icon: '🏥', color: Colors.success, screen: 'Marketplace' },
];
const SERVICES = [
{ id: 'meteo', label: 'Météo', icon: '🌤️', color: Colors.ocean[500] },
{ id: 'events', label: 'Événements', icon: '🎉', color: Colors.indigo[500] },
{ id: 'wallet', label: 'Wallet', icon: '💳', color: Colors.primary[500] },
{ id: 'bus', label: 'Bus', icon: '🚌', color: Colors.ocean[600] },
];
// ─── Component ───────────────────────────────────────────────────────────────
export default function HomeScreen({ navigation }: Props) {
const [refreshing, setRefreshing] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 60000);
return () => clearInterval(timer);
}, []);
const onRefresh = async () => {
setRefreshing(true);
await new Promise((r) => setTimeout(r, 1500));
setRefreshing(false);
};
const greeting = () => {
const h = currentTime.getHours();
if (h < 12) return 'Bonjour';
if (h < 18) return 'Bon après-midi';
return 'Bonsoir';
};
return (
<ScrollView
style={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary[500]} />}
showsVerticalScrollIndicator={false}
>
{/* ── Header ── */}
<View style={styles.header}>
<View style={styles.headerTop}>
<View>
<Text style={styles.greeting}>{greeting()} 👋</Text>
<Text style={styles.userName}>Eric</Text>
</View>
<TouchableOpacity style={styles.notificationBtn}>
<Text style={styles.notificationIcon}>🔔</Text>
<View style={styles.notificationBadge}>
<Text style={styles.badgeText}>3</Text>
</View>
</TouchableOpacity>
</View>
{/* Search bar */}
<View style={styles.searchBar}>
<Text style={styles.searchIcon}>🔍</Text>
<Text style={styles.searchPlaceholder}>Rechercher un service, un lieu...</Text>
</View>
{/* Live indicator */}
<View style={styles.liveRow}>
<View style={styles.liveIndicator}>
<View style={styles.liveDot} />
<Text style={styles.liveText}>EN DIRECT</Text>
</View>
<Text style={styles.lastUpdate}>
{currentTime.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
</View>
{/* ── Metrics Row ── */}
<View style={styles.metricsRow}>
{MOCK_METRICS.map((m) => (
<View key={m.id} style={styles.metricCard}>
<Text style={styles.metricIcon}>{m.icon}</Text>
<Text style={styles.metricValue}>{m.value}</Text>
<Text style={styles.metricUnit}>{m.unit}</Text>
<Text style={styles.metricLabel}>{m.label}</Text>
<Text style={[styles.metricTrend, { color: m.trend === 'up' ? Colors.success : Colors.danger }]}>
{m.trend === 'up' ? '↑' : '↓'}
</Text>
</View>
))}
</View>
{/* ── Weather Widget ── */}
<View style={styles.weatherWidget}>
<View>
<Text style={styles.weatherTemp}>28°C</Text>
<Text style={styles.weatherDetails}>Fort-de-France</Text>
<Text style={styles.weatherDetails}>Ensoleillé Humidité 72%</Text>
<Text style={styles.weatherDetails}>Vent 12 km/h NE</Text>
</View>
<Text style={styles.weatherEmoji}></Text>
</View>
{/* ── Alert Banner ── */}
<TouchableOpacity style={styles.alertBanner}>
<Text style={styles.alertIcon}></Text>
<View style={styles.alertContent}>
<Text style={styles.alertText}>Alerte qualité air Schoelcher</Text>
<Text style={styles.alertSub}>AQI élevé détecté. Évitez les efforts prolongés en extérieur.</Text>
</View>
<Text style={styles.alertArrow}></Text>
</TouchableOpacity>
{/* ── Quick Actions ── */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Actions rapides</Text>
</View>
<View style={styles.quickActions}>
{QUICK_ACTIONS.map((a) => (
<TouchableOpacity key={a.id} style={styles.quickAction} onPress={() => navigation.navigate(a.screen)}>
<View style={[styles.quickActionIcon, { backgroundColor: a.color }]}>
<Text style={styles.quickActionEmoji}>{a.icon}</Text>
</View>
<Text style={styles.quickActionLabel}>{a.label}</Text>
</TouchableOpacity>
))}
</View>
{/* ── Sensors ── */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Capteurs en direct</Text>
<TouchableOpacity>
<Text style={styles.seeAll}>Voir tout </Text>
</TouchableOpacity>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.sensorsScroll}>
{MOCK_SENSORS.map((s) => (
<TouchableOpacity key={s.id} style={styles.sensorCard}>
<View style={styles.sensorHeader}>
<View style={[styles.sensorIcon, { backgroundColor: s.status === 'ok' ? Colors.primary[50] : s.status === 'warning' ? '#FFF3E0' : '#FFEBEE' }]}>
<Text style={styles.sensorEmoji}>{s.icon}</Text>
</View>
<View style={[styles.sensorStatus, {
backgroundColor: s.status === 'ok' ? Colors.success : s.status === 'warning' ? Colors.warning : Colors.danger,
}]} />
</View>
<Text style={styles.sensorValue}>{s.value}<Text style={styles.sensorUnit}> {s.unit}</Text></Text>
<Text style={styles.sensorName}>{s.name}</Text>
<Text style={styles.sensorLocation}>📍 {s.location}</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* ── Services ── */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Services</Text>
</View>
<View style={styles.servicesGrid}>
{SERVICES.map((s) => (
<TouchableOpacity key={s.id} style={styles.serviceShortcut}>
<View style={[styles.serviceIcon, { backgroundColor: s.color + '15' }]}>
<Text style={styles.serviceEmoji}>{s.icon}</Text>
</View>
<Text style={styles.serviceLabel}>{s.label}</Text>
</TouchableOpacity>
))}
</View>
<View style={{ height: 80 }} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
backgroundColor: Colors.primary[500],
paddingTop: 50,
paddingHorizontal: Spacing.base,
paddingBottom: Spacing.lg,
borderBottomLeftRadius: BorderRadius.xxl,
borderBottomRightRadius: BorderRadius.xxl,
},
headerTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: Spacing.base },
greeting: { fontSize: Typography.sizes.sm, color: 'rgba(255,255,255,0.85)' },
userName: { fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.white },
notificationBtn: { position: 'relative' },
notificationIcon: { fontSize: 24 },
notificationBadge: {
position: 'absolute', top: -4, right: -4,
backgroundColor: Colors.danger, borderRadius: 10,
width: 18, height: 18, justifyContent: 'center', alignItems: 'center',
},
badgeText: { color: Colors.white, fontSize: 10, fontWeight: '700' },
searchBar: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: BorderRadius.full,
paddingHorizontal: Spacing.base,
height: 40,
marginBottom: Spacing.sm,
},
searchIcon: { fontSize: 14, marginRight: Spacing.sm },
searchPlaceholder: { color: 'rgba(255,255,255,0.6)', fontSize: Typography.sizes.sm },
liveRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
liveIndicator: { flexDirection: 'row', alignItems: 'center', gap: 6 },
liveDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: Colors.success },
liveText: { color: Colors.success, fontSize: Typography.sizes.xs, fontWeight: Typography.weights.semibold },
lastUpdate: { color: 'rgba(255,255,255,0.6)', fontSize: Typography.sizes.xs },
// Metrics
metricsRow: {
flexDirection: 'row', gap: 8,
paddingHorizontal: Spacing.base,
marginTop: -20, marginBottom: Spacing.base,
},
metricCard: {
flex: 1, backgroundColor: Colors.white,
borderRadius: BorderRadius.lg, padding: Spacing.md,
alignItems: 'center', ...Shadows.md,
borderWidth: 1, borderColor: Colors.neutral100,
},
metricIcon: { fontSize: 18, marginBottom: 4 },
metricValue: { fontSize: Typography.sizes.xl, fontWeight: Typography.weights.bold, color: Colors.neutral900 },
metricUnit: { fontSize: 9, color: Colors.neutral500 },
metricLabel: { fontSize: 9, color: Colors.neutral500, marginTop: 2, textAlign: 'center' },
metricTrend: { fontSize: 12, marginTop: 2 },
// Weather
weatherWidget: {
marginHorizontal: Spacing.base, marginBottom: Spacing.base,
backgroundColor: Colors.primary[500],
borderRadius: BorderRadius.xl, padding: Spacing.base,
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
},
weatherTemp: { fontSize: Typography.sizes.xxxl, fontWeight: Typography.weights.bold, color: Colors.white },
weatherDetails: { fontSize: Typography.sizes.sm, color: 'rgba(255,255,255,0.8)', marginTop: 2 },
weatherEmoji: { fontSize: 48 },
// Alert
alertBanner: {
marginHorizontal: Spacing.base, marginBottom: Spacing.base,
backgroundColor: '#FFEBEE', borderRadius: BorderRadius.lg,
borderWidth: 1, borderColor: 'rgba(211,47,47,0.2)',
padding: Spacing.base, flexDirection: 'row', alignItems: 'center', gap: 10,
},
alertIcon: { fontSize: 20 },
alertContent: { flex: 1 },
alertText: { fontSize: Typography.sizes.sm, color: Colors.danger, fontWeight: Typography.weights.medium },
alertSub: { fontSize: Typography.sizes.xs, color: Colors.neutral600, marginTop: 2 },
alertArrow: { fontSize: 18, color: Colors.danger },
// Section
sectionHeader: {
flexDirection: 'row', justifyContent: 'space-between',
alignItems: 'center', paddingHorizontal: Spacing.base, paddingBottom: Spacing.sm,
},
sectionTitle: { fontSize: Typography.sizes.md, fontWeight: Typography.weights.bold, color: Colors.neutral900 },
seeAll: { fontSize: Typography.sizes.sm, color: Colors.primary[500], fontWeight: Typography.weights.medium },
// Quick Actions
quickActions: {
flexDirection: 'row', paddingHorizontal: Spacing.base,
gap: 12, marginBottom: Spacing.base,
},
quickAction: { flex: 1, alignItems: 'center', gap: 6 },
quickActionIcon: {
width: 52, height: 52, borderRadius: BorderRadius.lg,
justifyContent: 'center', alignItems: 'center', ...Shadows.sm,
},
quickActionEmoji: { fontSize: 22 },
quickActionLabel: { fontSize: 10, color: Colors.neutral600, textAlign: 'center' },
// Sensors
sensorsScroll: { paddingLeft: Spacing.base, marginBottom: Spacing.base },
sensorCard: {
width: 150, backgroundColor: Colors.white,
borderRadius: BorderRadius.lg, padding: Spacing.base,
marginRight: Spacing.sm, ...Shadows.sm,
borderWidth: 1, borderColor: Colors.neutral100,
},
sensorHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: Spacing.sm },
sensorIcon: { width: 36, height: 36, borderRadius: BorderRadius.md, justifyContent: 'center', alignItems: 'center' },
sensorEmoji: { fontSize: 18 },
sensorStatus: { width: 8, height: 8, borderRadius: 4 },
sensorValue: { fontSize: Typography.sizes.xl, fontWeight: Typography.weights.bold, color: Colors.neutral900 },
sensorUnit: { fontSize: Typography.sizes.xs, color: Colors.neutral500 },
sensorName: { fontSize: Typography.sizes.xs, color: Colors.neutral600, marginTop: 4 },
sensorLocation: { fontSize: 10, color: Colors.neutral400, marginTop: 2 },
// Services
servicesGrid: {
flexDirection: 'row', paddingHorizontal: Spacing.base,
gap: 10, marginBottom: Spacing.base,
},
serviceShortcut: { flex: 1, alignItems: 'center', gap: 6 },
serviceIcon: {
width: 48, height: 48, borderRadius: BorderRadius.lg,
justifyContent: 'center', alignItems: 'center',
},
serviceEmoji: { fontSize: 20 },
serviceLabel: { fontSize: 9, color: Colors.neutral600, textAlign: 'center', lineHeight: 14 },
});

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const LayerDetailScreen = () => {
return (
<View>
<Text>LayerDetailScreen</Text>
</View>
);
};
export default LayerDetailScreen;

View File

@@ -0,0 +1,133 @@
// Smart App City — Map Screen (Carte Interactive)
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../src/theme/colors';
type Props = { navigation: NativeStackNavigationProp<any> };
const LAYERS = [
{ id: 'sensors', label: 'Capteurs IoT', icon: '📡', active: true },
{ id: 'air', label: 'Qualité Air', icon: '🌬️', active: false },
{ id: 'noise', label: 'Bruit', icon: '🔊', active: false },
{ id: 'traffic', label: 'Trafic', icon: '🚗', active: false },
{ id: 'weather', label: 'Météo', icon: '🌤️', active: true },
{ id: 'events', label: 'Événements', icon: '🎉', active: false },
];
export default function MapScreen({ navigation }: Props) {
const [activeLayers, setActiveLayers] = useState<string[]>(['sensors', 'weather']);
const toggleLayer = (id: string) => {
setActiveLayers((prev) =>
prev.includes(id) ? prev.filter((l) => l !== id) : [...prev, id]
);
};
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Carte Interactive</Text>
<Text style={styles.subtitle}>Martinique Temps réel</Text>
</View>
{/* Map placeholder */}
<View style={styles.mapContainer}>
<View style={styles.mapPlaceholder}>
<Text style={styles.mapEmoji}>🗺</Text>
<Text style={styles.mapText}>Carte OpenStreetMap</Text>
<Text style={styles.mapSubtext}>react-native-maps Martinique</Text>
<Text style={styles.mapCoords}>14.6°N, 61.0°W</Text>
</View>
{/* Sensor markers mock */}
<View style={[styles.marker, { top: '30%', left: '40%' }]}>
<View style={[styles.markerDot, { backgroundColor: Colors.success }]} />
<Text style={styles.markerLabel}>28°C</Text>
</View>
<View style={[styles.marker, { top: '50%', left: '60%' }]}>
<View style={[styles.markerDot, { backgroundColor: Colors.warning }]} />
<Text style={styles.markerLabel}>AQI 42</Text>
</View>
<View style={[styles.marker, { top: '70%', left: '30%' }]}>
<View style={[styles.markerDot, { backgroundColor: Colors.danger }]} />
<Text style={styles.markerLabel}>68dB</Text>
</View>
</View>
{/* Layer toggles */}
<View style={styles.layerPanel}>
<Text style={styles.layerTitle}>Couches</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.layerScroll}>
{LAYERS.map((layer) => (
<TouchableOpacity
key={layer.id}
style={[
styles.layerChip,
activeLayers.includes(layer.id) && styles.layerChipActive,
]}
onPress={() => toggleLayer(layer.id)}
>
<Text style={styles.layerIcon}>{layer.icon}</Text>
<Text style={[
styles.layerLabel,
activeLayers.includes(layer.id) && styles.layerLabelActive,
]}>
{layer.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
backgroundColor: Colors.primary[500],
paddingTop: 50,
paddingBottom: Spacing.base,
paddingHorizontal: Spacing.base,
},
title: { fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.white },
subtitle: { fontSize: Typography.sizes.sm, color: 'rgba(255,255,255,0.8)' },
mapContainer: { flex: 1, position: 'relative' },
mapPlaceholder: {
flex: 1, justifyContent: 'center', alignItems: 'center',
backgroundColor: '#E8F5E9',
},
mapEmoji: { fontSize: 64, marginBottom: Spacing.base },
mapText: { fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.neutral700 },
mapSubtext: { fontSize: Typography.sizes.sm, color: Colors.neutral500, marginTop: Spacing.xs },
mapCoords: { fontSize: Typography.sizes.xs, color: Colors.neutral400, marginTop: Spacing.xs },
marker: { position: 'absolute', alignItems: 'center' },
markerDot: { width: 12, height: 12, borderRadius: 6, borderWidth: 2, borderColor: Colors.white },
markerLabel: {
fontSize: 10, fontWeight: '600', color: Colors.neutral700,
backgroundColor: 'rgba(255,255,255,0.9)',
paddingHorizontal: 4, paddingVertical: 1,
borderRadius: 4, marginTop: 2,
},
layerPanel: {
backgroundColor: Colors.white,
borderTopLeftRadius: BorderRadius.xl,
borderTopRightRadius: BorderRadius.xl,
padding: Spacing.base,
...Shadows.lg,
},
layerTitle: { fontSize: Typography.sizes.md, fontWeight: Typography.weights.bold, color: Colors.neutral900, marginBottom: Spacing.sm },
layerScroll: { flexDirection: 'row' },
layerChip: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.neutral100, borderRadius: BorderRadius.full,
paddingHorizontal: Spacing.base, paddingVertical: Spacing.xs,
marginRight: Spacing.sm, gap: 4,
},
layerChipActive: { backgroundColor: Colors.primary[500] },
layerIcon: { fontSize: 14 },
layerLabel: { fontSize: Typography.sizes.sm, color: Colors.neutral600 },
layerLabelActive: { color: Colors.white },
});

View File

@@ -0,0 +1,170 @@
// Smart App City — Alerts Screen
import React, { useState } from 'react';
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, RefreshControl,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../../src/theme/colors';
import { useAlerts } from '../../../hooks/useAlerts';
import SectionHeader from '../../common/Header';
type Props = { navigation: NativeStackNavigationProp<any> };
const SEVERITY_CONFIG = {
critical: { color: Colors.danger, icon: '🔴', label: 'Critique' },
high: { color: Colors.warning, icon: '🟠', label: 'Haute' },
medium: { color: '#FF9800', icon: '🟡', label: 'Moyenne' },
low: { color: Colors.info, icon: '🔵', label: 'Basse' },
};
export default function AlertsScreen({ navigation }: Props) {
const { alerts, activeAlerts, acknowledgeAlert, refresh } = useAlerts();
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<'all' | 'active' | 'acknowledged'>('active');
const filtered = filter === 'all' ? alerts : filter === 'active' ? activeAlerts : alerts.filter((a) => a.acknowledged);
const onRefresh = async () => {
setRefreshing(true);
await refresh();
setRefreshing(false);
};
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.backText}> Retour</Text>
</TouchableOpacity>
<Text style={styles.title}>Alertes</Text>
<View style={styles.badge}>
<Text style={styles.badgeText}>{activeAlerts.length}</Text>
</View>
</View>
{/* Filter tabs */}
<View style={styles.tabs}>
{[
{ key: 'active', label: 'Actives' },
{ key: 'all', label: 'Toutes' },
{ key: 'acknowledged', label: 'Traitées' },
].map((tab) => (
<TouchableOpacity
key={tab.key}
style={[styles.tab, filter === tab.key && styles.tabActive]}
onPress={() => setFilter(tab.key as any)}
>
<Text style={[styles.tabText, filter === tab.key && styles.tabTextActive]}>
{tab.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Alerts list */}
<ScrollView
style={styles.list}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary[500]} />}
showsVerticalScrollIndicator={false}
>
{filtered.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyEmoji}></Text>
<Text style={styles.emptyText}>Aucune alerte {filter === 'active' ? 'active' : ''}</Text>
</View>
) : (
filtered.map((alert) => {
const config = SEVERITY_CONFIG[alert.severity];
return (
<TouchableOpacity
key={alert.id}
style={[styles.alertCard, alert.acknowledged && { opacity: 0.6 }]}
onPress={() => !alert.acknowledged && acknowledgeAlert(alert.id)}
>
<View style={[styles.severityBar, { backgroundColor: config.color }]} />
<View style={styles.alertContent}>
<View style={styles.alertHeader}>
<Text style={styles.alertTitle}>
{config.icon} {alert.sensorName}
</Text>
<Text style={[styles.severityBadge, { color: config.color }]}>
{config.label}
</Text>
</View>
<Text style={styles.alertMessage}>{alert.message}</Text>
<View style={styles.alertFooter}>
<Text style={styles.alertTime}>
{new Date(alert.createdAt).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
})}
</Text>
{alert.value !== undefined && (
<Text style={styles.alertValue}>
Valeur: {alert.value}{alert.threshold ? ` (${alert.threshold})` : ''}
</Text>
)}
</View>
</View>
{!alert.acknowledged && <Text style={styles.checkIcon}></Text>}
{alert.acknowledged && <Text style={styles.checkIcon}></Text>}
</TouchableOpacity>
);
})
)}
<View style={{ height: 40 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.primary[500],
paddingTop: 50, paddingBottom: Spacing.base,
paddingHorizontal: Spacing.base,
gap: Spacing.base,
},
backText: { color: Colors.white, fontSize: Typography.sizes.base },
title: { flex: 1, fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.white },
badge: {
backgroundColor: Colors.danger, borderRadius: 12,
paddingHorizontal: Spacing.sm, paddingVertical: 2,
minWidth: 24, alignItems: 'center',
},
badgeText: { color: Colors.white, fontSize: Typography.sizes.sm, fontWeight: '700' },
tabs: {
flexDirection: 'row', backgroundColor: Colors.white,
paddingHorizontal: Spacing.base, paddingTop: Spacing.base,
gap: Spacing.sm,
},
tab: {
paddingHorizontal: Spacing.base, paddingVertical: Spacing.xs,
borderRadius: BorderRadius.full, backgroundColor: Colors.neutral100,
},
tabActive: { backgroundColor: Colors.primary[500] },
tabText: { fontSize: Typography.sizes.sm, color: Colors.neutral600 },
tabTextActive: { color: Colors.white, fontWeight: '600' },
list: { flex: 1, padding: Spacing.base },
empty: { alignItems: 'center', paddingTop: Spacing.xxxl },
emptyEmoji: { fontSize: 48, marginBottom: Spacing.base },
emptyText: { fontSize: Typography.sizes.base, color: Colors.neutral500 },
alertCard: {
flexDirection: 'row', backgroundColor: Colors.white,
borderRadius: BorderRadius.lg, marginBottom: Spacing.sm,
...Shadows.sm, overflow: 'hidden',
},
severityBar: { width: 4 },
alertContent: { flex: 1, padding: Spacing.base },
alertHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: Spacing.xs },
alertTitle: { fontSize: Typography.sizes.base, fontWeight: Typography.weights.bold, color: Colors.neutral900, flex: 1 },
severityBadge: { fontSize: Typography.sizes.xs, fontWeight: '600' },
alertMessage: { fontSize: Typography.sizes.sm, color: Colors.neutral600, marginBottom: Spacing.sm, lineHeight: 18 },
alertFooter: { flexDirection: 'row', justifyContent: 'space-between' },
alertTime: { fontSize: Typography.sizes.xs, color: Colors.neutral400 },
alertValue: { fontSize: Typography.sizes.xs, color: Colors.neutral500 },
checkIcon: { fontSize: 18, color: Colors.neutral400, padding: Spacing.base, alignSelf: 'center' },
});

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const SensorDetailScreen = () => {
return (
<View>
<Text>SensorDetailScreen</Text>
</View>
);
};
export default SensorDetailScreen;

View File

@@ -0,0 +1,157 @@
// Smart App City — Sensors Screen
import React, { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../../src/theme/colors';
import { useSensors } from '../../../hooks/useSensors';
type Props = { navigation: NativeStackNavigationProp<any> };
const TYPE_FILTERS = [
{ key: null, label: 'Tous', icon: '📡' },
{ key: 'temperature', label: 'Temp', icon: '🌡️' },
{ key: 'humidity', label: 'Humidité', icon: '💧' },
{ key: 'air_quality', label: 'Air', icon: '🌬️' },
{ key: 'noise', label: 'Bruit', icon: '🔊' },
{ key: 'traffic', label: 'Trafic', icon: '🚗' },
{ key: 'energy', label: 'Énergie', icon: '⚡' },
];
const STATUS_DOT = { ok: Colors.success, warning: Colors.warning, alert: Colors.danger, offline: Colors.neutral400 };
export default function SensorsScreen({ navigation }: Props) {
const { sensors, isLoading, refresh } = useSensors();
const [search, setSearch] = useState('');
const [selectedType, setSelectedType] = useState<string | null>(null);
const filtered = sensors.filter((s) => {
if (selectedType && s.type !== selectedType) return false;
if (search && !s.name.toLowerCase().includes(search.toLowerCase()) && !s.location.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.backText}> Retour</Text>
</TouchableOpacity>
<Text style={styles.title}>Capteurs IoT</Text>
<Text style={styles.count}>{sensors.length}</Text>
</View>
{/* Search */}
<View style={styles.searchBar}>
<Text style={styles.searchIcon}>🔍</Text>
<TextInput
style={styles.searchInput}
placeholder="Rechercher un capteur..."
placeholderTextColor={Colors.neutral400}
value={search}
onChangeText={setSearch}
/>
</View>
{/* Type filters */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filtersScroll}>
{TYPE_FILTERS.map((f) => (
<TouchableOpacity
key={f.key ?? 'all'}
style={[styles.filterChip, selectedType === f.key && styles.filterChipActive]}
onPress={() => setSelectedType(f.key)}
>
<Text style={styles.filterIcon}>{f.icon}</Text>
<Text style={[styles.filterLabel, selectedType === f.key && styles.filterLabelActive]}>{f.label}</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Sensors list */}
<ScrollView style={styles.list} showsVerticalScrollIndicator={false}>
{filtered.map((sensor) => (
<TouchableOpacity key={sensor.id} style={styles.sensorCard}>
<View style={[styles.sensorIconBg, { backgroundColor: Colors.primary[50] }]}>
<Text style={styles.sensorEmoji}>
{sensor.type === 'temperature' ? '🌡️' :
sensor.type === 'humidity' ? '💧' :
sensor.type === 'air_quality' ? '🌬️' :
sensor.type === 'noise' ? '🔊' :
sensor.type === 'traffic' ? '🚗' : '⚡'}
</Text>
</View>
<View style={styles.sensorInfo}>
<View style={styles.sensorHeader}>
<Text style={styles.sensorName}>{sensor.name}</Text>
<View style={[styles.statusDot, { backgroundColor: STATUS_DOT[sensor.status] }]} />
</View>
<Text style={styles.sensorLocation}>📍 {sensor.location}</Text>
<View style={styles.sensorRow}>
<Text style={styles.sensorValue}>
{sensor.value} <Text style={styles.sensorUnit}>{sensor.unit}</Text>
</Text>
<Text style={styles.sensorUpdate}>
{new Date(sensor.lastUpdate).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
</View>
</TouchableOpacity>
))}
<View style={{ height: 40 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.primary[500],
paddingTop: 50, paddingBottom: Spacing.base,
paddingHorizontal: Spacing.base, gap: Spacing.base,
},
backText: { color: Colors.white, fontSize: Typography.sizes.base },
title: { flex: 1, fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.white },
count: { color: 'rgba(255,255,255,0.8)', fontSize: Typography.sizes.base },
searchBar: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.white, marginHorizontal: Spacing.base,
marginTop: Spacing.base, borderRadius: BorderRadius.full,
paddingHorizontal: Spacing.base, height: 40,
...Shadows.sm,
},
searchIcon: { fontSize: 14, marginRight: Spacing.sm },
searchInput: { flex: 1, fontSize: Typography.sizes.sm, color: Colors.neutral900 },
filtersScroll: { padding: Spacing.base, paddingBottom: Spacing.sm },
filterChip: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.white, borderRadius: BorderRadius.full,
paddingHorizontal: Spacing.base, paddingVertical: Spacing.xs,
marginRight: Spacing.sm, gap: 4, ...Shadows.sm,
},
filterChipActive: { backgroundColor: Colors.primary[500] },
filterIcon: { fontSize: 14 },
filterLabel: { fontSize: Typography.sizes.sm, color: Colors.neutral600 },
filterLabelActive: { color: Colors.white },
list: { flex: 1, paddingHorizontal: Spacing.base },
sensorCard: {
flexDirection: 'row', backgroundColor: Colors.white,
borderRadius: BorderRadius.lg, padding: Spacing.base,
marginBottom: Spacing.sm, alignItems: 'center', gap: Spacing.base,
...Shadows.sm,
},
sensorIconBg: {
width: 48, height: 48, borderRadius: BorderRadius.lg,
justifyContent: 'center', alignItems: 'center',
},
sensorEmoji: { fontSize: 20 },
sensorInfo: { flex: 1 },
sensorHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
sensorName: { fontSize: Typography.sizes.base, fontWeight: Typography.weights.bold, color: Colors.neutral900, flex: 1 },
statusDot: { width: 8, height: 8, borderRadius: 4 },
sensorLocation: { fontSize: Typography.sizes.sm, color: Colors.neutral500, marginTop: 2 },
sensorRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 },
sensorValue: { fontSize: Typography.sizes.md, fontWeight: Typography.weights.bold, color: Colors.primary[500] },
sensorUnit: { fontSize: Typography.sizes.xs, fontWeight: '400', color: Colors.neutral500 },
sensorUpdate: { fontSize: Typography.sizes.xs, color: Colors.neutral400 },
});

View File

@@ -0,0 +1,84 @@
// Smart App City — Zones Screen
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../../src/theme/colors';
import { useIoTStore } from '../../../stores/iotStore';
type Props = { navigation: NativeStackNavigationProp<any> };
export default function ZonesScreen({ navigation }: Props) {
const zones = useIoTStore((s) => s.zones);
const sensors = useIoTStore((s) => s.sensors);
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.backText}> Retour</Text>
</TouchableOpacity>
<Text style={styles.title}>Zones</Text>
<Text style={styles.count}>{zones.length}</Text>
</View>
<ScrollView style={styles.list} showsVerticalScrollIndicator={false}>
{zones.map((zone) => {
const zoneSensors = sensors.filter((s) => s.zoneId === zone.id);
return (
<TouchableOpacity key={zone.id} style={styles.zoneCard}>
<View style={[styles.zoneColorBar, { backgroundColor: zone.color }]} />
<View style={styles.zoneContent}>
<Text style={styles.zoneName}>{zone.name}</Text>
<Text style={styles.zoneDesc}>{zone.description}</Text>
<View style={styles.zoneStats}>
<View style={styles.zoneStat}>
<Text style={styles.zoneStatValue}>{zoneSensors.length}</Text>
<Text style={styles.zoneStatLabel}>Capteurs</Text>
</View>
<View style={styles.zoneStat}>
<Text style={[styles.zoneStatValue, { color: zone.alertCount > 0 ? Colors.danger : Colors.success }]}>
{zone.alertCount}
</Text>
<Text style={styles.zoneStatLabel}>Alertes</Text>
</View>
<View style={styles.zoneStat}>
<Text style={styles.zoneStatValue}>{(zone.radius / 1000).toFixed(1)}km</Text>
<Text style={styles.zoneStatLabel}>Rayon</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
})}
<View style={{ height: 40 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.primary[500],
paddingTop: 50, paddingBottom: Spacing.base,
paddingHorizontal: Spacing.base, gap: Spacing.base,
},
backText: { color: Colors.white, fontSize: Typography.sizes.base },
title: { flex: 1, fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.white },
count: { color: 'rgba(255,255,255,0.8)', fontSize: Typography.sizes.base },
list: { flex: 1, padding: Spacing.base },
zoneCard: {
flexDirection: 'row', backgroundColor: Colors.white,
borderRadius: BorderRadius.lg, marginBottom: Spacing.sm,
...Shadows.sm, overflow: 'hidden',
},
zoneColorBar: { width: 4 },
zoneContent: { flex: 1, padding: Spacing.base },
zoneName: { fontSize: Typography.sizes.md, fontWeight: Typography.weights.bold, color: Colors.neutral900 },
zoneDesc: { fontSize: Typography.sizes.sm, color: Colors.neutral500, marginTop: 2, marginBottom: Spacing.sm },
zoneStats: { flexDirection: 'row', gap: Spacing.base },
zoneStat: { alignItems: 'center' },
zoneStatValue: { fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.primary[500] },
zoneStatLabel: { fontSize: Typography.sizes.xs, color: Colors.neutral400, marginTop: 2 },
});

View File

@@ -0,0 +1,155 @@
// Smart App City — Marketplace Screen
import React, { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../src/theme/colors';
type Props = { navigation: NativeStackNavigationProp<any> };
const CATEGORIES = [
{ id: 'all', label: 'Tous', icon: '🏪' },
{ id: 'food', label: 'Alimentation', icon: '🍎' },
{ id: 'transport', label: 'Transport', icon: '🚌' },
{ id: 'energy', label: 'Énergie', icon: '⚡' },
{ id: 'health', label: 'Santé', icon: '🏥' },
{ id: 'culture', label: 'Culture', icon: '🎭' },
];
const PRODUCTS = [
{ id: 'p1', name: 'Panier Bio Local', provider: 'AMAP Martinique', price: '15€', unit: '/semaine', category: 'food', icon: '🥬', rating: 4.8 },
{ id: 'p2', name: 'Pass Transport', provider: 'CFTA', price: '45€', unit: '/mois', category: 'transport', icon: '🎫', rating: 4.2 },
{ id: 'p3', name: 'Kit Solaire', provider: 'EDF DOM', price: '299€', unit: '', category: 'energy', icon: '☀️', rating: 4.6 },
{ id: 'p4', name: 'Consultation Télémédecine', provider: 'Santé+', price: '25€', unit: '/consult', category: 'health', icon: '🩺', rating: 4.5 },
{ id: 'p5', name: 'Billet Musée', provider: 'Musée de la Pagerie', price: '8€', unit: '', category: 'culture', icon: '🏛️', rating: 4.7 },
];
export default function MarketplaceScreen({ navigation }: Props) {
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const filtered = PRODUCTS.filter(
(p) =>
(selectedCategory === 'all' || p.category === selectedCategory) &&
(search === '' || p.name.toLowerCase().includes(search.toLowerCase()) || p.provider.toLowerCase().includes(search.toLowerCase()))
);
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Marketplace</Text>
<Text style={styles.subtitle}>Services & Produits locaux</Text>
<View style={styles.searchBar}>
<Text style={styles.searchIcon}>🔍</Text>
<TextInput
style={styles.searchInput}
placeholder="Rechercher un service..."
placeholderTextColor={Colors.neutral400}
value={search}
onChangeText={setSearch}
/>
</View>
</View>
{/* Categories */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.categoriesScroll}>
{CATEGORIES.map((cat) => (
<TouchableOpacity
key={cat.id}
style={[styles.categoryChip, selectedCategory === cat.id && styles.categoryChipActive]}
onPress={() => setSelectedCategory(cat.id)}
>
<Text style={styles.categoryIcon}>{cat.icon}</Text>
<Text style={[styles.categoryLabel, selectedCategory === cat.id && styles.categoryLabelActive]}>
{cat.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Products */}
<ScrollView style={styles.productsList} showsVerticalScrollIndicator={false}>
{filtered.map((product) => (
<TouchableOpacity key={product.id} style={styles.productCard}>
<View style={styles.productIcon}>
<Text style={styles.productEmoji}>{product.icon}</Text>
</View>
<View style={styles.productInfo}>
<Text style={styles.productName}>{product.name}</Text>
<Text style={styles.productProvider}>{product.provider}</Text>
<View style={styles.productRow}>
<Text style={styles.productPrice}>{product.price}<Text style={styles.productUnit}>{product.unit}</Text></Text>
<Text style={styles.productRating}> {product.rating}</Text>
</View>
</View>
<TouchableOpacity style={styles.addBtn}>
<Text style={styles.addBtnText}>+</Text>
</TouchableOpacity>
</TouchableOpacity>
))}
<View style={{ height: 80 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
backgroundColor: Colors.primary[500],
paddingTop: 50,
paddingBottom: Spacing.base,
paddingHorizontal: Spacing.base,
borderBottomLeftRadius: BorderRadius.xl,
borderBottomRightRadius: BorderRadius.xl,
},
title: { fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.white },
subtitle: { fontSize: Typography.sizes.sm, color: 'rgba(255,255,255,0.8)', marginBottom: Spacing.sm },
searchBar: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: BorderRadius.full,
paddingHorizontal: Spacing.base,
height: 36,
},
searchIcon: { fontSize: 14, marginRight: Spacing.sm },
searchInput: { flex: 1, color: Colors.white, fontSize: Typography.sizes.sm, height: 36 },
categoriesScroll: { padding: Spacing.base, paddingBottom: Spacing.sm },
categoryChip: {
alignItems: 'center', backgroundColor: Colors.white,
borderRadius: BorderRadius.lg, paddingHorizontal: Spacing.base,
paddingVertical: Spacing.sm, marginRight: Spacing.sm,
borderWidth: 1, borderColor: Colors.neutral200,
},
categoryChipActive: { backgroundColor: Colors.primary[500], borderColor: Colors.primary[500] },
categoryIcon: { fontSize: 20 },
categoryLabel: { fontSize: 10, color: Colors.neutral600, marginTop: 2 },
categoryLabelActive: { color: Colors.white },
productsList: { flex: 1, paddingHorizontal: Spacing.base },
productCard: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.white, borderRadius: BorderRadius.lg,
padding: Spacing.base, marginBottom: Spacing.sm,
...Shadows.sm, borderWidth: 1, borderColor: Colors.neutral100,
},
productIcon: {
width: 52, height: 52, borderRadius: BorderRadius.lg,
backgroundColor: Colors.primary[50],
justifyContent: 'center', alignItems: 'center',
marginRight: Spacing.base,
},
productEmoji: { fontSize: 24 },
productInfo: { flex: 1 },
productName: { fontSize: Typography.sizes.base, fontWeight: Typography.weights.bold, color: Colors.neutral900 },
productProvider: { fontSize: Typography.sizes.sm, color: Colors.neutral500, marginTop: 2 },
productRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 },
productPrice: { fontSize: Typography.sizes.md, fontWeight: Typography.weights.bold, color: Colors.primary[500] },
productUnit: { fontSize: Typography.sizes.xs, fontWeight: '400', color: Colors.neutral500 },
productRating: { fontSize: Typography.sizes.sm, color: Colors.neutral600 },
addBtn: {
width: 36, height: 36, borderRadius: BorderRadius.md,
backgroundColor: Colors.primary[500],
justifyContent: 'center', alignItems: 'center',
},
addBtnText: { color: Colors.white, fontSize: 20, fontWeight: '700' },
});

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { View, Text } from 'react-native';
const NotificationPrefsScreen = () => {
return (
<View>
<Text>NotificationPrefsScreen</Text>
</View>
);
};
export default NotificationPrefsScreen;

View File

@@ -0,0 +1,166 @@
// Smart App City — Notifications Screen
import React, { useState } from 'react';
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, RefreshControl,
} from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../../src/theme/colors';
import { useNotifications } from '../../../hooks/useAuth';
type Props = { navigation: NativeStackNavigationProp<any> };
const TYPE_CONFIG = {
alert: { icon: '🚨', color: Colors.danger },
info: { icon: '', color: Colors.info },
event: { icon: '🎉', color: Colors.indigo[500] },
system: { icon: '⚙️', color: Colors.neutral500 },
};
export default function NotificationsScreen({ navigation }: Props) {
const { notifications, unreadCount, markAsRead, markAllAsRead, removeNotification, clearAll } = useNotifications();
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<'all' | 'unread'>('all');
const filtered = filter === 'unread' ? notifications.filter((n) => !n.read) : notifications;
const onRefresh = async () => {
setRefreshing(true);
await new Promise((r) => setTimeout(r, 1000));
setRefreshing(false);
};
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.backText}> Retour</Text>
</TouchableOpacity>
<Text style={styles.title}>Notifications</Text>
{unreadCount > 0 && (
<TouchableOpacity onPress={markAllAsRead}>
<Text style={styles.markAll}>Tout lu</Text>
</TouchableOpacity>
)}
</View>
{/* Actions */}
<View style={styles.actions}>
<View style={styles.tabs}>
<TouchableOpacity
style={[styles.tab, filter === 'all' && styles.tabActive]}
onPress={() => setFilter('all')}
>
<Text style={[styles.tabText, filter === 'all' && styles.tabTextActive]}>
Toutes ({notifications.length})
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, filter === 'unread' && styles.tabActive]}
onPress={() => setFilter('unread')}
>
<Text style={[styles.tabText, filter === 'unread' && styles.tabTextActive]}>
Non lues ({unreadCount})
</Text>
</TouchableOpacity>
</View>
{notifications.length > 0 && (
<TouchableOpacity onPress={clearAll}>
<Text style={styles.clearAll}>Supprimer tout</Text>
</TouchableOpacity>
)}
</View>
{/* List */}
<ScrollView
style={styles.list}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={Colors.primary[500]} />}
showsVerticalScrollIndicator={false}
>
{filtered.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyEmoji}>🔔</Text>
<Text style={styles.emptyText}>Aucune notification</Text>
</View>
) : (
filtered.map((notif) => {
const config = TYPE_CONFIG[notif.type];
return (
<TouchableOpacity
key={notif.id}
style={[styles.notifCard, !notif.read && styles.notifUnread]}
onPress={() => markAsRead(notif.id)}
>
<Text style={styles.notifIcon}>{config.icon}</Text>
<View style={styles.notifContent}>
<View style={styles.notifHeader}>
<Text style={[styles.notifTitle, !notif.read && { fontWeight: '700' }]}>{notif.title}</Text>
<TouchableOpacity onPress={() => removeNotification(notif.id)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Text style={styles.removeIcon}></Text>
</TouchableOpacity>
</View>
<Text style={styles.notifBody} numberOfLines={2}>{notif.body}</Text>
<Text style={styles.notifTime}>
{new Date(notif.createdAt).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
})}
</Text>
</View>
{!notif.read && <View style={styles.unreadDot} />}
</TouchableOpacity>
);
})
)}
<View style={{ height: 40 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.primary[500],
paddingTop: 50, paddingBottom: Spacing.base,
paddingHorizontal: Spacing.base,
gap: Spacing.base,
},
backText: { color: Colors.white, fontSize: Typography.sizes.base },
title: { flex: 1, fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.white },
markAll: { color: Colors.white, fontSize: Typography.sizes.sm, fontWeight: '600' },
actions: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
backgroundColor: Colors.white,
paddingHorizontal: Spacing.base, paddingTop: Spacing.base,
},
tabs: { flexDirection: 'row', gap: Spacing.sm },
tab: {
paddingHorizontal: Spacing.base, paddingVertical: Spacing.xs,
borderRadius: BorderRadius.full, backgroundColor: Colors.neutral100,
},
tabActive: { backgroundColor: Colors.primary[500] },
tabText: { fontSize: Typography.sizes.sm, color: Colors.neutral600 },
tabTextActive: { color: Colors.white, fontWeight: '600' },
clearAll: { fontSize: Typography.sizes.sm, color: Colors.danger },
list: { flex: 1, padding: Spacing.base },
empty: { alignItems: 'center', paddingTop: Spacing.xxxl },
emptyEmoji: { fontSize: 48, marginBottom: Spacing.base },
emptyText: { fontSize: Typography.sizes.base, color: Colors.neutral500 },
notifCard: {
flexDirection: 'row', backgroundColor: Colors.white,
borderRadius: BorderRadius.lg, padding: Spacing.base,
marginBottom: Spacing.sm, ...Shadows.sm,
alignItems: 'flex-start', gap: Spacing.base,
},
notifUnread: { borderLeftWidth: 3, borderLeftColor: Colors.primary[500] },
notifIcon: { fontSize: 24, marginTop: 2 },
notifContent: { flex: 1 },
notifHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: Spacing.xs },
notifTitle: { fontSize: Typography.sizes.base, fontWeight: Typography.weights.semibold, color: Colors.neutral900, flex: 1 },
removeIcon: { fontSize: 14, color: Colors.neutral400, padding: Spacing.xs },
notifBody: { fontSize: Typography.sizes.sm, color: Colors.neutral600, lineHeight: 18, marginBottom: Spacing.xs },
notifTime: { fontSize: Typography.sizes.xs, color: Colors.neutral400 },
unreadDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: Colors.primary[500], marginTop: 4 },
});

View File

@@ -0,0 +1,187 @@
// Smart App City — Profile Screen
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Switch } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useAuthStore } from '../../stores/authStore';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../src/theme/colors';
type Props = { navigation: NativeStackNavigationProp<any> };
const MENU_ITEMS = [
{ id: 'notifications', label: 'Notifications', icon: '🔔', type: 'toggle' },
{ id: 'darkmode', label: 'Mode sombre', icon: '🌙', type: 'toggle' },
{ id: 'language', label: 'Langue', icon: '🌍', value: 'Français', type: 'nav' },
{ id: 'zones', label: 'Mes zones', icon: '📍', type: 'nav' },
{ id: 'sensors', label: 'Mes capteurs', icon: '📡', type: 'nav' },
{ id: 'history', label: 'Historique', icon: '📊', type: 'nav' },
{ id: 'privacy', label: 'Confidentialité', icon: '🔒', type: 'nav' },
{ id: 'help', label: 'Aide & Support', icon: '❓', type: 'nav' },
{ id: 'about', label: 'À propos', icon: '', type: 'nav' },
];
export default function ProfileScreen({ navigation }: Props) {
const [toggles, setToggles] = React.useState<Record<string, boolean>>({
notifications: true,
darkmode: false,
});
const logout = useAuthStore((s) => s.logout);
const user = useAuthStore((s) => s.user);
const handleToggle = (id: string) => {
setToggles((prev) => ({ ...prev, [id]: !prev[id] }));
};
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
{/* Header */}
<View style={styles.header}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>👤</Text>
</View>
<Text style={styles.name}>{user?.firstName ? `${user.firstName} ${user.lastName}` : 'Eric F.'}</Text>
<Text style={styles.email}>{user?.email ?? 'eric@digitribe.fr'}</Text>
<View style={styles.badgeRow}>
<View style={styles.badge}>
<Text style={styles.badgeText}>🏙 Citoyen</Text>
</View>
<View style={styles.badge}>
<Text style={styles.badgeText}> Vérifié</Text>
</View>
</View>
</View>
{/* Stats */}
<View style={styles.statsRow}>
<View style={styles.statCard}>
<Text style={styles.statValue}>12</Text>
<Text style={styles.statLabel}>Signalements</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>45</Text>
<Text style={styles.statLabel}>Points</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>5</Text>
<Text style={styles.statLabel}>Abonnements</Text>
</View>
</View>
{/* Menu */}
<View style={styles.menuSection}>
{MENU_ITEMS.map((item) => (
<TouchableOpacity key={item.id} style={styles.menuItem} disabled={item.type === 'toggle'}>
<View style={styles.menuLeft}>
<Text style={styles.menuIcon}>{item.icon}</Text>
<Text style={styles.menuLabel}>{item.label}</Text>
</View>
{item.type === 'toggle' ? (
<Switch
value={toggles[item.id] ?? false}
onValueChange={() => handleToggle(item.id)}
trackColor={{ false: Colors.neutral200, true: Colors.primary[300] }}
thumbColor={toggles[item.id] ? Colors.primary[500] : Colors.neutral400}
/>
) : (
<View style={styles.menuRight}>
{item.value && <Text style={styles.menuValue}>{item.value}</Text>}
<Text style={styles.menuArrow}></Text>
</View>
)}
</TouchableOpacity>
))}
</View>
{/* Logout */}
<TouchableOpacity style={styles.logoutBtn} onPress={logout}>
<Text style={styles.logoutIcon}>🚪</Text>
<Text style={styles.logoutText}>Se déconnecter</Text>
</TouchableOpacity>
<Text style={styles.version}>Smart App City v0.1.0</Text>
<View style={{ height: 40 }} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
backgroundColor: Colors.primary[500],
paddingTop: 50,
paddingBottom: Spacing.xxl,
paddingHorizontal: Spacing.xl,
alignItems: 'center',
borderBottomLeftRadius: BorderRadius.xxl,
borderBottomRightRadius: BorderRadius.xxl,
},
avatar: {
width: 80, height: 80, borderRadius: BorderRadius.xl,
backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center', alignItems: 'center',
marginBottom: Spacing.base,
},
avatarText: { fontSize: 40 },
name: { fontSize: Typography.sizes.xl, fontWeight: Typography.weights.bold, color: Colors.white },
email: { fontSize: Typography.sizes.sm, color: 'rgba(255,255,255,0.7)', marginTop: Spacing.xs },
badgeRow: { flexDirection: 'row', gap: Spacing.sm, marginTop: Spacing.base },
badge: {
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: BorderRadius.full,
paddingHorizontal: Spacing.base,
paddingVertical: Spacing.xs,
},
badgeText: { fontSize: Typography.sizes.sm, color: Colors.white },
statsRow: {
flexDirection: 'row',
paddingHorizontal: Spacing.base,
marginTop: -20,
marginBottom: Spacing.base,
gap: Spacing.sm,
},
statCard: {
flex: 1, backgroundColor: Colors.white,
borderRadius: BorderRadius.lg, padding: Spacing.base,
alignItems: 'center', ...Shadows.md,
},
statValue: { fontSize: Typography.sizes.xxl, fontWeight: Typography.weights.bold, color: Colors.primary[500] },
statLabel: { fontSize: Typography.sizes.xs, color: Colors.neutral500, marginTop: 2 },
menuSection: {
backgroundColor: Colors.white,
marginHorizontal: Spacing.base,
borderRadius: BorderRadius.xl,
...Shadows.sm,
overflow: 'hidden',
},
menuItem: {
flexDirection: 'row', justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: Spacing.base,
paddingVertical: Spacing.base,
borderBottomWidth: 1, borderBottomColor: Colors.neutral100,
},
menuLeft: { flexDirection: 'row', alignItems: 'center', gap: Spacing.base },
menuIcon: { fontSize: 20 },
menuLabel: { fontSize: Typography.sizes.base, color: Colors.neutral900 },
menuRight: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm },
menuValue: { fontSize: Typography.sizes.sm, color: Colors.neutral500 },
menuArrow: { fontSize: 22, color: Colors.neutral300 },
logoutBtn: {
flexDirection: 'row', alignItems: 'center',
justifyContent: 'center',
backgroundColor: Colors.white,
marginHorizontal: Spacing.base,
marginTop: Spacing.base,
borderRadius: BorderRadius.lg,
paddingVertical: Spacing.base,
gap: Spacing.sm,
borderWidth: 1, borderColor: '#FFCDD2',
},
logoutIcon: { fontSize: 18 },
logoutText: { fontSize: Typography.sizes.base, color: Colors.danger, fontWeight: Typography.weights.medium },
version: {
textAlign: 'center',
fontSize: Typography.sizes.xs,
color: Colors.neutral400,
marginTop: Spacing.base,
},
});

View File

@@ -0,0 +1,121 @@
// Smart App City — Settings Screen
import React, { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, Switch, StyleSheet } from 'react-native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Colors, Typography, Spacing, BorderRadius, Shadows } from '../../../../src/theme/colors';
import { useUIStore, Language } from '../../../stores/uiStore';
type Props = { navigation: NativeStackNavigationProp<any> };
export default function SettingsScreen({ navigation }: Props) {
const language = useUIStore((s) => s.language);
const setLanguage = useUIStore((s) => s.setLanguage);
const [showLangPicker, setShowLangPicker] = useState(false);
const languages: { key: Language; label: string; flag: string }[] = [
{ key: 'fr', label: 'Français', flag: '🇫🇷' },
{ key: 'en', label: 'English', flag: '🇬🇧' },
{ key: 'es', label: 'Español', flag: '🇪🇸' },
{ key: 'de', label: 'Deutsch', flag: '🇩🇪' },
];
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.backText}> Retour</Text>
</TouchableOpacity>
<Text style={styles.title}>Paramètres</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Language */}
<TouchableOpacity style={styles.row} onPress={() => setShowLangPicker(!showLangPicker)}>
<Text style={styles.rowIcon}>🌍</Text>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Langue</Text>
<Text style={styles.rowValue}>{languages.find((l) => l.key === language)?.flag} {languages.find((l) => l.key === language)?.label}</Text>
</View>
<Text style={styles.rowArrow}></Text>
</TouchableOpacity>
{showLangPicker && (
<View style={styles.langPicker}>
{languages.map((lang) => (
<TouchableOpacity
key={lang.key}
style={[styles.langOption, language === lang.key && styles.langOptionActive]}
onPress={() => { setLanguage(lang.key); setShowLangPicker(false); }}
>
<Text style={styles.langFlag}>{lang.flag}</Text>
<Text style={[styles.langLabel, language === lang.key && styles.langLabelActive]}>{lang.label}</Text>
{language === lang.key && <Text style={styles.langCheck}></Text>}
</TouchableOpacity>
))}
</View>
)}
<View style={styles.divider} />
{/* Privacy */}
<TouchableOpacity style={styles.row}>
<Text style={styles.rowIcon}>🔒</Text>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Confidentialité</Text>
<Text style={styles.rowValue}>Gérer vos données</Text>
</View>
<Text style={styles.rowArrow}></Text>
</TouchableOpacity>
{/* About */}
<TouchableOpacity style={styles.row}>
<Text style={styles.rowIcon}></Text>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>À propos</Text>
<Text style={styles.rowValue}>v0.1.0</Text>
</View>
<Text style={styles.rowArrow}></Text>
</TouchableOpacity>
<View style={{ height: 40 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: Colors.neutral50 },
header: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.primary[500],
paddingTop: 50, paddingBottom: Spacing.base,
paddingHorizontal: Spacing.base, gap: Spacing.base,
},
backText: { color: Colors.white, fontSize: Typography.sizes.base },
title: { flex: 1, fontSize: Typography.sizes.lg, fontWeight: Typography.weights.bold, color: Colors.white },
row: {
flexDirection: 'row', alignItems: 'center',
backgroundColor: Colors.white,
paddingHorizontal: Spacing.base,
paddingVertical: Spacing.base,
borderBottomWidth: 1, borderBottomColor: Colors.neutral100,
gap: Spacing.base,
},
rowIcon: { fontSize: 20 },
rowContent: { flex: 1 },
rowLabel: { fontSize: Typography.sizes.base, color: Colors.neutral900 },
rowValue: { fontSize: Typography.sizes.sm, color: Colors.neutral500, marginTop: 2 },
rowArrow: { fontSize: 22, color: Colors.neutral300 },
divider: { height: Spacing.base, backgroundColor: Colors.neutral100 },
langPicker: { backgroundColor: Colors.white, paddingHorizontal: Spacing.base },
langOption: {
flexDirection: 'row', alignItems: 'center',
paddingVertical: Spacing.base, gap: Spacing.base,
borderBottomWidth: 1, borderBottomColor: Colors.neutral100,
},
langOptionActive: { backgroundColor: Colors.primary[50] },
langFlag: { fontSize: 20 },
langLabel: { flex: 1, fontSize: Typography.sizes.base, color: Colors.neutral900 },
langLabelActive: { fontWeight: '700', color: Colors.primary[500] },
langCheck: { fontSize: 16, color: Colors.primary[500], fontWeight: '700' },
});

View File

@@ -0,0 +1,229 @@
/**
* API Client — Axios instance with JWT interceptors & automatic token refresh.
*
* Usage:
* import { api, get, post, put, delete } from '@/services/api';
* const { data } = await get<Sensor[]>('/sensors');
* const { data } = await post<Sensor>('/sensors', { name: 'PM2.5' });
*/
import axios, {
AxiosError,
AxiosInstance,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Base URL for every API call points to the production gateway. */
export const API_BASE_URL = 'https://api-smartapp.digitribe.fr/api/v1';
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
/** Timeout for every request in milliseconds. */
const REQUEST_TIMEOUT_MS = 15_000;
/** HTTP status codes that trigger special handling. */
const HTTP_UNAUTHORIZED = 401;
const HTTP_FORBIDDEN = 403;
const HTTP_NOT_FOUND = 404;
const HTTP_SERVER_ERROR = 500;
// ---------------------------------------------------------------------------
// Token helpers (AsyncStorage-backed)
// ---------------------------------------------------------------------------
const getToken = async (): Promise<string | null> =>
AsyncStorage.getItem(ACCESS_TOKEN_KEY);
const getRefreshToken = async (): Promise<string | null> =>
AsyncStorage.getItem(REFRESH_TOKEN_KEY);
const setTokens = async (access: string, refresh?: string): Promise<void> => {
await AsyncStorage.setItem(ACCESS_TOKEN_KEY, access);
if (refresh) {
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refresh);
}
};
const clearTokens = async (): Promise<void> => {
await AsyncStorage.multiRemove([ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY]);
};
// ---------------------------------------------------------------------------
// Axios instance
// ---------------------------------------------------------------------------
export const api: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: REQUEST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
// ---------------------------------------------------------------------------
// Request interceptor — attach Bearer token
// ---------------------------------------------------------------------------
api.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const token = await getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error: AxiosError) => Promise.reject(error),
);
// ---------------------------------------------------------------------------
// Response interceptor — error handling + automatic token refresh
// ---------------------------------------------------------------------------
/** Queue of failed requests that will be retried after a token refresh. */
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: AxiosError) => void;
}> = [];
const processQueue = (error: AxiosError | null, token: string | null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token as string);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// -----------------------------------------------------------------------
// 401 Unauthorized → attempt silent token refresh
// -----------------------------------------------------------------------
if (
error.response?.status === HTTP_UNAUTHORIZED &&
!originalRequest._retry &&
originalRequest.url &&
// Avoid infinite loop on the refresh endpoint itself
!originalRequest.url.includes('/auth/refresh')
) {
if (isRefreshing) {
// Someone else is already refreshing queue this request
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
},
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = await getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
// Call the refresh endpoint
const { data } = await axios.post<{ accessToken: string; refreshToken?: string }>(
`${API_BASE_URL}/auth/refresh`,
{ refreshToken },
);
const newAccess = data.accessToken;
const newRefresh = data.refreshToken ?? refreshToken;
await setTokens(newAccess, newRefresh);
// Update the default header so subsequent requests use the new token
api.defaults.headers.common.Authorization = `Bearer ${newAccess}`;
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
processQueue(null, newAccess);
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError as AxiosError, null);
await clearTokens();
// Optionally: navigate to login screen via an event or navigation ref
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// -----------------------------------------------------------------------
// 403 Forbidden (valid token but insufficient permissions)
// -----------------------------------------------------------------------
if (error.response?.status === HTTP_FORBIDDEN) {
// Consumer can handle (e.g. show "access denied" toast)
return Promise.reject(error);
}
// -----------------------------------------------------------------------
// 404 Not Found
// -----------------------------------------------------------------------
if (error.response?.status === HTTP_NOT_FOUND) {
return Promise.reject(error);
}
// -----------------------------------------------------------------------
// 500 Server Error
// -----------------------------------------------------------------------
if (
error.response?.status === HTTP_SERVER_ERROR ||
(error.response?.status && error.response.status >= 500)
) {
// Consumer can handle (e.g. show "server error" toast, retry UI)
return Promise.reject(error);
}
// -----------------------------------------------------------------------
// Any other error
// -----------------------------------------------------------------------
return Promise.reject(error);
},
);
// ---------------------------------------------------------------------------
// Typed helper functions
// ---------------------------------------------------------------------------
/** Generic GET request. */
export const get = <T>(url: string, params?: object, config = {}) =>
api.get<T>(url, { params, ...config }).then((r) => r.data);
/** Generic POST request. */
export const post = <T>(url: string, data?: object, config = {}) =>
api.post<T>(url, data, config).then((r) => r.data);
/** Generic PUT request. */
export const put = <T>(url: string, data?: object, config = {}) =>
api.put<T>(url, data, config).then((r) => r.data);
/** Generic DELETE request. */
export const del = <T>(url: string, config = {}) =>
api.delete<T>(url, config).then((r) => r.data);
// Default export for convenience
export default api;

View File

@@ -0,0 +1,96 @@
import { useAuthStore } from '../stores/authStore';
import type { LoginResponse, RegisterData, User } from '../stores/authStore';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:8000';
export const authService = {
async login(email: string, password: string): Promise<LoginResponse> {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? body.error ?? `Login failed (${res.status})`);
}
const data: LoginResponse = await res.json();
// Sync with store
useAuthStore.setState({
user: data.user,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
isAuthenticated: true,
});
return data;
},
async register(data: RegisterData): Promise<LoginResponse> {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? body.error ?? `Registration failed (${res.status})`);
}
const result: LoginResponse = await res.json();
useAuthStore.setState({
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
isAuthenticated: true,
});
return result;
},
async logout(): Promise<void> {
const { accessToken } = useAuthStore.getState();
if (accessToken) {
try {
await fetch(`${API_BASE_URL}/api/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
});
} catch { /* ignore */ }
}
useAuthStore.setState({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
});
},
async refreshToken(): Promise<LoginResponse | null> {
const { refreshToken } = useAuthStore.getState();
if (!refreshToken) return null;
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
useAuthStore.setState({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
error: 'Session expired',
});
return null;
}
const data: LoginResponse = await res.json();
useAuthStore.setState({
user: data.user,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
isAuthenticated: true,
});
return data;
},
};

View File

@@ -0,0 +1 @@
// TODO: Implement

View File

@@ -0,0 +1,41 @@
// Smart App City — IoT Service
import { get, post, put, del } from './api';
import type { Sensor, Zone, Alert } from '../stores/iotStore';
export const iotService = {
async getSensors(): Promise<Sensor[]> {
return get<Sensor[]>('/sensors');
},
async getSensor(id: string): Promise<Sensor> {
return get<Sensor>(`/sensors/${id}`);
},
async getZones(): Promise<Zone[]> {
return get<Zone[]>('/zones');
},
async getZone(id: string): Promise<Zone> {
return get<Zone>(`/zones/${id}`);
},
async getAlerts(): Promise<Alert[]> {
return get<Alert[]>('/alerts');
},
async acknowledgeAlert(id: string): Promise<void> {
return put(`/alerts/${id}/acknowledge`);
},
async createSensor(data: Partial<Sensor>): Promise<Sensor> {
return post<Sensor>('/sensors', data);
},
async updateSensor(id: string, data: Partial<Sensor>): Promise<Sensor> {
return put<Sensor>(`/sensors/${id}`, data);
},
async deleteSensor(id: string): Promise<void> {
return del(`/sensors/${id}`);
},
};

View File

@@ -0,0 +1,38 @@
// Smart App City — Notification Service
import { get, post, del } from './api';
import type { AppNotification } from '../stores/notificationStore';
export const notificationService = {
async getNotifications(): Promise<AppNotification[]> {
return get<AppNotification[]>('/notifications');
},
async markAsRead(id: string): Promise<void> {
return post(`/notifications/${id}/read`);
},
async markAllAsRead(): Promise<void> {
return post('/notifications/read-all');
},
async deleteNotification(id: string): Promise<void> {
return del(`/notifications/${id}`);
},
async clearAll(): Promise<void> {
return del('/notifications');
},
async registerPushToken(token: string): Promise<void> {
return post('/notifications/push-token', { token });
},
async updatePreferences(prefs: {
pushEnabled: boolean;
alertNotifications: boolean;
eventNotifications: boolean;
systemNotifications: boolean;
}): Promise<void> {
return post('/notifications/preferences', prefs);
},
};

View File

@@ -0,0 +1,248 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface User {
id: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
createdAt?: string;
updatedAt?: string;
}
export interface LoginResponse {
user: User;
accessToken: string;
refreshToken: string;
}
export interface RegisterData {
email: string;
password: string;
firstName: string;
lastName: string;
}
export interface AuthState {
// State
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
// Actions
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
refreshAccessToken: () => Promise<void>;
clearError: () => void;
setUser: (user: User) => void;
}
// ─── API base URL ────────────────────────────────────────────────────────────
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:8000';
// ─── Auth store ──────────────────────────────────────────────────────────────
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
// ── Initial state ────────────────────────────────────────────────────
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
// ── login ───────────────────────────────────────────────────────────
login: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
body.message ?? body.error ?? `Login failed (${response.status})`,
);
}
const data: LoginResponse = await response.json();
set({
user: data.user,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
isAuthenticated: true,
isLoading: false,
error: null,
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'An unexpected error occurred';
set({ isLoading: false, error: message, isAuthenticated: false });
throw err;
}
},
// ── register ────────────────────────────────────────────────────────
register: async (data: RegisterData) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
body.message ?? body.error ?? `Registration failed (${response.status})`,
);
}
const result: LoginResponse = await response.json();
set({
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
isAuthenticated: true,
isLoading: false,
error: null,
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'An unexpected error occurred';
set({ isLoading: false, error: message, isAuthenticated: false });
throw err;
}
},
// ── logout ──────────────────────────────────────────────────────────
logout: async () => {
const { accessToken } = get();
// Best-effort server-side invalidate
if (accessToken) {
try {
await fetch(`${API_BASE_URL}/api/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
});
} catch {
// Ignore network errors during logout
}
}
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
},
// ── refreshAccessToken ──────────────────────────────────────────────
refreshAccessToken: async () => {
const { refreshToken } = get();
if (!refreshToken) {
set({ isAuthenticated: false, isLoading: false });
return;
}
set({ isLoading: true });
try {
const response = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
// Refresh failed force logout
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: 'Session expired. Please log in again.',
});
return;
}
const data: LoginResponse = await response.json();
set({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
user: data.user,
isAuthenticated: true,
isLoading: false,
error: null,
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Token refresh failed';
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: message,
});
}
},
// ── clearError ──────────────────────────────────────────────────────
clearError: () => set({ error: null }),
// ── setUser ─────────────────────────────────────────────────────────
setUser: (user: User) => set({ user }),
}),
{
name: 'auth-storage', // AsyncStorage key
storage: createJSONStorage(() => AsyncStorage),
// Only persist tokens + user, not transient state
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
// Rehydrate: restore authenticated flag from token presence
onRehydrateStorage: () => (state) => {
if (state?.accessToken) {
state.isAuthenticated = true;
}
},
},
),
);
// ─── Selector exports (optional convenience) ─────────────────────────────────
export const selectUser = (state: AuthState) => state.user;
export const selectIsAuthenticated = (state: AuthState) => state.isAuthenticated;
export const selectIsLoading = (state: AuthState) => state.isLoading;
export const selectAuthError = (state: AuthState) => state.error;
export const selectAccessToken = (state: AuthState) => state.accessToken;

View File

@@ -0,0 +1,148 @@
// Smart App City — IoT Store (Zustand)
// Manages sensors, zones, and real-time IoT data
import { create } from 'zustand';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface Sensor {
id: string;
name: string;
type: 'temperature' | 'humidity' | 'air_quality' | 'noise' | 'traffic' | 'energy';
location: string;
latitude: number;
longitude: number;
value: number;
unit: string;
status: 'ok' | 'warning' | 'alert' | 'offline';
lastUpdate: string;
zoneId?: string;
}
export interface Zone {
id: string;
name: string;
description: string;
latitude: number;
longitude: number;
radius: number; // meters
sensorCount: number;
alertCount: number;
color: string;
}
export interface Alert {
id: string;
sensorId: string;
sensorName: string;
type: 'threshold' | 'offline' | 'anomaly';
severity: 'low' | 'medium' | 'high' | 'critical';
message: string;
value?: number;
threshold?: number;
createdAt: string;
acknowledged: boolean;
}
export interface IoTState {
sensors: Sensor[];
zones: Zone[];
alerts: Alert[];
selectedSensorId: string | null;
selectedZoneId: string | null;
isLoading: boolean;
error: string | null;
lastRefresh: Date | null;
// Actions
setSensors: (sensors: Sensor[]) => void;
setZones: (zones: Zone[]) => void;
setAlerts: (alerts: Alert[]) => void;
selectSensor: (id: string | null) => void;
selectZone: (id: string | null) => void;
acknowledgeAlert: (id: string) => void;
updateSensorValue: (id: string, value: number, status: Sensor['status']) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
refresh: () => Promise<void>;
}
// ─── Mock Data ───────────────────────────────────────────────────────────────
const MOCK_SENSORS: Sensor[] = [
{ id: 's1', name: 'Capteur Centre-Ville', type: 'temperature', location: 'Fort-de-France', latitude: 14.6161, longitude: -61.0588, value: 28.3, unit: '°C', status: 'ok', lastUpdate: new Date().toISOString(), zoneId: 'z1' },
{ id: 's2', name: 'Capteur Port', type: 'humidity', location: 'Baie de Fort-de-France', latitude: 14.6077, longitude: -61.0703, value: 72, unit: '%', status: 'ok', lastUpdate: new Date().toISOString(), zoneId: 'z1' },
{ id: 's3', name: 'Capteur Qualité Air', type: 'air_quality', location: 'Schoelcher', latitude: 14.6150, longitude: -61.0980, value: 85, unit: 'AQI', status: 'warning', lastUpdate: new Date().toISOString(), zoneId: 'z2' },
{ id: 's4', name: 'Capteur Bruit', type: 'noise', location: 'Centre-ville', latitude: 14.6161, longitude: -61.0588, value: 68, unit: 'dB', status: 'alert', lastUpdate: new Date().toISOString(), zoneId: 'z1' },
{ id: 's5', name: 'Capteur Trafic', type: 'traffic', location: 'A501', latitude: 14.6200, longitude: -61.0400, value: 145, unit: 'veh/h', status: 'ok', lastUpdate: new Date().toISOString(), zoneId: 'z3' },
{ id: 's6', name: 'Capteur Énergie', type: 'energy', location: 'ZAC Rivière Roche', latitude: 14.6300, longitude: -61.0200, value: 2340, unit: 'kWh', status: 'ok', lastUpdate: new Date().toISOString(), zoneId: 'z3' },
];
const MOCK_ZONES: Zone[] = [
{ id: 'z1', name: 'Centre-ville FDF', description: 'Fort-de-France centre', latitude: 14.6161, longitude: -61.0588, radius: 2000, sensorCount: 3, alertCount: 1, color: '#1565C0' },
{ id: 'z2', name: 'Schoelcher', description: 'Zone Schoelcher', latitude: 14.6150, longitude: -61.0980, radius: 1500, sensorCount: 1, alertCount: 1, color: '#F57C00' },
{ id: 'z3', name: 'Zone Nord', description: 'Zone nord Martinique', latitude: 14.6300, longitude: -61.0200, radius: 3000, sensorCount: 2, alertCount: 0, color: '#2E7D32' },
];
const MOCK_ALERTS: Alert[] = [
{ id: 'a1', sensorId: 's3', sensorName: 'Capteur Qualité Air', type: 'threshold', severity: 'high', message: 'AQI élevé détecté à Schoelcher. Évitez les efforts prolongés.', value: 85, threshold: 50, createdAt: new Date(Date.now() - 3600000).toISOString(), acknowledged: false },
{ id: 'a2', sensorId: 's4', sensorName: 'Capteur Bruit', type: 'threshold', severity: 'medium', message: 'Niveau sonore élevé détecté en centre-ville.', value: 68, threshold: 55, createdAt: new Date(Date.now() - 7200000).toISOString(), acknowledged: false },
];
// ─── Store ───────────────────────────────────────────────────────────────────
export const useIoTStore = create<IoTState>((set, get) => ({
sensors: MOCK_SENSORS,
zones: MOCK_ZONES,
alerts: MOCK_ALERTS,
selectedSensorId: null,
selectedZoneId: null,
isLoading: false,
error: null,
lastRefresh: null,
setSensors: (sensors) => set({ sensors }),
setZones: (zones) => set({ zones }),
setAlerts: (alerts) => set({ alerts }),
selectSensor: (id) => set({ selectedSensorId: id }),
selectZone: (id) => set({ selectedZoneId: id }),
acknowledgeAlert: (id) =>
set((state) => ({
alerts: state.alerts.map((a) => (a.id === id ? { ...a, acknowledged: true } : a)),
})),
updateSensorValue: (id, value, status) =>
set((state) => ({
sensors: state.sensors.map((s) =>
s.id === id ? { ...s, value, status, lastUpdate: new Date().toISOString() } : s
),
})),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
refresh: async () => {
set({ isLoading: true, error: null });
try {
// Simulate API call
await new Promise((r) => setTimeout(r, 1000));
// In production: const data = await iotService.getSensors();
set({ lastRefresh: new Date(), isLoading: false });
} catch (err: any) {
set({ error: err.message, isLoading: false });
}
},
}));
// ─── Selectors ───────────────────────────────────────────────────────────────
export const selectSensorsByZone = (state: IoTState, zoneId: string) =>
state.sensors.filter((s) => s.zoneId === zoneId);
export const selectActiveAlerts = (state: IoTState) =>
state.alerts.filter((a) => !a.acknowledged);
export const selectSensorsByStatus = (state: IoTState, status: Sensor['status']) =>
state.sensors.filter((s) => s.status === status);

View File

@@ -0,0 +1,92 @@
// Smart App City — Notification Store (Zustand)
import { create } from 'zustand';
export interface AppNotification {
id: string;
title: string;
body: string;
type: 'alert' | 'info' | 'event' | 'system';
priority: 'low' | 'normal' | 'high';
read: boolean;
createdAt: string;
data?: Record<string, any>;
}
export interface NotificationState {
notifications: AppNotification[];
unreadCount: number;
pushEnabled: boolean;
alertNotifications: boolean;
eventNotifications: boolean;
systemNotifications: boolean;
// Actions
addNotification: (notification: AppNotification) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
clearAll: () => void;
setPushEnabled: (enabled: boolean) => void;
setAlertNotifications: (enabled: boolean) => void;
setEventNotifications: (enabled: boolean) => void;
setSystemNotifications: (enabled: boolean) => void;
}
const MOCK_NOTIFICATIONS: AppNotification[] = [
{ id: 'n1', title: 'Alerte Qualité Air', body: 'AQI élevé à Schoelcher (85). Évitez les efforts prolongés.', type: 'alert', priority: 'high', read: false, createdAt: new Date(Date.now() - 1800000).toISOString() },
{ id: 'n2', title: 'Qualité Air — Schoelcher', body: 'Seuil dépassé : 85 AQI détecté.', type: 'alert', priority: 'high', read: false, createdAt: new Date(Date.now() - 3600000).toISOString() },
{ id: 'n3', title: 'Nouveau service disponible', body: 'Le service de suivi bus en temps réel est maintenant disponible !', type: 'info', priority: 'normal', read: true, createdAt: new Date(Date.now() - 86400000).toISOString() },
{ id: 'n4', title: 'Événement : Fête de la Musique', body: 'Rendez-vous le 21 juin au centre-ville de Fort-de-France.', type: 'event', priority: 'normal', read: false, createdAt: new Date(Date.now() - 172800000).toISOString() },
{ id: 'n5', title: 'Mise à jour disponible', body: 'Smart App City v0.2.0 est disponible. Mettez à jour !', type: 'system', priority: 'low', read: true, createdAt: new Date(Date.now() - 259200000).toISOString() },
];
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: MOCK_NOTIFICATIONS,
unreadCount: MOCK_NOTIFICATIONS.filter((n) => !n.read).length,
pushEnabled: true,
alertNotifications: true,
eventNotifications: true,
systemNotifications: true,
addNotification: (notification) =>
set((state) => ({
notifications: [notification, ...state.notifications],
unreadCount: state.unreadCount + (notification.read ? 0 : 1),
})),
markAsRead: (id) =>
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id && !n.read ? { ...n, read: true } : n
),
unreadCount: Math.max(0, state.unreadCount - (state.notifications.find((n) => n.id === id && !n.read) ? 1 : 0)),
})),
markAllAsRead: () =>
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, read: true })),
unreadCount: 0,
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
unreadCount: state.notifications.find((n) => n.id === id && !n.read)
? Math.max(0, state.unreadCount - 1)
: state.unreadCount,
})),
clearAll: () => set({ notifications: [], unreadCount: 0 }),
setPushEnabled: (pushEnabled) => set({ pushEnabled }),
setAlertNotifications: (alertNotifications) => set({ alertNotifications }),
setEventNotifications: (eventNotifications) => set({ eventNotifications }),
setSystemNotifications: (systemNotifications) => set({ systemNotifications }),
}));
export const selectUnreadNotifications = (state: NotificationState) =>
state.notifications.filter((n) => !n.read);
export const selectNotificationsByType = (state: NotificationState, type: AppNotification['type']) =>
state.notifications.filter((n) => n.type === type);

View File

@@ -0,0 +1,177 @@
// Smart App City — UI Store (Zustand)
// Manages theme, language, sidebar, modals, toasts
import { create } from 'zustand';
export type Language = 'fr' | 'en' | 'es' | 'de';
export type ThemeMode = 'light' | 'dark' | 'system';
export interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
duration?: number;
}
export interface UIState {
theme: ThemeMode;
language: Language;
toasts: Toast[];
isSidebarOpen: boolean;
isRefreshing: boolean;
currentRoute: string;
// Actions
setTheme: (theme: ThemeMode) => void;
setLanguage: (language: Language) => void;
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
setSidebarOpen: (open: boolean) => void;
setRefreshing: (refreshing: boolean) => void;
setCurrentRoute: (route: string) => void;
}
export const useUIStore = create<UIState>((set) => ({
theme: 'light',
language: 'fr',
toasts: [],
isSidebarOpen: false,
isRefreshing: false,
currentRoute: 'Home',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
addToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: Date.now().toString() }],
})),
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
clearToasts: () => set({ toasts: [] }),
setSidebarOpen: (isSidebarOpen) => set({ isSidebarOpen }),
setRefreshing: (isRefreshing) => set({ isRefreshing }),
setCurrentRoute: (currentRoute) => set({ currentRoute }),
}));
// ─── Translations helper ─────────────────────────────────────────────────────
export const translations: Record<Language, Record<string, string>> = {
fr: {
home: 'Accueil',
map: 'Carte',
marketplace: 'Marketplace',
chat: 'AI Chat',
profile: 'Profil',
login: 'Connexion',
register: 'Inscription',
logout: 'Déconnexion',
email: 'Email',
password: 'Mot de passe',
forgotPassword: 'Mot de passe oublié ?',
search: 'Rechercher...',
notifications: 'Notifications',
settings: 'Paramètres',
darkMode: 'Mode sombre',
language: 'Langue',
help: 'Aide & Support',
about: 'À propos',
sensors: 'Capteurs',
alerts: 'Alertes',
zones: 'Zones',
loading: 'Chargement...',
noData: 'Aucune donnée',
pullToRefresh: 'Tirez pour rafraîchir',
lastUpdate: 'Dernière mise à jour',
},
en: {
home: 'Home',
map: 'Map',
marketplace: 'Marketplace',
chat: 'AI Chat',
profile: 'Profile',
login: 'Sign In',
register: 'Sign Up',
logout: 'Sign Out',
email: 'Email',
password: 'Password',
forgotPassword: 'Forgot password?',
search: 'Search...',
notifications: 'Notifications',
settings: 'Settings',
darkMode: 'Dark mode',
language: 'Language',
help: 'Help & Support',
about: 'About',
sensors: 'Sensors',
alerts: 'Alerts',
zones: 'Zones',
loading: 'Loading...',
noData: 'No data',
pullToRefresh: 'Pull to refresh',
lastUpdate: 'Last update',
},
es: {
home: 'Inicio',
map: 'Mapa',
marketplace: 'Mercado',
chat: 'IA Chat',
profile: 'Perfil',
login: 'Iniciar sesión',
register: 'Registrarse',
logout: 'Cerrar sesión',
email: 'Correo',
password: 'Contraseña',
forgotPassword: '¿Contraseña olvidada?',
search: 'Buscar...',
notifications: 'Notificaciones',
settings: 'Ajustes',
darkMode: 'Modo oscuro',
language: 'Idioma',
help: 'Ayuda',
about: 'Acerca de',
sensors: 'Sensores',
alerts: 'Alertas',
zones: 'Zonas',
loading: 'Cargando...',
noData: 'Sin datos',
pullToRefresh: 'Tire para actualizar',
lastUpdate: 'Última actualización',
},
de: {
home: 'Startseite',
map: 'Karte',
marketplace: 'Marktplatz',
chat: 'KI Chat',
profile: 'Profil',
login: 'Anmelden',
register: 'Registrieren',
logout: 'Abmelden',
email: 'E-Mail',
password: 'Passwort',
forgotPassword: 'Passwort vergessen?',
search: 'Suchen...',
notifications: 'Benachrichtigungen',
settings: 'Einstellungen',
darkMode: 'Dunkelmodus',
language: 'Sprache',
help: 'Hilfe',
about: 'Über',
sensors: 'Sensoren',
alerts: 'Warnungen',
zones: 'Zonen',
loading: 'Laden...',
noData: 'Keine Daten',
pullToRefresh: 'Ziehen zum Aktualisieren',
lastUpdate: 'Letzte Aktualisierung',
},
};
export const t = (key: string, lang: Language = 'fr'): string => {
return translations[lang]?.[key] ?? translations['fr'][key] ?? key;
};

View File

@@ -0,0 +1,132 @@
// Smart App City — Theme constants
// Palette: Blue Ocean / Indigo / Cyan — inspired by sea & sky of Martinique
export const Colors = {
// Primary Palette — Blue Ocean
primary: {
50: '#E3F2FD',
100: '#BBDEFB',
200: '#90CAF9',
300: '#64B5F6',
400: '#42A5F5',
500: '#1565C0',
600: '#0D47A1',
700: '#0A3470',
800: '#082854',
900: '#051C38',
},
// Deep Ocean / Cyan
ocean: {
50: '#E0F7FA',
100: '#B2EBF2',
200: '#80DEEA',
300: '#4DD0E1',
400: '#26C6DA',
500: '#00ACC1',
600: '#00838F',
700: '#006064',
},
// Indigo Accent
indigo: {
50: '#E8EAF6',
100: '#C5CAE9',
200: '#9FA8DA',
300: '#7986CB',
400: '#5C6BC0',
500: '#3949AB',
600: '#283593',
700: '#1A237E',
},
// Alert Colors
danger: '#D32F2F',
warning: '#F57C00',
success: '#2E7D32',
info: '#0288D1',
// Neutrals
white: '#FFFFFF',
neutral50: '#FAFAFA',
neutral100: '#F5F5F5',
neutral200: '#EEEEEE',
neutral300: '#E0E0E0',
neutral400: '#BDBDBD',
neutral500: '#9E9E9E',
neutral600: '#757575',
neutral700: '#616161',
neutral800: '#424242',
neutral900: '#212121',
black: '#1A1A1A',
// Dark Mode
darkBg: '#0D1B2A',
darkBgSecondary: '#1B2838',
darkBgCard: '#1E3350',
darkText: '#E8EAF6',
darkTextSecondary: '#9FA8DA',
darkTextTertiary: '#5C6BC0',
} as const;
export const Typography = {
fontFamily: 'System',
sizes: {
xs: 11,
sm: 13,
base: 15,
md: 17,
lg: 20,
xl: 24,
xxl: 28,
xxxl: 34,
xxxxl: 40,
},
weights: {
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
},
} as const;
export const Spacing = {
xxs: 2,
xs: 4,
sm: 8,
md: 12,
base: 16,
lg: 20,
xl: 24,
xxl: 32,
xxxl: 40,
xxxxl: 48,
} as const;
export const BorderRadius = {
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
full: 9999,
} as const;
export const Shadows = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 3,
elevation: 2,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 4,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 24,
elevation: 8,
},
} as const;

View File

@@ -0,0 +1 @@
// TODO: Implement

View File

@@ -0,0 +1,48 @@
// Smart App City — Formatters utility
export const formatters = {
temperature: (value: number, unit: string = '°C') => `${value.toFixed(1)}${unit}`,
humidity: (value: number) => `${value}%`,
aqi: (value: number) => {
if (value <= 50) return { label: 'Bon', color: '#2E7D32' };
if (value <= 100) return { label: 'Modéré', color: '#F57C00' };
if (value <= 150) return { label: 'Mauvais', color: '#D32F2F' };
return { label: 'Dangereux', color: '#7B1FA2' };
},
noise: (value: number) => {
if (value <= 40) return { label: 'Silencieux', color: '#2E7D32' };
if (value <= 55) return { label: 'Normal', color: '#0288D1' };
if (value <= 70) return { label: 'Bruyant', color: '#F57C00' };
return { label: 'Très bruyant', color: '#D32F2F' };
},
date: (date: string | Date, locale: string = 'fr-FR') => {
const d = new Date(date);
return d.toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
},
time: (date: string | Date) => {
const d = new Date(date);
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
},
relativeTime: (date: string | Date) => {
const d = new Date(date);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "À l'instant";
if (diffMins < 60) return `Il y a ${diffMins} min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays < 7) return `Il y a ${diffDays}j`;
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' });
},
currency: (value: number, currency: string = 'EUR') =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(value),
};

View File

@@ -0,0 +1,35 @@
// Smart App City — Validators utility
export const validators = {
email: (email: string): boolean => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
},
password: (password: string): { valid: boolean; errors: string[] } => {
const errors: string[] = [];
if (password.length < 8) errors.push('Au moins 8 caractères');
if (!/[A-Z]/.test(password)) errors.push('Au moins une majuscule');
if (!/[a-z]/.test(password)) errors.push('Au moins une minuscule');
if (!/[0-9]/.test(password)) errors.push('Au moins un chiffre');
return { valid: errors.length === 0, errors };
},
name: (name: string): boolean => {
return name.trim().length >= 2;
},
required: (value: string): boolean => {
return value.trim().length > 0;
},
iban: (iban: string): boolean => {
const cleaned = iban.replace(/\s/g, '').toUpperCase();
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}$/.test(cleaned)) return false;
// IBAN checksum validation
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (c) => (c.charCodeAt(0) - 55).toString());
let remainder = numeric;
while (remainder.length > 2) {
const block = remainder.slice(0, 9);
remainder = (parseInt(block, 10) % 97).toString() + remainder.slice(block.length);
}
return parseInt(remainder, 10) % 97 === 1;
},
};