Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- **Visual Status:** Displays a "System Secure" (Green) or "Seismic Alert" (Red) status based on recent data.
- **Haptic Feedback:** Triggers device vibration when the system state transitions from Secure to Alert.
- **Animations:** Uses `react-native-reanimated` for a pulsing shield effect during active alerts.
- **AI Emergency Reports (v1.2.0):** When an alert is confirmed, the backend generates a report via a local Ollama LLM and pushes it over the `ai_reports` WebSocket channel. The app displays an inline banner for the latest report and a card in the alert history feed (summary + recommendations), with a "Report non disponibile" badge if generation failed. Reports can be re-fetched via `GET /reports/{alert_id}` after a reconnect.

### 2. 🗺️ Sensor Map (WIP)

Expand Down
29 changes: 28 additions & 1 deletion mobile/__tests__/useAlertStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,29 @@ import { useAlertStore } from "../store/useAlertStore";

const makeAlert = (overrides = {}) => ({
type: "CRITICAL",
alert_id: 1,
zone_id: 1,
magnitude: 5.2,
message: "Test alert",
timestamp: "2026-01-01T00:00:00Z",
...overrides,
});

const makeReport = (overrides = {}) => ({
type: "EMERGENCY_REPORT" as const,
alert_id: 1,
report_id: 1,
zone_id: 1,
magnitude: 5.2,
status: "COMPLETED" as const,
summary: "A seismic event was detected.",
recommendations: ["Stay away from windows."],
timestamp: "2026-01-01T00:01:00Z",
...overrides,
});

beforeEach(() => {
useAlertStore.setState({ alerts: [] });
useAlertStore.setState({ alerts: [], reports: {} });
});

describe("useAlertStore", () => {
Expand All @@ -35,8 +49,21 @@ describe("useAlertStore", () => {

it("clears all alerts", () => {
useAlertStore.getState().addAlert(makeAlert());
useAlertStore.getState().addReport(makeReport());
useAlertStore.getState().clearAlerts();
expect(useAlertStore.getState().alerts).toEqual([]);
expect(useAlertStore.getState().reports).toEqual({});
});

it("adds a report keyed by alert_id", () => {
useAlertStore.getState().addReport(makeReport());
expect(useAlertStore.getState().reports[1]).toMatchObject({ status: "COMPLETED" });
});

it("keeps the latest report for the same alert", () => {
useAlertStore.getState().addReport(makeReport({ status: "COMPLETED" }));
useAlertStore.getState().addReport(makeReport({ status: "FAILED", summary: undefined }));
expect(useAlertStore.getState().reports[1].status).toBe("FAILED");
});

it("maintains most recent alert order", () => {
Expand Down
9 changes: 6 additions & 3 deletions mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"permissions": ["VIBRATE"]
"permissions": [
"VIBRATE"
]
},
"web": {
"bundler": "metro",
Expand All @@ -32,10 +34,11 @@
},
"plugins": [
"expo-router",
"expo-notifications"
"expo-notifications",
"expo-font"
],
"experiments": {
"typedRoutes": true
}
}
}
}
39 changes: 37 additions & 2 deletions mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,33 @@ function AlertBanner({ lastAlert }: Readonly<{ lastAlert: any }>) {
);
}

function AiReportCard({ lastReport }: Readonly<{ lastReport: any }>) {
if (!lastReport) return null;

if (lastReport.status === "FAILED") {
return (
<View style={styles.reportCardFailed}>
<Text style={styles.reportCardTitle}>🤖 AI Report non disponibile</Text>
<Text style={styles.reportCardBody}>The AI report could not be generated. Verify with local authorities.</Text>
</View>
);
}

return (
<View style={styles.reportCard}>
<Text style={styles.reportCardTitle}>🤖 AI Emergency Report</Text>
<Text style={styles.reportCardBody}>{lastReport.summary}</Text>
{lastReport.recommendations && lastReport.recommendations.length > 0 && (
<View style={styles.reportRecommendations}>
{[...new Set(lastReport.recommendations)].map((r: string) => (
<Text key={r} style={styles.reportRecommendationItem}>{`• ${r}`}</Text>
))}
</View>
)}
</View>
);
}

function NetworkChart({ readings, isAlertActive }: Readonly<{ readings: any[]; isAlertActive: boolean }>) {
if (!readings || readings.length === 0) {
return <Text style={{ textAlign: 'center', color: "#6b7280" }}>Awaiting telemetry...</Text>;
Expand Down Expand Up @@ -124,7 +151,7 @@ function DashboardContent({ errorSensors, errorReadings, loadingSensors, loading
}

export default function MonitorScreen() {
const { isConnected, lastAlert } = useWebSocket();
const { isConnected, lastAlert, lastReport } = useWebSocket();
const [isAlertActive, setIsAlertActive] = useState(false);
const pulse = useSharedValue(1);
const alertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -183,6 +210,8 @@ export default function MonitorScreen() {

{isAlertActive && lastAlert && <AlertBanner lastAlert={lastAlert} />}

<AiReportCard lastReport={lastReport} />

<View style={styles.dashboardCard}>
<DashboardContent
errorSensors={errorSensors}
Expand Down Expand Up @@ -233,5 +262,11 @@ const styles = StyleSheet.create({
chartTitle: { fontSize: 16, fontWeight: "700", color: "#374151" },
alertDetails: { marginBottom: 10, padding: 10, backgroundColor: "#fee2e2", borderRadius: 12, alignItems: 'center' },
alertValue: { fontSize: 18, fontWeight: "800", color: "#b91c1c" },
alertMessage: { fontSize: 14, fontStyle: "italic", color: "#991b1b" }
alertMessage: { fontSize: 14, fontStyle: "italic", color: "#991b1b" },
reportCard: { marginBottom: 10, padding: 12, backgroundColor: "#f5f3ff", borderRadius: 12 },
reportCardFailed: { marginBottom: 10, padding: 12, backgroundColor: "#fef3c7", borderRadius: 12 },
reportCardTitle: { fontSize: 14, fontWeight: "800", color: "#7c3aed", marginBottom: 6 },
reportCardBody: { fontSize: 13, color: "#4c1d95", lineHeight: 18 },
reportRecommendations: { marginTop: 8 },
reportRecommendationItem: { fontSize: 12, color: "#6d28d9", lineHeight: 16 }
});
57 changes: 43 additions & 14 deletions mobile/components/AlertHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import { AlertTriangle, Sparkles } from 'lucide-react-native';
import { useAlertStore } from '../store/useAlertStore';

export function AlertHistoryList() {
const { alerts } = useAlertStore();
const { alerts, reports } = useAlertStore();

if (alerts.length === 0) return null;

Expand All @@ -15,15 +15,38 @@ export function AlertHistoryList() {
data={alerts}
keyExtractor={(item, index) => `${item.timestamp}-${index}`}
scrollEnabled={false} // Disable scroll if placed inside a ScrollView parent
renderItem={({ item }) => (
<View style={styles.row}>
<AlertTriangle size={16} color="#dc2626" />
<View style={styles.textContainer}>
<Text style={styles.message}>Zone {item.zone_id} • Mag {item.magnitude.toFixed(1)}</Text>
<Text style={styles.time}>{new Date(item.timestamp).toLocaleTimeString()}</Text>
renderItem={({ item }) => {
const report = item.alert_id != null ? reports[item.alert_id] : undefined;
return (
<View style={styles.row}>
<AlertTriangle size={16} color="#dc2626" />
<View style={styles.textContainer}>
<Text style={styles.message}>Zone {item.zone_id} • Mag {item.magnitude.toFixed(1)}</Text>
<Text style={styles.time}>{new Date(item.timestamp).toLocaleTimeString()}</Text>
</View>
{report && (
<View style={styles.reportCard}>
{report.status === 'COMPLETED' ? (
<>
<View style={styles.reportHeader}>
<Sparkles size={12} color="#7c3aed" />
<Text style={styles.reportTitle}>AI Report</Text>
</View>
<Text style={styles.reportSummary}>{report.summary}</Text>
{report.recommendations && report.recommendations.length > 0 && (
<Text style={styles.reportRecommendations}>
{report.recommendations.map((r) => `• ${r}`).join('\n')}
</Text>
)}
</>
) : (
<Text style={styles.reportUnavailable}>Report non disponibile</Text>
)}
</View>
)}
</View>
</View>
)}
);
}}
/>
</View>
);
Expand All @@ -32,8 +55,14 @@ export function AlertHistoryList() {
const styles = StyleSheet.create({
container: { marginTop: 20, paddingTop: 20, borderTopWidth: 1, borderTopColor: '#f3f4f6' },
title: { fontSize: 14, fontWeight: '700', color: '#374151', marginBottom: 12, textTransform: 'uppercase' },
row: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fef2f2', padding: 12, borderRadius: 8, marginBottom: 8 },
textContainer: { marginLeft: 10, flex: 1, flexDirection: 'row', justifyContent: 'space-between' },
row: { flexDirection: 'column', backgroundColor: '#fef2f2', padding: 12, borderRadius: 8, marginBottom: 8 },
textContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
message: { fontSize: 14, fontWeight: '600', color: '#991b1b' },
time: { fontSize: 12, color: '#dc2626' }
});
time: { fontSize: 12, color: '#dc2626' },
reportCard: { marginTop: 10, backgroundColor: '#f5f3ff', padding: 10, borderRadius: 8 },
reportHeader: { flexDirection: 'row', alignItems: 'center', gap: 4, marginBottom: 4 },
reportTitle: { fontSize: 11, fontWeight: '700', color: '#7c3aed', textTransform: 'uppercase', letterSpacing: 0.5 },
reportSummary: { fontSize: 13, color: '#4c1d95', lineHeight: 18 },
reportRecommendations: { marginTop: 6, fontSize: 12, color: '#6d28d9', lineHeight: 16 },
reportUnavailable: { fontSize: 12, fontStyle: 'italic', color: '#991b1b' },
});
61 changes: 53 additions & 8 deletions mobile/context/WebSocketContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,29 @@ Notifications.setNotificationHandler({
// --- TYPES & INTERFACES ---
export interface AlertMessage {
type: string;
alert_id?: number;
zone_id: number;
magnitude: number;
message: string;
timestamp: string;
}

export interface EmergencyReportMessage {
type: "EMERGENCY_REPORT";
alert_id: number;
report_id: number;
zone_id: number;
magnitude: number;
status: "COMPLETED" | "FAILED";
summary?: string;
recommendations?: string[];
timestamp: string;
}

interface WebSocketContextType {
isConnected: boolean;
lastAlert: AlertMessage | null;
lastReport: EmergencyReportMessage | null;
}

const WebSocketContext = createContext<WebSocketContextType | null>(null);
Expand All @@ -52,6 +66,7 @@ const MAX_RECONNECT_DELAY = 30000;
export const WebSocketProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isConnected, setIsConnected] = useState<boolean>(false);
const [lastAlert, setLastAlert] = useState<AlertMessage | null>(null);
const [lastReport, setLastReport] = useState<EmergencyReportMessage | null>(null);

// Bring in the offline mode flag
const isOfflineMode = usePreferencesStore((state) => state.isOfflineMode);
Expand Down Expand Up @@ -112,20 +127,45 @@ export const WebSocketProvider: React.FC<{ children: ReactNode }> = ({ children

ws.current.onmessage = (event: WebSocketMessageEvent) => {
try {
const message: AlertMessage = JSON.parse(event.data);
console.log("⚡ ALERT RECEIVED:", message);
const message: any = JSON.parse(event.data);
const { notificationsEnabled } = usePreferencesStore.getState();

setLastAlert(message);
useAlertStore.getState().addAlert(message);
// 🤖 AI Emergency Report (generated asynchronously by the local Ollama worker)
if (message.type === "EMERGENCY_REPORT") {
const report: EmergencyReportMessage = message;
console.log("🤖 AI REPORT RECEIVED:", report);
setLastReport(report);
useAlertStore.getState().addReport(report);

if (notificationsEnabled) {
Notifications.scheduleNotificationAsync({
content: {
title: report.status === "COMPLETED" ? "🤖 AI Emergency Report" : "🤖 AI Report non disponibile",
body:
report.status === "COMPLETED"
? report.summary ?? "Emergency report generated."
: "The AI report could not be generated. Contact local authorities.",
sound: true,
priority: Notifications.AndroidNotificationPriority.MAX,
},
trigger: null,
});
}
return;
}

const { notificationsEnabled } = usePreferencesStore.getState();
const alert: AlertMessage = message;
console.log("⚡ ALERT RECEIVED:", alert);

setLastAlert(alert);
useAlertStore.getState().addAlert(alert);

if (message.type === "CRITICAL" && notificationsEnabled) {
if (alert.type === "CRITICAL" && notificationsEnabled) {
Vibration.vibrate(SOS_VIBRATION_PATTERN);
Notifications.scheduleNotificationAsync({
content: {
title: "⚠️ CRITICAL SEISMIC ALERT",
body: `Magnitude ${message.magnitude.toFixed(1)} detected. ${message.message}`,
body: `Magnitude ${alert.magnitude.toFixed(1)} detected. ${alert.message}`,
sound: true,
priority: Notifications.AndroidNotificationPriority.MAX,
},
Expand Down Expand Up @@ -189,7 +229,12 @@ export const WebSocketProvider: React.FC<{ children: ReactNode }> = ({ children
// 💡 IL SECONDO useEffect(() => { connect() }) È STATO ELIMINATO COMPLETAMENTE!

return (
<WebSocketContext.Provider value={useMemo(() => ({ isConnected, lastAlert }), [isConnected, lastAlert])}>
<WebSocketContext.Provider
value={useMemo(
() => ({ isConnected, lastAlert, lastReport }),
[isConnected, lastAlert, lastReport]
)}
>
{children}
</WebSocketContext.Provider>
);
Expand Down
Loading
Loading