feat(network): offline notice modal on Home + offline-aware detail hooks

Two related UX improvements when the device has no network:

- OfflineNoticeModal pops once per offline session on the Home tab
  with a translated "Tu peux continuer en local" message. Dismissed
  state lives in a ref keyed on connectivity transitions, so coming
  back online and going offline again will re-show it.
- useDiseaseDetail and useGuideDetail now check NetworkContext before
  attempting the API call. Without this, an offline disease detail
  screen would trigger a 10s fetch timeout and look frozen; we now
  fall back to the cached or bundled local data immediately and set
  isLoading=false on the same tick.
- ToastContext (NetworkToastWatcher) i18n-ifies the offline/online
  toast strings via the new network.* keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 14:02:56 +02:00
parent c0c85929ed
commit 425f3035ef
5 changed files with 158 additions and 9 deletions

View file

@ -0,0 +1,77 @@
import { Modal, Pressable, View } from "react-native";
import { useTranslation } from "react-i18next";
import { WifiOff, Check } from "lucide-react-native";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
interface OfflineNoticeModalProps {
visible: boolean;
onClose: () => void;
}
export function OfflineNoticeModal({ visible, onClose }: OfflineNoticeModalProps) {
const { t } = useTranslation();
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
statusBarTranslucent
>
<Pressable
onPress={onClose}
className="flex-1 items-center justify-center px-6 bg-black/50"
>
<Pressable
onPress={(e) => e.stopPropagation()}
className="w-full max-w-[400px] bg-white rounded-3xl p-6"
style={{
shadowColor: "#000",
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.18,
shadowRadius: 28,
elevation: 16,
}}
>
{/* Icon header */}
<View className="items-center mb-4">
<View className="w-14 h-14 rounded-full items-center justify-center bg-[#FFF4E5]">
<WifiOff size={26} color="#E67E22" strokeWidth={2.4} />
</View>
</View>
<Text className="text-[18px] font-bold text-[#1A1A1A] text-center">
{t("network.homeOfflineModalTitle")}
</Text>
<Text className="mt-2 text-[14px] leading-5 text-[#6B6B6B] text-center">
{t("network.homeOfflineModalMessage")}
</Text>
<View className="mt-6">
<Pressable
onPress={onClose}
className="min-h-[52px] rounded-[14px] py-3 px-3 items-center justify-center bg-primary active:opacity-85"
style={{
shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
}}
>
<View className="flex-row items-center">
<Check size={16} color="#FFFFFF" strokeWidth={2.6} />
<Text className="text-[15px] font-bold text-white ml-2">
{t("network.homeOfflineModalAction")}
</Text>
</View>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}

View file

@ -1,25 +1,27 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { useNetwork } from "@/contexts/NetworkContext"; import { useNetwork } from "@/contexts/NetworkContext";
// Auto-show offline/online toasts based on network status // Auto-show offline/online toasts based on network status
export function NetworkToastWatcher({ children }: { children: React.ReactNode }) { export function NetworkToastWatcher({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const { isConnected } = useNetwork(); const { isConnected } = useNetwork();
const prevConnected = useRef(true); const prevConnected = useRef(true);
useEffect(() => { useEffect(() => {
if (!isConnected && prevConnected.current) { if (!isConnected && prevConnected.current) {
toast.error("Mode hors-ligne", { toast.error(t("network.offlineToastTitle"), {
description: "Les donnees en cache seront utilisees", description: t("network.offlineToastDescription"),
duration: Infinity, duration: Infinity,
id: "offline", id: "offline",
}); });
} else if (isConnected && !prevConnected.current) { } else if (isConnected && !prevConnected.current) {
toast.dismiss("offline"); toast.dismiss("offline");
toast.success("Connexion retablie", { duration: 2500 }); toast.success(t("network.onlineToastTitle"), { duration: 2500 });
} }
prevConnected.current = isConnected; prevConnected.current = isConnected;
}, [isConnected]); }, [isConnected, t]);
return <>{children}</>; return <>{children}</>;
} }

View file

@ -3,6 +3,7 @@ import { fetchDiseaseBySlug } from "@/services/api/diseases";
import { mapApiDiseaseToLocal } from "@/services/api/mappers"; import { mapApiDiseaseToLocal } from "@/services/api/mappers";
import { cacheGet, cacheSet } from "@/services/cache/cacheManager"; import { cacheGet, cacheSet } from "@/services/cache/cacheManager";
import { getDiseaseById, type Disease } from "@/data/diseases"; import { getDiseaseById, type Disease } from "@/data/diseases";
import { useNetwork } from "@/contexts/NetworkContext";
type DataSource = "api" | "cache" | "local"; type DataSource = "api" | "cache" | "local";
@ -14,6 +15,7 @@ interface UseDiseaseDetailResult {
} }
export function useDiseaseDetail(diseaseId: string): UseDiseaseDetailResult { export function useDiseaseDetail(diseaseId: string): UseDiseaseDetailResult {
const { isConnected } = useNetwork();
const [disease, setDisease] = useState<Disease | null>(null); const [disease, setDisease] = useState<Disease | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -33,7 +35,22 @@ export function useDiseaseDetail(diseaseId: string): UseDiseaseDetailResult {
setIsLoading(false); setIsLoading(false);
} }
// 2. Fetch from API // 2. Si offline → on n'attaque pas le réseau (sinon timeout 10s = "infinite loading"
// perçu par l'utilisateur). Fallback immédiat sur le cache déjà set ci-dessus,
// ou les données locales bundlées si pas de cache.
if (!isConnected) {
if (!cached && mountedRef.current) {
const local = getDiseaseById(diseaseId);
if (local) {
setDisease(local);
setSource("local");
}
setIsLoading(false);
}
return;
}
// 3. Fetch from API (en ligne uniquement)
const slug = diseaseId.replace(/_/g, "-"); const slug = diseaseId.replace(/_/g, "-");
const result = await fetchDiseaseBySlug(slug); const result = await fetchDiseaseBySlug(slug);
@ -60,7 +77,7 @@ export function useDiseaseDetail(diseaseId: string): UseDiseaseDetailResult {
load(); load();
return () => { mountedRef.current = false; }; return () => { mountedRef.current = false; };
}, [diseaseId]); }, [diseaseId, isConnected]);
return { disease, isLoading, error, source }; return { disease, isLoading, error, source };
} }

View file

@ -3,6 +3,7 @@ import { fetchGuideBySlug } from "@/services/api/guides";
import { mapApiGuideToLocal } from "@/services/api/mappers"; import { mapApiGuideToLocal } from "@/services/api/mappers";
import { cacheGet, cacheSet } from "@/services/cache/cacheManager"; import { cacheGet, cacheSet } from "@/services/cache/cacheManager";
import { getGuideById, type Guide } from "@/data/guides"; import { getGuideById, type Guide } from "@/data/guides";
import { useNetwork } from "@/contexts/NetworkContext";
type DataSource = "api" | "cache" | "local"; type DataSource = "api" | "cache" | "local";
@ -14,6 +15,7 @@ interface UseGuideDetailResult {
} }
export function useGuideDetail(guideId: string): UseGuideDetailResult { export function useGuideDetail(guideId: string): UseGuideDetailResult {
const { isConnected } = useNetwork();
const [guide, setGuide] = useState<Guide | null>(null); const [guide, setGuide] = useState<Guide | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -33,7 +35,22 @@ export function useGuideDetail(guideId: string): UseGuideDetailResult {
setIsLoading(false); setIsLoading(false);
} }
// 2. Fetch from API // 2. Si offline → on n'attaque pas le réseau (sinon timeout 10s = "infinite loading"
// perçu par l'utilisateur). Fallback immédiat sur le cache déjà set ci-dessus,
// ou les données locales bundlées si pas de cache.
if (!isConnected) {
if (!cached && mountedRef.current) {
const local = getGuideById(guideId);
if (local) {
setGuide(local);
setSource("local");
}
setIsLoading(false);
}
return;
}
// 3. Fetch from API (en ligne uniquement)
const slug = guideId.replace(/_/g, "-"); const slug = guideId.replace(/_/g, "-");
const result = await fetchGuideBySlug(slug); const result = await fetchGuideBySlug(slug);
@ -60,7 +77,7 @@ export function useGuideDetail(guideId: string): UseGuideDetailResult {
load(); load();
return () => { mountedRef.current = false; }; return () => { mountedRef.current = false; };
}, [guideId]); }, [guideId, isConnected]);
return { guide, isLoading, error, source }; return { guide, isLoading, error, source };
} }

View file

@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { View, ScrollView } from "react-native"; import { View, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
@ -8,6 +9,7 @@ import Animated, { FadeInDown } from "react-native-reanimated";
import type { RootStackParamList } from "@/types/navigation"; import type { RootStackParamList } from "@/types/navigation";
import { useDiseases } from "@/hooks/useDiseases"; import { useDiseases } from "@/hooks/useDiseases";
import { useGuides } from "@/hooks/useGuides"; import { useGuides } from "@/hooks/useGuides";
import { useNetwork } from "@/contexts/NetworkContext";
import SearchHeader from "@/components/home/SearchHeader"; import SearchHeader from "@/components/home/SearchHeader";
import SearchSection from "@/components/home/SearchSection"; import SearchSection from "@/components/home/SearchSection";
import SectionHeader from "@/components/home/components/homeheader"; import SectionHeader from "@/components/home/components/homeheader";
@ -15,12 +17,19 @@ import FrequentDiseasesHorizontal from "@/components/home/FrequentDiseasesHorizo
// import SeasonAlert from "@/components/home/SeasonAlert"; // TODO: réactiver quand la page Notifications sera de retour // import SeasonAlert from "@/components/home/SeasonAlert"; // TODO: réactiver quand la page Notifications sera de retour
import PracticalGuides from "@/components/home/PracticalGuides"; import PracticalGuides from "@/components/home/PracticalGuides";
import RecentScans from "@/components/home/RecentScans"; import RecentScans from "@/components/home/RecentScans";
import { OfflineNoticeModal } from "@/components/ui/OfflineNoticeModal";
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
const ENTER_DURATION = 420; const ENTER_DURATION = 420;
// Reanimated v4 : un `entering` avec delay 0 sur un nœud fraîchement monté peut
// rester invisible (l'anim démarre avant que le layout soit prêt). On garantit
// au minimum 60ms pour que la 1re vue rende toujours.
function entering(delay: number) { function entering(delay: number) {
return FadeInDown.delay(delay).duration(ENTER_DURATION).springify().damping(16); return FadeInDown.delay(Math.max(delay, 60))
.duration(ENTER_DURATION)
.springify()
.damping(16);
} }
export default function HomeScreen() { export default function HomeScreen() {
@ -28,6 +37,28 @@ export default function HomeScreen() {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { data: diseases, isLoading: diseasesLoading } = useDiseases(); const { data: diseases, isLoading: diseasesLoading } = useDiseases();
const { data: guides, isLoading: guidesLoading } = useGuides(); const { data: guides, isLoading: guidesLoading } = useGuides();
const { isConnected } = useNetwork();
// Modal "Connexion requise" affichée 1× par session offline.
// Reset du flag quand l'utilisateur se reconnecte → si il repart offline,
// la modal se réaffichera la prochaine fois qu'il atteint Home.
const [offlineModalOpen, setOfflineModalOpen] = useState(false);
const dismissedThisOfflineSessionRef = useRef(false);
useEffect(() => {
if (isConnected) {
dismissedThisOfflineSessionRef.current = false;
return;
}
if (!dismissedThisOfflineSessionRef.current) {
setOfflineModalOpen(true);
}
}, [isConnected]);
function handleCloseOfflineModal() {
setOfflineModalOpen(false);
dismissedThisOfflineSessionRef.current = true;
}
return ( return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}> <SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
@ -76,6 +107,11 @@ export default function HomeScreen() {
<View className="h-8" /> <View className="h-8" />
</ScrollView> </ScrollView>
<OfflineNoticeModal
visible={offlineModalOpen}
onClose={handleCloseOfflineModal}
/>
</SafeAreaView> </SafeAreaView>
); );
} }