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:
parent
c0c85929ed
commit
425f3035ef
77
VinEye/src/components/ui/OfflineNoticeModal.tsx
Normal file
77
VinEye/src/components/ui/OfflineNoticeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue