feat(ui): Settings hero with avatar + stats row, fix LargeDiseaseCard reveal anim, new adaptive icon

- SettingsScreen: the profile row now reads from useUserProfile so
  it shows the saved emoji avatar and displayName instead of a
  generic "Edit profile" placeholder. A row of three StatTile
  (scans, grapes, streak) sits underneath, mirroring the Profile
  Bento at a glance.
- LargeDiseaseCard: replace the `entering={FadeInDown.delay(...)}`
  prop with an explicit useSharedValue + useEffect + withDelay anim.
  Reanimated v4 has a bug where the first entering on a list that
  was just re-rendered (skeleton → data swap) silently drops, leaving
  cards invisible. The manual driver always runs.
- Replace android adaptive-icon.png with the new asset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 14:03:43 +02:00
parent 59d4ae8ee4
commit ddf0464d35
3 changed files with 107 additions and 10 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -1,8 +1,15 @@
import { useEffect } from "react";
import { View, Pressable, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { ArrowRight, AlertTriangle } from "lucide-react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withSpring,
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
@ -48,6 +55,26 @@ export default function LargeDiseaseCard({
const type = TYPE_TINT[disease.type];
const severity = SEVERITY_TINT[disease.severity];
// Anim manuelle (useSharedValue + useEffect) plutôt que `entering={FadeInDown}`,
// car Reanimated v4 a un bug où la 1re anim entering d'une liste fraîchement
// re-renderée (skeleton → data) ne s'applique pas et le card reste invisible.
const opacity = useSharedValue(0);
const translateY = useSharedValue(20);
useEffect(() => {
const delay = 60 + index * 90;
opacity.value = withDelay(delay, withTiming(1, { duration: 450 }));
translateY.value = withDelay(
delay,
withSpring(0, { damping: 16, stiffness: 120 }),
);
}, []);
const animStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ translateY: translateY.value }],
}));
const cardRadius = compact ? 24 : 32;
const cardClassName = compact
? "bg-white p-5 min-h-[220px] justify-between"
@ -83,12 +110,7 @@ export default function LargeDiseaseCard({
const arrowIconSize = compact ? 18 : 20;
return (
<Animated.View
entering={FadeInDown.delay(index * 90)
.duration(550)
.springify()
.damping(16)}
>
<Animated.View style={animStyle}>
<Pressable
onPress={onPress}
className={cardClassName}

View file

@ -23,6 +23,7 @@ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { colors } from "@/theme/colors";
import { useGameProgress } from "@/hooks/useGameProgress";
import { useHistory } from "@/hooks/useHistory";
import { useUserProfile } from "@/hooks/useUserProfile";
import { useAuth } from "@/contexts/AuthContext";
import { storage } from "@/services/storage";
import type { RootStackParamList } from "@/types/navigation";
@ -40,11 +41,44 @@ interface MenuItem {
onToggle?: (value: boolean) => void;
}
function StatTile({
icon,
tint,
value,
label,
}: {
icon: keyof typeof Ionicons.glyphMap;
tint: string;
value: number;
label: string;
}) {
return (
<View className="flex-1 bg-white rounded-2xl border border-[#F2F2F2] p-3 items-center gap-1.5">
<View
className="w-8 h-8 rounded-full items-center justify-center"
style={{ backgroundColor: `${tint}15` }}
>
<Ionicons name={icon} size={16} color={tint} />
</View>
<Text className="text-[18px] font-bold text-[#1A1A1A] leading-6">
{value}
</Text>
<Text
className="text-[10px] font-semibold text-[#8E8E93] uppercase tracking-wider text-center"
numberOfLines={1}
>
{label}
</Text>
</View>
);
}
export default function SettingsScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { progress, resetProgress } = useGameProgress();
const { clearHistory, seedTestData } = useHistory();
const { profile } = useUserProfile();
const { user, resetAccount } = useAuth();
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
@ -250,6 +284,13 @@ export default function SettingsScreen() {
const userLevel = progress?.level ?? 1;
const userXp = progress?.xp ?? 0;
const totalScans = progress?.totalScans ?? 0;
const uniqueGrapes = progress?.uniqueGrapes?.length ?? 0;
const bestStreak = progress?.bestStreak ?? 0;
const heroName =
profile?.displayName?.trim() ||
user?.name?.trim() ||
t("settings.editProfile");
return (
<SafeAreaView style={styles.safe} edges={["top"]}>
@ -277,11 +318,17 @@ export default function SettingsScreen() {
onPress={() => navigation.navigate("Profile")}
>
<View style={styles.avatarWrap}>
{profile?.avatar ? (
<Text style={styles.avatarEmoji}>{profile.avatar}</Text>
) : (
<Ionicons name="person" size={28} color="#FFFFFF" />
)}
</View>
<View style={styles.profileText}>
<Text style={styles.profileName}>{t("settings.editProfile")}</Text>
<Text style={styles.profileMeta}>
<Text style={styles.profileName} numberOfLines={1}>
{heroName}
</Text>
<Text style={styles.profileMeta} numberOfLines={1}>
{t("profile.level", { level: userLevel })} · {userXp} XP
</Text>
</View>
@ -290,6 +337,28 @@ export default function SettingsScreen() {
</View>
</TouchableOpacity>
{/* Stats row — chiffres réels du joueur */}
<View className="flex-row gap-3 mb-6">
<StatTile
icon="scan-outline"
tint="#F59E0B"
value={totalScans}
label={t("profile.totalScans")}
/>
<StatTile
icon="leaf-outline"
tint="#10B981"
value={uniqueGrapes}
label={t("profile.uniqueGrapes")}
/>
<StatTile
icon="flame-outline"
tint="#EF4444"
value={bestStreak}
label={t("profile.bestStreak")}
/>
</View>
<Text style={styles.sectionLabel}>{t("settings.general")}</Text>
{renderMenuGroup(generalItems)}
@ -443,6 +512,12 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
},
avatarEmoji: {
fontSize: 28,
lineHeight: 34,
textAlign: "center",
includeFontPadding: false,
},
profileText: { flex: 1, gap: 2 },
profileName: {
fontSize: 16,