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