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 { 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}</>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue