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:
parent
59d4ae8ee4
commit
ddf0464d35
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 5.6 KiB |
|
|
@ -1,8 +1,15 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
import { View, Pressable, StyleSheet } from "react-native";
|
import { View, Pressable, StyleSheet } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { ArrowRight, AlertTriangle } from "lucide-react-native";
|
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 { Text } from "@/components/ui/text";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
|
|
@ -48,6 +55,26 @@ export default function LargeDiseaseCard({
|
||||||
const type = TYPE_TINT[disease.type];
|
const type = TYPE_TINT[disease.type];
|
||||||
const severity = SEVERITY_TINT[disease.severity];
|
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 cardRadius = compact ? 24 : 32;
|
||||||
const cardClassName = compact
|
const cardClassName = compact
|
||||||
? "bg-white p-5 min-h-[220px] justify-between"
|
? "bg-white p-5 min-h-[220px] justify-between"
|
||||||
|
|
@ -83,12 +110,7 @@ export default function LargeDiseaseCard({
|
||||||
const arrowIconSize = compact ? 18 : 20;
|
const arrowIconSize = compact ? 18 : 20;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View style={animStyle}>
|
||||||
entering={FadeInDown.delay(index * 90)
|
|
||||||
.duration(550)
|
|
||||||
.springify()
|
|
||||||
.damping(16)}
|
|
||||||
>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={cardClassName}
|
className={cardClassName}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
import { useGameProgress } from "@/hooks/useGameProgress";
|
import { useGameProgress } from "@/hooks/useGameProgress";
|
||||||
import { useHistory } from "@/hooks/useHistory";
|
import { useHistory } from "@/hooks/useHistory";
|
||||||
|
import { useUserProfile } from "@/hooks/useUserProfile";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { storage } from "@/services/storage";
|
import { storage } from "@/services/storage";
|
||||||
import type { RootStackParamList } from "@/types/navigation";
|
import type { RootStackParamList } from "@/types/navigation";
|
||||||
|
|
@ -40,11 +41,44 @@ interface MenuItem {
|
||||||
onToggle?: (value: boolean) => void;
|
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() {
|
export default function SettingsScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { progress, resetProgress } = useGameProgress();
|
const { progress, resetProgress } = useGameProgress();
|
||||||
const { clearHistory, seedTestData } = useHistory();
|
const { clearHistory, seedTestData } = useHistory();
|
||||||
|
const { profile } = useUserProfile();
|
||||||
const { user, resetAccount } = useAuth();
|
const { user, resetAccount } = useAuth();
|
||||||
|
|
||||||
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
||||||
|
|
@ -250,6 +284,13 @@ export default function SettingsScreen() {
|
||||||
|
|
||||||
const userLevel = progress?.level ?? 1;
|
const userLevel = progress?.level ?? 1;
|
||||||
const userXp = progress?.xp ?? 0;
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={["top"]}>
|
<SafeAreaView style={styles.safe} edges={["top"]}>
|
||||||
|
|
@ -277,11 +318,17 @@ export default function SettingsScreen() {
|
||||||
onPress={() => navigation.navigate("Profile")}
|
onPress={() => navigation.navigate("Profile")}
|
||||||
>
|
>
|
||||||
<View style={styles.avatarWrap}>
|
<View style={styles.avatarWrap}>
|
||||||
<Ionicons name="person" size={28} color="#FFFFFF" />
|
{profile?.avatar ? (
|
||||||
|
<Text style={styles.avatarEmoji}>{profile.avatar}</Text>
|
||||||
|
) : (
|
||||||
|
<Ionicons name="person" size={28} color="#FFFFFF" />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.profileText}>
|
<View style={styles.profileText}>
|
||||||
<Text style={styles.profileName}>{t("settings.editProfile")}</Text>
|
<Text style={styles.profileName} numberOfLines={1}>
|
||||||
<Text style={styles.profileMeta}>
|
{heroName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.profileMeta} numberOfLines={1}>
|
||||||
{t("profile.level", { level: userLevel })} · {userXp} XP
|
{t("profile.level", { level: userLevel })} · {userXp} XP
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -290,6 +337,28 @@ export default function SettingsScreen() {
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</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>
|
<Text style={styles.sectionLabel}>{t("settings.general")}</Text>
|
||||||
{renderMenuGroup(generalItems)}
|
{renderMenuGroup(generalItems)}
|
||||||
|
|
||||||
|
|
@ -443,6 +512,12 @@ const styles = StyleSheet.create({
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
avatarEmoji: {
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 34,
|
||||||
|
textAlign: "center",
|
||||||
|
includeFontPadding: false,
|
||||||
|
},
|
||||||
profileText: { flex: 1, gap: 2 },
|
profileText: { flex: 1, gap: 2 },
|
||||||
profileName: {
|
profileName: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue