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

View file

@ -3,6 +3,7 @@ import { fetchDiseaseBySlug } from "@/services/api/diseases";
import { mapApiDiseaseToLocal } from "@/services/api/mappers";
import { cacheGet, cacheSet } from "@/services/cache/cacheManager";
import { getDiseaseById, type Disease } from "@/data/diseases";
import { useNetwork } from "@/contexts/NetworkContext";
type DataSource = "api" | "cache" | "local";
@ -14,6 +15,7 @@ interface UseDiseaseDetailResult {
}
export function useDiseaseDetail(diseaseId: string): UseDiseaseDetailResult {
const { isConnected } = useNetwork();
const [disease, setDisease] = useState<Disease | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -33,7 +35,22 @@ export function useDiseaseDetail(diseaseId: string): UseDiseaseDetailResult {
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 result = await fetchDiseaseBySlug(slug);
@ -60,7 +77,7 @@ export function useDiseaseDetail(diseaseId: string): UseDiseaseDetailResult {
load();
return () => { mountedRef.current = false; };
}, [diseaseId]);
}, [diseaseId, isConnected]);
return { disease, isLoading, error, source };
}

View file

@ -3,6 +3,7 @@ import { fetchGuideBySlug } from "@/services/api/guides";
import { mapApiGuideToLocal } from "@/services/api/mappers";
import { cacheGet, cacheSet } from "@/services/cache/cacheManager";
import { getGuideById, type Guide } from "@/data/guides";
import { useNetwork } from "@/contexts/NetworkContext";
type DataSource = "api" | "cache" | "local";
@ -14,6 +15,7 @@ interface UseGuideDetailResult {
}
export function useGuideDetail(guideId: string): UseGuideDetailResult {
const { isConnected } = useNetwork();
const [guide, setGuide] = useState<Guide | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -33,7 +35,22 @@ export function useGuideDetail(guideId: string): UseGuideDetailResult {
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 result = await fetchGuideBySlug(slug);
@ -60,7 +77,7 @@ export function useGuideDetail(guideId: string): UseGuideDetailResult {
load();
return () => { mountedRef.current = false; };
}, [guideId]);
}, [guideId, isConnected]);
return { guide, isLoading, error, source };
}

View file

@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { View, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
@ -8,6 +9,7 @@ import Animated, { FadeInDown } from "react-native-reanimated";
import type { RootStackParamList } from "@/types/navigation";
import { useDiseases } from "@/hooks/useDiseases";
import { useGuides } from "@/hooks/useGuides";
import { useNetwork } from "@/contexts/NetworkContext";
import SearchHeader from "@/components/home/SearchHeader";
import SearchSection from "@/components/home/SearchSection";
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 PracticalGuides from "@/components/home/PracticalGuides";
import RecentScans from "@/components/home/RecentScans";
import { OfflineNoticeModal } from "@/components/ui/OfflineNoticeModal";
type Nav = NativeStackNavigationProp<RootStackParamList>;
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) {
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() {
@ -28,6 +37,28 @@ export default function HomeScreen() {
const navigation = useNavigation<Nav>();
const { data: diseases, isLoading: diseasesLoading } = useDiseases();
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 (
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
@ -76,6 +107,11 @@ export default function HomeScreen() {
<View className="h-8" />
</ScrollView>
<OfflineNoticeModal
visible={offlineModalOpen}
onClose={handleCloseOfflineModal}
/>
</SafeAreaView>
);
}