feat(mobile): UI overhaul + ML pipeline + Android build fixes

UI/screens
- Refonte ProfileScreen, SettingsScreen, ResultScreen, ScanDetailScreen,
  GuidesScreen, MapScreen, MyPlantsScreen
- Nouveaux composants : LargeDiseaseCard, ConfidenceTile, StatusTag,
  EditProfileModal, HeaderActionButtons
- useUserProfile hook + types/user.ts pour le profil utilisateur
- i18n FR/EN enrichi pour les nouveaux écrans

ML
- src/services/ml/classes.ts (mapping ML → slugs Prisma)
- src/services/ml/preprocessing.ts (resize 224x224 + decode JPEG + norm /255)
- model.ts adapté + fallback mock quand le module natif est absent

Build Android (notes)
- .claude/notes/android-build/README.md : fixes CMake/Ninja "path too long"
  (response files + ninja 1.12.1 + CMAKE_OBJECT_PATH_MAX=1024)
- Note du blocage Nitro Modules headers + pistes (EAS Build, inférence
  serveur, fallback Expo Go mock)
- ⚠️ Le bloc externalNativeBuild dans android/app/build.gradle n'est pas
  versionné (android/ gitignored par expo prebuild) — à porter dans un
  plugin Expo config si on garde fast-tflite local

Admin
- vineye-admin/prisma/seed.ts : seed mock pour tester la Map sans scanner

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-04-30 21:00:03 +02:00
parent 06be3483d7
commit a8b84472e6
39 changed files with 2207 additions and 585 deletions

View file

@ -7,6 +7,7 @@
| Feature | Documentation | Status | Dernière MAJ |
|---------|--------------|--------|-------------|
| Build Android (CMake/Ninja) | [`android-build/`](android-build/README.md) | 🟡 Fix #1 ✅ / Fix #2 en cours | 2026-04-30 |
## Fichiers critiques globaux

View file

@ -0,0 +1,112 @@
# Build Android — Fixes & configuration
> Notes des corrections appliquées pour faire passer le build natif Android.
> Stack : Expo SDK 54 (bare workflow) + `react-native-fast-tflite` (module natif C++).
---
## Fix #1 — Erreur CMake / Ninja "path too long" (2026-04-30)
### Symptômes
- Build natif `:react-native-fast-tflite:externalNativeBuildDebug` échoue
- Erreurs CMake : chemins trop longs, ninja ne peut pas régénérer `build.ninja`
- Limite Windows 260 chars sur les chemins de fichiers + limite 8191 chars sur la ligne de commande `CreateProcess`
### Solution appliquée
#### 1. `VinEye/android/app/build.gradle` — bloc `externalNativeBuild`
Ajouté dans `android.defaultConfig` :
```gradle
externalNativeBuild {
cmake {
arguments "-DCMAKE_MAKE_PROGRAM=C:\\Users\\Client\\AppData\\Local\\Android\\Sdk\\cmake\\4.1.2\\bin\\ninja.exe",
"-DCMAKE_OBJECT_PATH_MAX=1024",
"-DCMAKE_CXX_USE_RESPONSE_FILE_FOR_OBJECTS=1",
"-DCMAKE_CXX_USE_RESPONSE_FILE_FOR_LIBRARIES=1",
"-DCMAKE_CXX_RESPONSE_FILE_LINK_FLAG=@",
"-DCMAKE_NINJA_FORCE_RESPONSE_FILE=1"
}
}
```
**À quoi servent ces flags** :
| Flag | Rôle |
|------|------|
| `CMAKE_MAKE_PROGRAM` | Pointe vers ninja 1.12.1 (bundle Android cmake 4.1.2) au lieu du ninja 1.10.2 obsolète de cmake 3.22.1 |
| `CMAKE_OBJECT_PATH_MAX=1024` | Augmente la limite des chemins d'objets pour les builds C++ profondément imbriqués |
| `CMAKE_CXX_USE_RESPONSE_FILE_FOR_OBJECTS=1` | Passe la liste d'objets via `@fichier.rsp` au lieu d'arguments inline |
| `CMAKE_CXX_USE_RESPONSE_FILE_FOR_LIBRARIES=1` | Idem pour les libs au link |
| `CMAKE_CXX_RESPONSE_FILE_LINK_FLAG=@` | Préfixe attendu par le linker Clang/MSVC pour les response files |
| `CMAKE_NINJA_FORCE_RESPONSE_FILE=1` | Force ninja à utiliser des response files même quand il pense pouvoir éviter |
**Pourquoi les response files sont critiques** : sur Windows, `CreateProcess` plafonne à 8191 chars. Quand on link `react-native-fast-tflite` avec ses dizaines de modules + headers + libs (TF Lite, Nitro Modules, RN core), la ligne de commande dépasse la limite. Les response files contournent ça en écrivant les arguments dans un fichier `.rsp` passé via `@chemin.rsp`.
#### 2. Registre Windows — `LongPathsEnabled`
Vérifié : `HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1`
(Déjà à 1 sur ce poste, pas de modification nécessaire.)
#### 3. Ninja récent
Ninja 1.12.1 disponible dans `C:\Users\Client\AppData\Local\Android\Sdk\cmake\4.1.2\bin\ninja.exe`.
Pas besoin de télécharger — on pointe `CMAKE_MAKE_PROGRAM` directement dessus.
### Statut
**Résolu** — l'erreur de path/ninja ne se produit plus.
---
## Fix #2`react-native-nitro-modules` headers manquants (2026-04-30, en cours)
### Symptômes
```
CMake Error in CMakeLists.txt:
Imported target "react-native-nitro-modules::NitroModules" includes
non-existent path
"C:/Users/Client/projet_web/Grapevine_Disease_Detection/VinEye/node_modules/react-native-nitro-modules/android/build/headers/nitromodules"
in its INTERFACE_INCLUDE_DIRECTORIES.
```
### Cause
`react-native-fast-tflite` dépend de `react-native-nitro-modules`. Les headers de Nitro sont générés au build de son sous-projet Gradle, mais le clean a effacé `android/build/headers/` avant que fast-tflite ne tente de configurer son CMake.
### Pistes de résolution
1. **Builder dans le bon ordre**`./gradlew :react-native-nitro-modules:assembleDebug` avant `:react-native-fast-tflite:externalNativeBuildDebug`
2. **Régénérer les headers** — supprimer `node_modules/react-native-nitro-modules/android/build/` et relancer un build complet (Gradle régénère)
3. **Reinstaller les modules**`pnpm install --force` puis `pnpm dlx expo prebuild --clean`
4. **Vérifier la version** — incompatibilité possible entre `react-native-fast-tflite` et `react-native-nitro-modules` (vérifier les peerDependencies)
### Statut
🟡 **En cours** — fix CMake/ninja passé, ce nouveau problème est sur la chaîne de dépendances Gradle.
---
## Commandes utiles
```bash
# Clean complet
cd VinEye/android
./gradlew clean
# Build natif uniquement (debug)
./gradlew :app:externalNativeBuildDebug --info
# Run sur device/émulateur
cd VinEye
pnpm dlx expo run:android
# Régénérer le projet natif depuis zéro
pnpm dlx expo prebuild --clean
```
---
## Versions cmake disponibles localement
| Version | Ninja bundlé | Chemin |
|---------|-------------|--------|
| 3.22.1 | 1.10.2 | `~AppData\Local\Android\Sdk\cmake\3.22.1\bin\` |
| 3.31.6 | — | `~AppData\Local\Android\Sdk\cmake\3.31.6\bin\` |
| 4.1.2 | **1.12.1** | `~AppData\Local\Android\Sdk\cmake\4.1.2\bin\` (utilisé) |

View file

@ -15,7 +15,7 @@ Cible des amateurs de vin/jardinage. Scan par camera, identification de maladies
| Styling | **NativeWind v4** (Tailwind) prioritaire, StyleSheet pour ombres/gradients |
| Icones | **lucide-react-native** (bottom bar) + **Ionicons** (reste de l'app) |
| Animations | React Native Reanimated v4 |
| IA | TFLite mock (weighted random) |
| IA | **react-native-fast-tflite** + MobileNetV2 (.tflite, 9.4 MB, 4 classes) |
| Persistance | AsyncStorage |
| i18n | i18next + react-i18next (FR + EN) |
| Camera | expo-camera |
@ -186,5 +186,89 @@ pnpm ios # Build iOS
---
**Version** : 2.0.0
**Derniere mise a jour** : 2026-04-02
**Version** : 2.1.0
**Derniere mise a jour** : 2026-04-29
---
## ML / inference on-device
Le modele MobileNetV2 (val_accuracy 99.93% — voir `docs/paper.md`) est embarque
dans le bundle et execute en local via `react-native-fast-tflite`.
### Pipeline
```
ScannerScreen.handleCapture()
└─ cameraRef.takePictureAsync({ quality: 0.85 })
└─ useDetection.analyze(uri)
└─ services/tflite/model.ts → runInference(uri)
├─ services/ml/preprocessing.ts → preprocessImage(uri)
│ ├─ expo-image-manipulator: resize 224x224 + JPEG base64
│ └─ jpeg-js.decode → Float32Array RGB normalisee /255
└─ tflite.loadTensorflowModel(grapevine_v1.tflite).runSync([input])
└─ softmax/argmax → { class, confidence, allProbabilities }
```
### Mapping des 4 classes ML
| Classe ML | Slug Prisma | Ecran cible |
|-----------|-------------|-------------|
| `healthy` | (aucun) | ResultScreen avec message "Vigne saine" |
| `black_rot` | `black-rot` | DiseaseDetail |
| `esca` | `esca` | DiseaseDetail |
| `leaf_blight` | `leaf-blight` | DiseaseDetail |
Source : `src/services/ml/classes.ts` (`CLASS_TO_SLUG`).
### Seuils de confidence
| Confidence | Result |
|------------|--------|
| >= 70% | `vine` (affiche la classe + CTA DiseaseDetail) |
| 40 - 70% | `uncertain` (suggere de reprendre la photo) |
| < 40% | `not_vine` |
### Fichiers cles
| Fichier | Role |
|---------|------|
| `src/assets/models/grapevine_v1.tflite` | Modele MobileNetV2 (9.4 MB, embarque) |
| `src/services/ml/classes.ts` | Mapping classes ML → slugs Prisma + i18n keys |
| `src/services/ml/preprocessing.ts` | Resize + decode JPEG + normalisation /255 |
| `src/services/tflite/model.ts` | `loadModel()` + `runInference(uri)` (fallback mock si module absent) |
| `src/hooks/useDetection.ts` | Hook React qui wrap `runInference` |
| `src/screens/ScannerScreen.tsx` | Capture camera + appel inference |
| `src/screens/ResultScreen.tsx` | Affichage classe + probabilites + CTA DiseaseDetail |
| `metro.config.js` | Ajout `tflite` aux assetExts |
| `vineye-admin/prisma/seed.ts` | Seed des slugs `black-rot`, `esca`, `leaf-blight` |
### Prebuild requis
`react-native-fast-tflite` est un module natif. Avant de builder/tester sur device :
```bash
cd VinEye
pnpm dlx expo prebuild --clean
pnpm dlx expo run:android # ou run:ios
```
En Expo Go (sans prebuild) : le `runInference` detecte que le module n'est pas
disponible et bascule automatiquement sur un **mock random pondere** (voir
`mockDetection` dans `services/tflite/model.ts`). L'UI reste fonctionnelle pour
le dev sans device natif.
### Roadmap (option C — futur)
- Persister chaque scan via `POST /api/mobile/scans` (Prisma `Scan` table existe deja)
- Telemetry des classes les plus frequentes (pour priorisation re-entrainement)
- A/B switch entre on-device et serveur d'inference (pour comparer perf)
---
## Build natif Android — fixes appliqués
Détail complet : [`.claude/notes/android-build/README.md`](.claude/notes/android-build/README.md)
- ✅ **CMake/Ninja path too long** — résolu via `externalNativeBuild.cmake.arguments` dans `android/app/build.gradle` (response files + ninja 1.12.1 + `CMAKE_OBJECT_PATH_MAX=1024`)
- 🟡 **`react-native-nitro-modules` headers manquants** — survient au clean ; corriger en buildant Nitro avant fast-tflite, ou via `pnpm dlx expo prebuild --clean`

View file

@ -32,7 +32,9 @@
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE"
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION"
]
},
"web": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View file

@ -3,4 +3,6 @@ const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
config.resolver.assetExts.push('tflite', 'bin');
module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 });

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

View file

@ -0,0 +1,188 @@
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 { Text } from '@/components/ui/text';
import { colors } from '@/theme/colors';
import type { Disease } from '@/data/diseases';
interface LargeDiseaseCardProps {
disease: Disease;
onPress: () => void;
index?: number;
}
const TYPE_TINT: Record<Disease['type'], { bg: string; fg: string }> = {
fungal: { bg: '#7B1FA2', fg: '#FFFFFF' },
bacterial: { bg: '#C62828', fg: '#FFFFFF' },
pest: { bg: '#EF6C00', fg: '#FFFFFF' },
abiotic: { bg: '#1565C0', fg: '#FFFFFF' },
};
const SEVERITY_TINT: Record<Disease['severity'], { bg: string; fg: string; labelKey: string }> = {
high: { bg: '#FBE9E7', fg: '#D32F2F', labelKey: 'guides.riskLevel.high' },
medium: { bg: '#FFF8E1', fg: '#F9A825', labelKey: 'guides.riskLevel.medium' },
low: { bg: '#E8F5E9', fg: '#2E7D32', labelKey: 'guides.riskLevel.low' },
};
const TYPE_LABEL: Record<Disease['type'], string> = {
fungal: 'diseases.types.fungal',
bacterial: 'diseases.types.bacterial',
pest: 'diseases.types.pest',
abiotic: 'diseases.types.abiotic',
};
export default function LargeDiseaseCard({
disease,
onPress,
index = 0,
}: LargeDiseaseCardProps) {
const { t } = useTranslation();
const type = TYPE_TINT[disease.type];
const severity = SEVERITY_TINT[disease.severity];
return (
<Animated.View entering={FadeInDown.delay(index * 90).duration(550).springify().damping(16)}>
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.card,
pressed && { transform: [{ scale: 0.98 }] },
]}
>
{/* Header: type badge + icon */}
<View style={styles.header}>
<View style={[styles.typeBadge, { backgroundColor: type.bg }]}>
<Text style={[styles.typeLabel, { color: type.fg }]}>
{t(TYPE_LABEL[disease.type]).toUpperCase()}
</Text>
</View>
<View style={styles.iconCircle}>
<Ionicons
name={disease.icon as keyof typeof Ionicons.glyphMap}
size={26}
color={disease.iconColor}
/>
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.title} numberOfLines={2}>
{t(disease.name)}
</Text>
<Text style={styles.description} numberOfLines={3}>
{t(disease.description)}
</Text>
</View>
{/* Footer: severity tag + arrow button */}
<View style={styles.footer}>
<View style={[styles.severityTag, { backgroundColor: severity.bg }]}>
<AlertTriangle size={14} color={severity.fg} strokeWidth={2.5} />
<Text style={[styles.severityLabel, { color: severity.fg }]}>
{t(severity.labelKey).toUpperCase()}
</Text>
</View>
<View style={styles.arrowButton}>
<ArrowRight size={20} color="#FFFFFF" strokeWidth={2.6} />
</View>
</View>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderWidth: 1,
borderColor: '#EEEEEE',
borderRadius: 32,
padding: 24,
minHeight: 260,
justifyContent: 'space-between',
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.08,
shadowRadius: 30,
elevation: 8,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
typeBadge: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 999,
},
typeLabel: {
fontSize: 11,
fontWeight: '800',
letterSpacing: 1.2,
},
iconCircle: {
width: 44,
height: 44,
borderRadius: 999,
backgroundColor: '#F8F9FA',
alignItems: 'center',
justifyContent: 'center',
},
content: {
gap: 10,
},
title: {
fontSize: 26,
fontWeight: '800',
color: '#1A1A1A',
lineHeight: 32,
letterSpacing: -0.6,
},
description: {
fontSize: 14,
color: '#4A4A4A',
lineHeight: 21,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: '#F5F5F5',
},
severityTag: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
},
severityLabel: {
fontSize: 11,
fontWeight: '800',
letterSpacing: 1,
},
arrowButton: {
width: 48,
height: 48,
borderRadius: 999,
backgroundColor: colors.primary[800],
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 4,
},
});

View file

@ -1,18 +1,12 @@
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native";
import { View, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { HeaderActionButtons } from "@/components/shared/HeaderActionButtons";
import { colors } from "@/theme/colors";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
export default function SearchHeader() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
return (
<View style={styles.headerContainer}>
@ -21,32 +15,7 @@ export default function SearchHeader() {
<Text style={styles.greetingText}>{t("home.greeting")}</Text>
</View>
<View style={styles.buttonsGroup}>
<TouchableOpacity
style={styles.notifButton}
activeOpacity={0.7}
onPress={() => navigation.navigate("Notifications")}
>
<Ionicons
name="notifications-outline"
size={22}
color={colors.neutral[800]}
/>
<View style={styles.notifBadge} />
</TouchableOpacity>
<TouchableOpacity
style={styles.notifButton}
activeOpacity={0.7}
onPress={() => navigation.navigate("Settings")}
>
<Ionicons
name="settings-outline"
size={22}
color={colors.neutral[800]}
/>
</TouchableOpacity>
</View>
<HeaderActionButtons />
</View>
);
}
@ -66,9 +35,9 @@ const styles = StyleSheet.create({
},
brandTitle: {
fontSize: 24,
fontWeight: "900", // Très gras pour l'identité
fontWeight: "900",
color: colors.primary[900],
letterSpacing: -1, // Look "Logo"
letterSpacing: -1,
},
greetingText: {
fontSize: 14,
@ -76,31 +45,4 @@ const styles = StyleSheet.create({
color: colors.neutral[500],
marginTop: -2,
},
buttonsGroup: {
flexDirection: "row" as const,
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#F0F0F0",
borderRadius: 32,
},
notifButton: {
height: 48,
width: 48,
alignItems: "center",
justifyContent: "center",
borderRadius: 32,
},
notifBadge: {
position: "absolute",
top: 10,
right: 10,
width: 9,
height: 9,
borderRadius: 5,
backgroundColor: "#EF4444",
borderWidth: 1.5,
borderColor: "#FFFFFF",
},
});

View file

@ -19,7 +19,7 @@ export function FloatingActions({
activeAction,
}: FloatingActionsProps) {
return (
<View style={styles.column}>
<View style={styles.column} collapsable={false}>
<ActionButton active={activeAction === "layers"} onPress={onLayers}>
<Layers
size={22}
@ -69,6 +69,7 @@ function ActionButton({ children, onPress, active }: ActionButtonProps) {
const styles = StyleSheet.create({
column: {
gap: 12,
elevation: 24,
},
button: {
width: 56,
@ -80,12 +81,12 @@ const styles = StyleSheet.create({
buttonInactive: {
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#F0F0F0",
borderColor: "#E5E7EB",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 10,
elevation: 4,
shadowOpacity: 0.18,
shadowRadius: 12,
elevation: 24,
},
buttonActive: {
backgroundColor: colors.primary[900],
@ -93,6 +94,6 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
elevation: 24,
},
});

View file

@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { Search, MapPin } from "lucide-react-native";
import { Text } from "@/components/ui/text";
import { HeaderActionButtons } from "@/components/shared/HeaderActionButtons";
import { colors } from "@/theme/colors";
import { WINE_REGIONS } from "@/data/wineRegions";
@ -21,33 +22,47 @@ interface FloatingSearchProps {
onFilterPress?: (id: MapFilterId) => void;
}
export function FloatingSearch({ activeFilter, onFilterPress }: FloatingSearchProps) {
export function FloatingSearch({
activeFilter,
onFilterPress,
}: FloatingSearchProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const filters = [
{ id: "myLocation", labelKey: "map.filters.myLocation", icon: "location" as const },
...WINE_REGIONS.map((r) => ({ id: r.id, labelKey: r.labelKey, icon: undefined })),
{
id: "myLocation",
labelKey: "map.filters.myLocation",
icon: "location" as const,
},
...WINE_REGIONS.map((r) => ({
id: r.id,
labelKey: r.labelKey,
icon: undefined,
})),
];
return (
<View>
<View style={styles.searchBar}>
<Search size={20} color={colors.primary[800]} strokeWidth={2} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t("map.searchPlaceholder")}
placeholderTextColor={colors.neutral[500]}
style={styles.input}
/>
<View style={styles.logoWrap}>
<Image
source={require("../../../assets/logo.png")}
style={styles.logo}
resizeMode="cover"
<View style={styles.root} collapsable={false}>
<View style={styles.searchRow}>
<View style={styles.searchBar}>
<Search size={20} color={colors.primary[800]} strokeWidth={2} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t("map.searchPlaceholder")}
placeholderTextColor={colors.neutral[500]}
style={styles.input}
/>
{/* <View style={styles.logoWrap}>
<Image
source={require("../../../assets/logo.png")}
style={styles.logo}
resizeMode="cover"
/>
</View> */}
</View>
<HeaderActionButtons />
</View>
<ScrollView
@ -71,10 +86,7 @@ export function FloatingSearch({ activeFilter, onFilterPress }: FloatingSearchPr
/>
)}
<Text
style={[
styles.chipText,
isActive && styles.chipTextActive,
]}
style={[styles.chipText, isActive && styles.chipTextActive]}
>
{t(filter.labelKey)}
</Text>
@ -87,11 +99,20 @@ export function FloatingSearch({ activeFilter, onFilterPress }: FloatingSearchPr
}
const styles = StyleSheet.create({
root: {
elevation: 24,
},
searchRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
searchBar: {
flex: 1,
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF",
borderRadius: 20,
borderRadius: 75,
paddingHorizontal: 16,
paddingVertical: 12,
gap: 12,
@ -99,7 +120,7 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
elevation: 24,
borderWidth: 1,
borderColor: colors.neutral[200],
},
@ -140,13 +161,13 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 6,
elevation: 2,
elevation: 24,
},
chipActive: {
backgroundColor: colors.primary[800],
borderColor: colors.primary[800],
shadowOpacity: 0.12,
elevation: 4,
elevation: 24,
},
chipText: {
fontSize: 13,

View file

@ -199,7 +199,7 @@ export const VineyardMapView = forwardRef<VineyardMapHandle, VineyardMapViewProp
return (
<WebView
ref={webRef}
style={StyleSheet.absoluteFill}
style={[StyleSheet.absoluteFill, { backgroundColor: "transparent" }]}
originWhitelist={["*"]}
source={{ html }}
onMessage={handleMessage}

View file

@ -0,0 +1,64 @@
import { View, StyleSheet, Text } from 'react-native';
import { ProgressCircle } from '@/components/ui/ProgressCircle';
interface ConfidenceTileProps {
confidence: number;
fillColor: string;
arcColor?: string;
size?: number;
scoreSize?: number;
}
export function ConfidenceTile({
confidence,
fillColor,
arcColor,
size = 64,
scoreSize = 18,
}: ConfidenceTileProps) {
const score = Math.round(confidence * 100);
const stroke = arcColor ?? darken(fillColor, 0.32);
return (
<View
style={[
styles.wrapper,
{ width: size, height: size, borderRadius: size / 2, backgroundColor: fillColor },
]}
>
<View style={StyleSheet.absoluteFillObject}>
<ProgressCircle
size={size}
strokeWidth={Math.max(4, Math.round(size * 0.09))}
progress={confidence}
color={stroke}
trackColor="rgba(255,255,255,0.35)"
/>
</View>
<Text style={[styles.score, { fontSize: scoreSize }]}>{score}</Text>
</View>
);
}
function darken(hex: string, ratio: number): string {
const m = hex.replace('#', '').match(/.{1,2}/g);
if (!m || m.length < 3) return hex;
const [r, g, b] = m.map((c) => parseInt(c, 16));
const dr = Math.max(0, Math.round(r * (1 - ratio)));
const dg = Math.max(0, Math.round(g * (1 - ratio)));
const db = Math.max(0, Math.round(b * (1 - ratio)));
return `#${[dr, dg, db].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
}
const styles = StyleSheet.create({
wrapper: {
alignItems: 'center',
justifyContent: 'center',
},
score: {
fontWeight: '800',
color: '#FFFFFF',
letterSpacing: -0.5,
},
});

View file

@ -6,10 +6,13 @@ import { Image } from 'expo-image';
import { Star, StarOff, Trash2 } from 'lucide-react-native';
import { toast } from 'sonner-native';
import { ConfidenceTile } from '@/components/my-plants/ConfidenceTile';
import { StatusTag } from '@/components/my-plants/StatusTag';
import { getCepageById } from '@/utils/cepages';
import { hapticLight, hapticSuccess } from '@/services/haptics';
import { colors } from '@/theme/colors';
import type { ScanRecord } from '@/types/detection';
import { getScanStatus } from '@/types/detection';
import type { ScanRecord, ScanStatus } from '@/types/detection';
interface ScanListItemProps {
scan: ScanRecord;
@ -18,12 +21,22 @@ interface ScanListItemProps {
onDelete: () => void;
}
const STATUS_FILL: Record<ScanStatus, string> = {
healthy: colors.primary[700],
infected: '#E63946',
uncertain: '#F4A261',
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
const FALLBACK_IMAGE = require('../../../assets/logo.png');
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
function getPlantName(scan: ScanRecord, t: (key: string) => string): string {
if (scan.customName && scan.customName.trim().length > 0) return scan.customName.trim();
if (scan.detection.cepageId) {
const c = getCepageById(scan.detection.cepageId);
if (c) return c.name.fr;
@ -33,19 +46,12 @@ function getPlantName(scan: ScanRecord, t: (key: string) => string): string {
return t('result.notVine');
}
function getStatusLabel(scan: ScanRecord, t: (key: string) => string): string {
if (scan.detection.result === 'vine') return t('result.vineDetected');
if (scan.detection.result === 'uncertain') return t('result.uncertain');
return t('result.notVine');
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const FALLBACK_IMAGE = require('../../../assets/logo.png');
export function ScanListItem({ scan, onPress, onToggleFavorite, onDelete }: ScanListItemProps) {
const { t } = useTranslation();
const swipeableRef = useRef<Swipeable>(null);
const isFav = scan.isFavorite === true;
const status = getScanStatus(scan);
const hasImage = !!scan.detection.imageUri;
function handleFavorite() {
onToggleFavorite();
@ -118,12 +124,12 @@ export function ScanListItem({ scan, onPress, onToggleFavorite, onDelete }: Scan
}}
activeOpacity={0.7}
>
{/* Image */}
{/* Image (cover for real captures, contain for fallback) */}
<View style={styles.imageWrapper}>
<Image
source={scan.detection.imageUri ? { uri: scan.detection.imageUri } : FALLBACK_IMAGE}
style={styles.image}
contentFit={scan.detection.imageUri ? 'cover' : 'contain'}
source={hasImage ? { uri: scan.detection.imageUri } : FALLBACK_IMAGE}
style={hasImage ? styles.image : styles.fallbackImage}
contentFit={hasImage ? 'cover' : 'contain'}
transition={200}
/>
</View>
@ -133,18 +139,26 @@ export function ScanListItem({ scan, onPress, onToggleFavorite, onDelete }: Scan
<Text style={styles.plantName} numberOfLines={1}>
{getPlantName(scan, t)}
</Text>
<Text style={styles.status} numberOfLines={1}>
{getStatusLabel(scan, t)}
</Text>
<View style={styles.metaRow}>
<StatusTag status={status} />
</View>
<Text style={styles.time}>{formatTime(scan.createdAt)}</Text>
</View>
{/* Favorite star */}
{isFav && (
<View style={styles.starWrapper}>
<Star size={18} color="#FFB800" fill="#FFB800" />
</View>
)}
{/* Confidence score on the right */}
<View style={styles.rightSlot}>
<ConfidenceTile
confidence={scan.detection.confidence}
fillColor={STATUS_FILL[status]}
size={44}
scoreSize={13}
/>
{isFav && (
<View style={styles.favStarMini}>
<Star size={14} color="#FFB800" fill="#FFB800" />
</View>
)}
</View>
</TouchableOpacity>
</Swipeable>
);
@ -168,33 +182,48 @@ const styles = StyleSheet.create({
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#F8F9FB',
alignItems: 'center',
justifyContent: 'center',
},
image: {
width: 64,
height: 64,
},
fallbackImage: {
width: 64,
height: 64,
},
content: {
flex: 1,
marginLeft: 12,
gap: 2,
gap: 4,
},
plantName: {
fontSize: 16,
fontWeight: '700',
color: '#1A1A1A',
},
status: {
fontSize: 13,
color: '#8E8E93',
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
time: {
fontSize: 12,
color: '#8E8E93',
marginTop: 2,
},
starWrapper: {
paddingLeft: 8,
rightSlot: {
alignItems: 'center',
gap: 4,
marginLeft: 8,
},
favStarMini: {
width: 22,
height: 22,
alignItems: 'center',
justifyContent: 'center',
},
// Swipe actions
actionsRow: {
flexDirection: 'row',
alignItems: 'center',

View file

@ -0,0 +1,64 @@
import { View, Text, StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next';
import { colors } from '@/theme/colors';
import type { ScanStatus } from '@/types/detection';
interface StatusTagProps {
status: ScanStatus;
}
const STATUS_STYLE: Record<ScanStatus, { bg: string; fg: string; dot: string; labelKey: string }> = {
healthy: {
bg: colors.primary[100],
fg: colors.primary[800],
dot: colors.primary[700],
labelKey: 'myPlants.status.healthy',
},
infected: {
bg: '#FCEBEB',
fg: '#A32D2D',
dot: '#E63946',
labelKey: 'myPlants.status.infected',
},
uncertain: {
bg: '#FAEEDA',
fg: '#BA7517',
dot: '#F4A261',
labelKey: 'myPlants.status.uncertain',
},
};
export function StatusTag({ status }: StatusTagProps) {
const { t } = useTranslation();
const cfg = STATUS_STYLE[status];
return (
<View style={[styles.tag, { backgroundColor: cfg.bg }]}>
<View style={[styles.dot, { backgroundColor: cfg.dot }]} />
<Text style={[styles.label, { color: cfg.fg }]}>{t(cfg.labelKey)}</Text>
</View>
);
}
const styles = StyleSheet.create({
tag: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
gap: 5,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
},
label: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.2,
},
});

View file

@ -0,0 +1,258 @@
import { useEffect, useState } from 'react';
import {
View,
Modal,
Pressable,
TextInput,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react-native';
import { toast } from 'sonner-native';
import { Text } from '@/components/ui/text';
import { colors } from '@/theme/colors';
import { AVATAR_OPTIONS, isValidEmail, type AvatarEmoji, type UserProfile } from '@/types/user';
interface EditProfileModalProps {
visible: boolean;
initialProfile: UserProfile;
onClose: () => void;
onSave: (next: UserProfile) => void;
}
export function EditProfileModal({
visible,
initialProfile,
onClose,
onSave,
}: EditProfileModalProps) {
const { t } = useTranslation();
const [displayName, setDisplayName] = useState(initialProfile.displayName);
const [email, setEmail] = useState(initialProfile.email);
const [avatar, setAvatar] = useState<AvatarEmoji>(initialProfile.avatar);
useEffect(() => {
if (visible) {
setDisplayName(initialProfile.displayName);
setEmail(initialProfile.email);
setAvatar(initialProfile.avatar);
}
}, [visible, initialProfile]);
function handleSave() {
const trimmedName = displayName.trim();
const trimmedEmail = email.trim();
if (trimmedEmail.length > 0 && !isValidEmail(trimmedEmail)) {
toast.error(t('profile.invalidEmail'));
return;
}
onSave({
displayName: trimmedName,
email: trimmedEmail,
avatar,
});
toast.success(t('profile.saved'));
onClose();
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.overlay}
>
<Pressable style={styles.backdrop} onPress={onClose} />
<View style={styles.card}>
<View style={styles.header}>
<Text style={styles.title}>{t('profile.editTitle')}</Text>
<Pressable onPress={onClose} hitSlop={10}>
<X size={20} color={colors.neutral[600]} />
</Pressable>
</View>
<ScrollView showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>{t('profile.avatarLabel')}</Text>
<View style={styles.avatarRow}>
{AVATAR_OPTIONS.map((option) => {
const selected = option === avatar;
return (
<Pressable
key={option}
onPress={() => setAvatar(option)}
style={[styles.avatarOption, selected && styles.avatarOptionSelected]}
>
<Text style={styles.avatarEmoji}>{option}</Text>
</Pressable>
);
})}
</View>
<Text style={styles.fieldLabel}>{t('profile.nameField')}</Text>
<TextInput
style={styles.input}
value={displayName}
onChangeText={setDisplayName}
placeholder={t('profile.namePlaceholder')}
placeholderTextColor={colors.neutral[400]}
maxLength={64}
returnKeyType="next"
/>
<Text style={styles.fieldLabel}>{t('profile.emailField')}</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder={t('profile.emailPlaceholder')}
placeholderTextColor={colors.neutral[400]}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
maxLength={128}
returnKeyType="done"
onSubmitEditing={handleSave}
/>
</ScrollView>
<View style={styles.actions}>
<Pressable
onPress={onClose}
style={({ pressed }) => [
styles.button,
styles.buttonGhost,
pressed && { opacity: 0.7 },
]}
>
<Text style={styles.buttonGhostLabel}>{t('common.cancel')}</Text>
</Pressable>
<Pressable
onPress={handleSave}
style={({ pressed }) => [
styles.button,
styles.buttonPrimary,
pressed && { opacity: 0.85 },
]}
>
<Text style={styles.buttonPrimaryLabel}>{t('profile.saveButton')}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.45)',
},
card: {
width: '100%',
maxWidth: 420,
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 20,
gap: 14,
maxHeight: '90%',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: 18,
fontWeight: '700',
color: colors.neutral[900],
},
fieldLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.neutral[500],
textTransform: 'uppercase',
letterSpacing: 1,
marginTop: 12,
marginBottom: 8,
},
avatarRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
avatarOption: {
width: 52,
height: 52,
borderRadius: 999,
backgroundColor: '#F8F9FA',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: 'transparent',
},
avatarOptionSelected: {
backgroundColor: colors.primary[100],
borderColor: colors.primary[800],
},
avatarEmoji: {
fontSize: 28,
lineHeight: 36,
textAlign: 'center',
includeFontPadding: false,
},
input: {
borderWidth: 1,
borderColor: colors.neutral[300],
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
color: colors.neutral[900],
backgroundColor: '#FAFAFA',
},
actions: {
flexDirection: 'row',
gap: 10,
marginTop: 12,
},
button: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
buttonGhost: {
backgroundColor: colors.neutral[100],
},
buttonGhostLabel: {
fontSize: 15,
fontWeight: '600',
color: colors.neutral[800],
},
buttonPrimary: {
backgroundColor: colors.primary[800],
},
buttonPrimaryLabel: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
},
});

View file

@ -0,0 +1,78 @@
import { View, TouchableOpacity, StyleSheet } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import { colors } from "@/theme/colors";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
interface HeaderActionButtonsProps {
showNotifBadge?: boolean;
}
export function HeaderActionButtons({
showNotifBadge = true,
}: HeaderActionButtonsProps) {
const navigation = useNavigation<Nav>();
return (
<View style={styles.group}>
<TouchableOpacity
style={styles.button}
activeOpacity={0.7}
onPress={() => navigation.navigate("Notifications")}
accessibilityLabel="Notifications"
>
<Ionicons
name="notifications-outline"
size={22}
color={colors.neutral[800]}
/>
{showNotifBadge && <View style={styles.badge} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
activeOpacity={0.7}
onPress={() => navigation.navigate("Settings")}
accessibilityLabel="Settings"
>
<Ionicons
name="settings-outline"
size={22}
color={colors.neutral[800]}
/>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
group: {
flexDirection: "row",
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#F0F0F0",
borderRadius: 32,
},
button: {
height: 48,
width: 48,
alignItems: "center",
justifyContent: "center",
borderRadius: 32,
},
badge: {
position: "absolute",
top: 10,
right: 10,
width: 9,
height: 9,
borderRadius: 5,
backgroundColor: "#EF4444",
borderWidth: 1.5,
borderColor: "#FFFFFF",
},
});

View file

@ -2,7 +2,6 @@ import { useState, useCallback } from 'react';
import { runInference } from '@/services/tflite/model';
import type { Detection } from '@/types/detection';
// TODO: Remplacer par le vrai hook TFLite avec react-native-fast-tflite
export function useDetection() {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [lastDetection, setLastDetection] = useState<Detection | null>(null);
@ -17,7 +16,7 @@ export function useDetection() {
setLastDetection(detection);
return detection;
} catch (err) {
setError('Erreur lors de l\'analyse. Veuillez réessayer.');
setError("Erreur lors de l'analyse. Veuillez reessayer.");
return null;
} finally {
setIsAnalyzing(false);

View file

@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from 'react';
import { storage } from '@/services/storage';
import { DEFAULT_PROFILE, type UserProfile } from '@/types/user';
export function useUserProfile() {
const [profile, setProfile] = useState<UserProfile>(DEFAULT_PROFILE);
const [isLoading, setIsLoading] = useState(true);
const loadProfile = useCallback(async () => {
setIsLoading(true);
const saved = await storage.get<UserProfile>(storage.KEYS.USER_PROFILE);
setProfile(saved ?? DEFAULT_PROFILE);
setIsLoading(false);
}, []);
useEffect(() => {
loadProfile();
}, [loadProfile]);
const updateProfile = useCallback(async (partial: Partial<UserProfile>) => {
setProfile((prev) => {
const next: UserProfile = { ...prev, ...partial };
storage.set(storage.KEYS.USER_PROFILE, next);
return next;
});
}, []);
return { profile, updateProfile, isLoading, reload: loadProfile };
}

View file

@ -277,6 +277,11 @@
"unfavorited": "Removed from favorites",
"deleted": "Scan deleted"
},
"status": {
"healthy": "Healthy",
"infected": "Diseased",
"uncertain": "Uncertain"
},
"empty": {
"title": "No plants scanned yet",
"subtitle": "Scan your first plant to start your collection",
@ -316,6 +321,11 @@
"moderate": "Moderate",
"low": "Low"
},
"riskLevel": {
"high": "High risk",
"medium": "Medium risk",
"low": "Low risk"
},
"healthyLeaf": {
"title": "Recognizing a healthy leaf",
"subtitle": "Basics for beginners",
@ -446,7 +456,18 @@
"resetData": "Reset data",
"resetConfirm": "Are you sure you want to reset all data?",
"days": "days",
"xpTotal": "Total XP"
"xpTotal": "Total XP",
"level": "Level {{level}}",
"editTitle": "Edit profile",
"editButton": "Edit",
"saveButton": "Save",
"saved": "Profile saved",
"invalidEmail": "Invalid email",
"nameField": "Name",
"emailField": "Email",
"namePlaceholder": "Your name",
"emailPlaceholder": "you@email.com",
"avatarLabel": "Avatar"
},
"settings": {
"general": "General",
@ -462,7 +483,12 @@
"referBody": "Share VinEye and earn bonus XP for every friend you invite.",
"developer": "Developer",
"seedTestData": "Add mock plants",
"seedDone": "5 mock plants added"
"seedDone": "5 mock plants added",
"notifications": {
"label": "Push notifications",
"enabled": "Notifications enabled",
"disabled": "Notifications disabled"
}
},
"achievements": {
"firstScan": "First Scan",

View file

@ -277,6 +277,11 @@
"unfavorited": "Retiré des favoris",
"deleted": "Scan supprimé"
},
"status": {
"healthy": "Saine",
"infected": "Malade",
"uncertain": "Incertain"
},
"empty": {
"title": "Aucune plante scannée",
"subtitle": "Scannez votre première plante pour commencer votre collection",
@ -316,6 +321,11 @@
"moderate": "Modéré",
"low": "Faible"
},
"riskLevel": {
"high": "Risque Élevé",
"medium": "Risque Modéré",
"low": "Risque Faible"
},
"healthyLeaf": {
"title": "Reconnaître une feuille saine",
"subtitle": "Les bases pour débutants",
@ -446,7 +456,18 @@
"resetData": "Réinitialiser les données",
"resetConfirm": "Êtes-vous sûr de vouloir réinitialiser toutes les données ?",
"days": "jours",
"xpTotal": "XP total"
"xpTotal": "XP total",
"level": "Niveau {{level}}",
"editTitle": "Modifier le profil",
"editButton": "Modifier",
"saveButton": "Enregistrer",
"saved": "Profil enregistré",
"invalidEmail": "Email invalide",
"nameField": "Nom",
"emailField": "Email",
"namePlaceholder": "Votre nom",
"emailPlaceholder": "votre@email.com",
"avatarLabel": "Avatar"
},
"settings": {
"general": "Général",
@ -462,7 +483,12 @@
"referBody": "Partagez VinEye et gagnez des XP bonus pour chaque ami invité.",
"developer": "Développeur",
"seedTestData": "Ajouter des plantes fictives",
"seedDone": "5 plantes fictives ajoutées"
"seedDone": "5 plantes fictives ajoutées",
"notifications": {
"label": "Notifications push",
"enabled": "Notifications activées",
"disabled": "Notifications désactivées"
}
},
"achievements": {
"firstScan": "Premier Scan",

View file

@ -33,7 +33,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
borderTopWidth: 1,
borderTopColor: colors.neutral[300],
paddingBottom: insets.bottom,
paddingTop: 8,
paddingTop: 6,
alignItems: "flex-end",
}}
>
@ -84,7 +84,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
justifyContent: "center",
marginTop: -25,
shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 },
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,

View file

@ -9,7 +9,7 @@ import { Text } from "@/components/ui/text";
import SearchHeader from "@/components/home/SearchHeader";
import SearchSection from "@/components/home/SearchSection";
import AnimatedSegmentedControl from "@/components/guides/AnimatedSegmentedControl";
import SmallDiseaseCard from "@/components/ui/SmallDiseaseCard";
import LargeDiseaseCard from "@/components/guides/LargeDiseaseCard";
import GuideListItem from "@/components/ui/GuideListItem";
import { DiseaseCardSkeleton, GuideListItemSkeleton } from "@/components/ui/Skeleton";
import { useDiseases } from "@/hooks/useDiseases";
@ -76,24 +76,20 @@ export default function GuidesScreen() {
/>
{activeTab === 0 ? (
<View style={styles.grid}>
<View style={styles.diseaseList}>
{showDiseasesSkeleton
? Array.from({ length: 4 }).map((_, i) => (
<View key={i} style={styles.gridItem}>
<DiseaseCardSkeleton style={{ height: 160 }} />
</View>
? Array.from({ length: 3 }).map((_, i) => (
<DiseaseCardSkeleton key={i} style={{ height: 260, borderRadius: 32 }} />
))
: diseases.map((disease, index) => (
<View key={disease.id} style={styles.gridItem}>
<SmallDiseaseCard
disease={disease}
onPress={() =>
navigation.navigate("DiseaseDetail", { diseaseId: disease.id })
}
index={index}
size="grid"
/>
</View>
<LargeDiseaseCard
key={disease.id}
disease={disease}
onPress={() =>
navigation.navigate("DiseaseDetail", { diseaseId: disease.id })
}
index={index}
/>
))}
</View>
) : (
@ -124,16 +120,11 @@ export default function GuidesScreen() {
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#F8F9FB",
backgroundColor: "#FAFAFA",
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
paddingHorizontal: 16,
gap: 12,
},
gridItem: {
width: "48%",
diseaseList: {
paddingHorizontal: 20,
gap: 16,
},
guidesSection: {
paddingHorizontal: 16,

View file

@ -181,6 +181,7 @@ export default function MapScreen() {
<View
style={[styles.searchSlot, { paddingTop: insets.top + 8 }]}
pointerEvents="box-none"
collapsable={false}
>
<FloatingSearch activeFilter={activeFilter} onFilterPress={handleFilterPress} />
</View>
@ -194,12 +195,16 @@ export default function MapScreen() {
defaultIndex={isEmpty ? 1 : 0}
/>
<View style={styles.actionsSlot} pointerEvents="box-none">
<View
style={styles.actionsSlot}
pointerEvents="box-none"
collapsable={false}
>
<FloatingActions
onLocate={handleLocateUser}
onLayers={handleComingSoon}
onSatellite={handleComingSoon}
activeAction={activeFilter === "myLocation" ? "locate" : undefined}
activeAction={activeFilter === "myLocation" ? "locate" : "layers"}
/>
</View>
</View>
@ -223,12 +228,14 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
paddingHorizontal: 16,
zIndex: 20,
elevation: 24,
},
actionsSlot: {
position: "absolute",
right: 16,
top: "30%",
zIndex: 10,
elevation: 10,
zIndex: 20,
elevation: 24,
},
});

View file

@ -17,6 +17,7 @@ import { Search, ScanLine } from 'lucide-react-native';
import { Text } from '@/components/ui/text';
import { DateGroupAccordion } from '@/components/my-plants/DateGroupAccordion';
import { HeaderActionButtons } from '@/components/shared/HeaderActionButtons';
import { useHistory } from '@/hooks/useHistory';
import { getCepageById } from '@/utils/cepages';
import { groupScansByDate } from '@/utils/dateGrouping';
@ -136,6 +137,7 @@ export default function MyPlantsScreen() {
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t('myPlants.title')}</Text>
<HeaderActionButtons />
</View>
{/* Search bar */}
@ -205,9 +207,13 @@ const styles = StyleSheet.create({
backgroundColor: '#F8F9FB',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 4,
gap: 12,
},
title: {
fontSize: 28,

View file

@ -1,19 +1,30 @@
import { View, ScrollView, StyleSheet, Platform, Dimensions, TouchableOpacity } from "react-native";
import { useState } from "react";
import {
View,
ScrollView,
StyleSheet,
Platform,
Dimensions,
TouchableOpacity,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Pencil } from "lucide-react-native";
import { Text } from "@/components/ui/text";
import { EditProfileModal } from "@/components/profile/EditProfileModal";
import { colors } from "@/theme/colors";
import { useGameProgress } from "@/hooks/useGameProgress";
import { useUserProfile } from "@/hooks/useUserProfile";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
const { width } = Dimensions.get("window");
const STAT_CARD_SIZE = (width - 56) / 2; // Ajusté pour le gap de 16
const STAT_CARD_SIZE = (width - 56) / 2;
const BENTO_STATS = [
{ key: "scans", icon: "scan-outline", iconColor: "#F59E0B", label: "profile.totalScans" },
@ -26,50 +37,61 @@ export default function ProfileScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { progress } = useGameProgress();
const { profile, updateProfile } = useUserProfile();
const [editing, setEditing] = useState(false);
function handleBack() {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
navigation.navigate("Main" as any);
navigation.navigate("Main" as never);
}
}
return (
<View style={styles.root}>
{/* Hero Header - Style Courbé */}
{/* Hero Header */}
<View style={styles.heroBlock}>
<SafeAreaView edges={["top"]} style={styles.heroSafeArea}>
<View style={styles.heroTopRow}>
<TouchableOpacity onPress={handleBack} style={styles.heroBackBtn}>
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate("Settings")} style={styles.heroSettingsBtn}>
<Ionicons name="settings-outline" size={22} color={colors.primary[800]} />
<TouchableOpacity onPress={handleBack} style={styles.heroBackBtn} activeOpacity={0.7}>
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* Avatar avec bague de séparation */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Avatar */}
<View style={styles.avatarContainer}>
<View style={styles.avatarRing}>
<View style={styles.avatar}>
<Text style={styles.avatarEmoji}>🧑🌾</Text>
<Text style={styles.avatarEmoji}>{profile.avatar}</Text>
</View>
</View>
</View>
{/* User Info - Focus sur la clarté */}
{/* User Info */}
<View style={styles.infoCard}>
<Text style={styles.userName}>Yanis Cyrius</Text>
<Text style={styles.userEmail}>yanis@vineye.app</Text>
<Text style={styles.userName}>
{profile.displayName || t("profile.namePlaceholder")}
</Text>
<Text style={styles.userEmail}>
{profile.email || t("profile.emailPlaceholder")}
</Text>
<View style={styles.actionRow}>
<TouchableOpacity style={styles.friendBtn} activeOpacity={0.8}>
<Text style={styles.friendBtnText}>+ Friends</Text>
<TouchableOpacity
style={styles.editBtn}
activeOpacity={0.85}
onPress={() => setEditing(true)}
>
<Pencil size={14} color={colors.primary[800]} strokeWidth={2.4} />
<Text style={styles.editBtnText}>{t("profile.editButton")}</Text>
</TouchableOpacity>
<View style={styles.xpBadge}>
<Text style={styles.xpBadgeText}>{progress.xp} XP</Text>
@ -77,15 +99,19 @@ export default function ProfileScreen() {
</View>
</View>
{/* Stats Grid - Bento Style Pur */}
{/* Stats Grid */}
<View style={styles.statsGrid}>
{BENTO_STATS.map((stat) => (
<View key={stat.key} style={styles.statCard}>
<View style={[styles.statIconWrap, { backgroundColor: `${stat.iconColor}15` }]}>
<Ionicons name={stat.icon as any} size={22} color={stat.iconColor} />
<View
style={[styles.statIconWrap, { backgroundColor: `${stat.iconColor}15` }]}
>
<Ionicons name={stat.icon as keyof typeof Ionicons.glyphMap} size={22} color={stat.iconColor} />
</View>
<Text style={styles.statValue}>
{stat.key === "grapes" ? (progress.uniqueGrapes?.length ?? 0) : progress[stat.key as keyof typeof progress] || 0}
{stat.key === "grapes"
? progress.uniqueGrapes?.length ?? 0
: (progress[stat.key as keyof typeof progress] as number) ?? 0}
</Text>
<Text style={styles.statLabel}>{t(stat.label)}</Text>
</View>
@ -94,6 +120,13 @@ export default function ProfileScreen() {
<View style={{ height: 60 }} />
</ScrollView>
<EditProfileModal
visible={editing}
initialProfile={profile}
onClose={() => setEditing(false)}
onSave={(next) => updateProfile(next)}
/>
</View>
);
}
@ -101,7 +134,7 @@ export default function ProfileScreen() {
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#F8F9FB", // Gris très clair bleuté
backgroundColor: "#FAFAFA",
},
heroBlock: {
height: 200,
@ -114,23 +147,14 @@ const styles = StyleSheet.create({
},
heroTopRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingTop: 10,
},
heroBackBtn: {
width: 44,
height: 44,
borderRadius: 16,
backgroundColor: "rgba(255,255,255,0.15)",
alignItems: "center",
justifyContent: "center",
},
heroSettingsBtn: {
width: 44,
height: 44,
borderRadius: 16,
backgroundColor: "#FFFFFF",
width: 40,
height: 40,
borderRadius: 14,
backgroundColor: "rgba(255,255,255,0.18)",
alignItems: "center",
justifyContent: "center",
},
@ -146,27 +170,36 @@ const styles = StyleSheet.create({
marginBottom: 20,
},
avatarRing: {
width: 110,
height: 110,
borderRadius: 55,
width: 116,
height: 116,
borderRadius: 58,
backgroundColor: "#FFFFFF",
alignItems: "center",
justifyContent: "center",
...Platform.select({
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.1, shadowRadius: 12 },
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.1,
shadowRadius: 12,
},
android: { elevation: 8 },
}),
},
avatar: {
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: colors.primary[50],
width: 104,
height: 104,
borderRadius: 52,
backgroundColor: colors.primary[100],
alignItems: "center",
justifyContent: "center",
overflow: "visible",
},
avatarEmoji: {
fontSize: 48,
fontSize: 56,
lineHeight: 72,
textAlign: "center",
includeFontPadding: false,
},
infoCard: {
backgroundColor: "#FFFFFF",
@ -175,47 +208,49 @@ const styles = StyleSheet.create({
alignItems: "center",
marginBottom: 20,
borderWidth: 1,
borderColor: "#F0F0F0",
borderColor: colors.neutral[200],
},
userName: {
fontSize: 24,
fontWeight: "800",
color: "#1A1A1A",
letterSpacing: -0.5,
fontSize: 22,
fontWeight: "700",
color: colors.neutral[900],
letterSpacing: -0.4,
},
userEmail: {
fontSize: 14,
color: "#A0A0A0",
color: colors.neutral[500],
marginTop: 2,
marginBottom: 20,
marginBottom: 18,
},
actionRow: {
flexDirection: "row",
gap: 12,
alignItems: "center",
},
friendBtn: {
backgroundColor: "#FFFFFF",
borderWidth: 1.5,
borderColor: "#F97316",
editBtn: {
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: colors.primary[100],
borderRadius: 100,
paddingHorizontal: 20,
paddingHorizontal: 16,
paddingVertical: 10,
},
friendBtnText: {
editBtnText: {
fontSize: 14,
fontWeight: "600",
color: "#F97316",
color: colors.primary[800],
},
xpBadge: {
backgroundColor: colors.primary[600],
backgroundColor: colors.primary[700],
borderRadius: 100,
paddingHorizontal: 20,
paddingHorizontal: 18,
paddingVertical: 10,
justifyContent: "center",
},
xpBadgeText: {
fontSize: 14,
fontWeight: "600",
fontWeight: "700",
color: "#FFFFFF",
},
statsGrid: {
@ -230,7 +265,7 @@ const styles = StyleSheet.create({
padding: 20,
marginBottom: 16,
borderWidth: 1,
borderColor: "#F2F2F2",
borderColor: colors.neutral[200],
},
statIconWrap: {
width: 44,
@ -242,12 +277,13 @@ const styles = StyleSheet.create({
},
statValue: {
fontSize: 22,
fontWeight: "500", // Medium au lieu de Bold pour le look premium
color: "#1A1A1A",
fontWeight: "700",
color: colors.neutral[900],
},
statLabel: {
fontSize: 13,
color: "#9A9A9A",
fontWeight: "500",
color: colors.neutral[500],
marginTop: 4,
},
});
});

View file

@ -17,38 +17,37 @@ import { ProgressCircle } from '@/components/ui/ProgressCircle';
import { Button } from '@/components/ui/Button';
import { Text } from '@/components/ui/text';
import { Badge } from '@/components/ui/Badge';
import { getCepageById } from '@/utils/cepages';
import { colors } from '@/theme/colors';
import { CLASS_TO_LABEL_KEY, CLASS_TO_SLUG } from '@/services/ml/classes';
import type { RootStackParamList } from '@/types/navigation';
import type { DetectionResult } from '@/types/detection';
import type { Detection, DetectionResult, DiseaseClass } from '@/types/detection';
type ResultNav = NativeStackNavigationProp<RootStackParamList, 'Result'>;
type ResultRoute = RouteProp<RootStackParamList, 'Result'>;
function getResultColor(result: DetectionResult): string {
if (result === 'vine') return colors.success;
if (result === 'uncertain') return colors.warning;
return colors.danger;
function getStatusColor(detection: Detection): string {
if (detection.diseaseClass === 'healthy' && detection.result === 'vine') return colors.success;
if (detection.result === 'vine') return colors.danger;
if (detection.result === 'uncertain') return colors.warning;
return colors.neutral[500];
}
function InfoCard({ icon, iconColor, label, value }: {
icon: keyof typeof Ionicons.glyphMap;
iconColor: string;
label: string;
value: string;
}) {
return (
<View className="w-[48%] gap-1 rounded-[14px] bg-white p-[14px] shadow-sm" style={{ elevation: 1 }}>
<View
className="h-8 w-8 items-center justify-center rounded-lg"
style={{ backgroundColor: iconColor + '18' }}
>
<Ionicons name={icon} size={20} color={iconColor} />
</View>
<Text className="text-[11px] text-neutral-500">{label}</Text>
<Text className="text-[15px] font-semibold text-neutral-900">{value}</Text>
</View>
);
function getStatusIcon(detection: Detection): keyof typeof Ionicons.glyphMap {
if (detection.diseaseClass === 'healthy' && detection.result === 'vine') return 'checkmark-circle';
if (detection.result === 'vine') return 'alert-circle';
if (detection.result === 'uncertain') return 'help-circle';
return 'close-circle';
}
function getStatusLabel(detection: Detection, t: (k: string) => string): string {
if (detection.diseaseClass === 'healthy' && detection.result === 'vine') {
return t('result.healthy');
}
if (detection.result === 'vine' && detection.diseaseClass) {
return t(CLASS_TO_LABEL_KEY[detection.diseaseClass]);
}
if (detection.result === 'uncertain') return t('result.uncertain');
return t('result.notVine');
}
export default function ResultScreen() {
@ -57,8 +56,9 @@ export default function ResultScreen() {
const route = useRoute<ResultRoute>();
const { detection } = route.params;
const cepage = detection.cepageId ? getCepageById(detection.cepageId) : undefined;
const resultColor = getResultColor(detection.result);
const statusColor = getStatusColor(detection);
const statusIcon = getStatusIcon(detection);
const statusLabel = getStatusLabel(detection, t);
const headerOpacity = useSharedValue(0);
const headerScale = useSharedValue(0.8);
@ -82,17 +82,18 @@ export default function ResultScreen() {
transform: [{ translateY: cardTranslateY.value }],
}));
const resultLabel =
detection.result === 'vine'
? t('result.vineDetected')
: detection.result === 'uncertain'
? t('result.uncertain')
: t('result.notVine');
const diseaseSlug = detection.diseaseSlug;
const showDiseaseCta = detection.result === 'vine' && diseaseSlug;
const showHealthy = detection.result === 'vine' && detection.diseaseClass === 'healthy';
function handleViewDisease() {
if (!diseaseSlug) return;
navigation.navigate('DiseaseDetail', { diseaseId: diseaseSlug.replace(/-/g, '_') });
}
return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]">
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-5 p-4 pb-12">
{/* Close button */}
<TouchableOpacity
className="h-8 w-8 items-center justify-center self-end rounded-full bg-neutral-200"
onPress={() => navigation.goBack()}
@ -100,91 +101,145 @@ export default function ResultScreen() {
<Ionicons name="close" size={20} color={colors.neutral[700]} />
</TouchableOpacity>
{/* Confidence circle */}
<Animated.View className="items-center gap-3 py-4" style={headerStyle}>
<ProgressCircle
size={100}
strokeWidth={8}
progress={detection.confidence / 100}
color={resultColor}
trackColor={resultColor + '25'}
color={statusColor}
trackColor={statusColor + '25'}
>
<Text className="text-[20px] font-extrabold" style={{ color: resultColor }}>
<Text className="text-[20px] font-extrabold" style={{ color: statusColor }}>
{detection.confidence}%
</Text>
</ProgressCircle>
{/* Success message with checkmark */}
<View className="flex-row items-center gap-1.5">
<Ionicons
name={detection.result === 'vine' ? 'checkmark-circle' : detection.result === 'uncertain' ? 'help-circle' : 'close-circle'}
size={20}
color={resultColor}
/>
<Text className="text-[13px] font-medium" style={{ color: resultColor }}>
{resultLabel}
<Ionicons name={statusIcon} size={20} color={statusColor} />
<Text className="text-[13px] font-medium" style={{ color: statusColor }}>
{statusLabel}
</Text>
</View>
</Animated.View>
{/* Plant name + tags + description + info grid */}
{cepage && detection.result === 'vine' && (
<Animated.View style={cardStyle}>
<Text className="mb-1 text-[24px] font-bold text-neutral-900">{cepage.name.fr}</Text>
{/* Tags */}
<View className="mb-5 flex-row flex-wrap gap-2">
<Badge
label={cepage.color === 'rouge' ? '🍷 Rouge' : cepage.color === 'blanc' ? '🥂 Blanc' : '🌸 Rosé'}
color="neutral"
size="sm"
/>
{cepage.regions.slice(0, 2).map((r) => (
<Badge key={r} label={r} color="neutral" size="sm" />
))}
</View>
{/* Description */}
<View className="mb-4 gap-1">
<Text className="text-[17px] font-semibold text-neutral-900">
{t('result.characteristics')}
</Text>
<Text className="text-[13px] leading-[22px] text-neutral-600">
{cepage.characteristics.fr}
</Text>
</View>
{/* 2x2 info grid */}
<View className="flex-row flex-wrap gap-[10px]">
<InfoCard icon="leaf" iconColor={colors.primary[700]} label={t('result.origin')} value={cepage.origin.fr} />
<InfoCard icon="water" iconColor="#2196F3" label={t('scanner.confidence')} value={`${detection.confidence}%`} />
<InfoCard icon="sunny" iconColor="#FF9800" label={t('result.regions')} value={cepage.regions[0] ?? '—'} />
<InfoCard icon="wine" iconColor="#E91E63" label="Type" value={cepage.color === 'rouge' ? 'Rouge' : cepage.color === 'blanc' ? 'Blanc' : 'Rosé'} />
</View>
{showHealthy && (
<Animated.View style={cardStyle} className="gap-2 rounded-[20px] bg-white p-5 shadow-sm">
<Text className="text-[20px] font-bold text-neutral-900">
{t('result.healthyTitle')}
</Text>
<Text className="text-[13px] leading-[22px] text-neutral-600">
{t('result.healthyMessage')}
</Text>
</Animated.View>
)}
{showDiseaseCta && !showHealthy && (
<Animated.View style={cardStyle}>
<Text className="mb-1 text-[24px] font-bold text-neutral-900">
{statusLabel}
</Text>
<View className="mb-5 flex-row flex-wrap gap-2">
<Badge label={t('result.detectedDisease')} color="warning" size="sm" />
{detection.allProbabilities && (
<Badge
label={`${detection.confidence}% ${t('result.confidence')}`}
color="neutral"
size="sm"
/>
)}
</View>
{detection.allProbabilities && (
<View className="mb-4 gap-2 rounded-[14px] bg-white p-4 shadow-sm">
<Text className="text-[13px] font-semibold text-neutral-700">
{t('result.allProbabilities')}
</Text>
{detection.allProbabilities
.slice()
.sort((a, b) => b.probability - a.probability)
.map((p) => (
<ProbabilityRow
key={p.class}
label={t(CLASS_TO_LABEL_KEY[p.class])}
value={p.probability}
isTop={p.class === detection.diseaseClass}
/>
))}
</View>
)}
</Animated.View>
)}
{detection.result === 'uncertain' && (
<Animated.View style={cardStyle} className="gap-2 rounded-[20px] bg-white p-5 shadow-sm">
<Text className="text-[20px] font-bold text-neutral-900">
{t('result.uncertainTitle')}
</Text>
<Text className="text-[13px] leading-[22px] text-neutral-600">
{t('result.uncertainMessage')}
</Text>
</Animated.View>
)}
{/* Action buttons */}
<Animated.View className="mt-2 gap-2" style={cardStyle}>
{showDiseaseCta && !showHealthy && (
<Button
variant="default"
size="lg"
className="w-full rounded-[14px]"
onPress={handleViewDisease}
>
<Ionicons name="information-circle" size={18} color={colors.surface} />
<Text className="text-white">{t('result.viewDiseaseDetail')}</Text>
</Button>
)}
<Button
variant="default"
variant={showDiseaseCta && !showHealthy ? 'ghost' : 'default'}
size="lg"
className="w-full rounded-[14px]"
onPress={() => navigation.goBack()}
>
<Ionicons name="scan" size={18} color={colors.surface} />
<Text className="text-white">{t('result.scanAgain')}</Text>
</Button>
<Button
variant="ghost"
size="lg"
className="w-full rounded-[14px]"
onPress={() => navigation.goBack()}
>
<Text style={{ color: colors.primary[700] }}>{t('result.viewHistory')}</Text>
<Ionicons
name="scan"
size={18}
color={showDiseaseCta && !showHealthy ? colors.primary[700] : colors.surface}
/>
<Text style={{ color: showDiseaseCta && !showHealthy ? colors.primary[700] : '#fff' }}>
{t('result.scanAgain')}
</Text>
</Button>
</Animated.View>
</ScrollView>
</SafeAreaView>
);
}
function ProbabilityRow({ label, value, isTop }: { label: string; value: number; isTop: boolean }) {
const percent = Math.round(value * 100);
return (
<View className="gap-1">
<View className="flex-row items-center justify-between">
<Text
className={`text-[12px] ${isTop ? 'font-semibold text-neutral-900' : 'text-neutral-600'}`}
>
{label}
</Text>
<Text
className={`text-[12px] ${isTop ? 'font-semibold text-neutral-900' : 'text-neutral-500'}`}
>
{percent}%
</Text>
</View>
<View className="h-1.5 overflow-hidden rounded-full bg-neutral-200">
<View
className="h-full rounded-full"
style={{
width: `${percent}%`,
backgroundColor: isTop ? colors.primary[700] : colors.neutral[400],
}}
/>
</View>
</View>
);
}

View file

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect } from "react";
import {
View,
ScrollView,
@ -8,13 +8,13 @@ import {
StyleSheet,
Platform,
ActivityIndicator,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { useTranslation } from "react-i18next";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import {
ChevronLeft,
Star,
@ -27,46 +27,66 @@ import {
Share2,
Trash2,
AlertCircle,
} from 'lucide-react-native';
} from "lucide-react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
} from 'react-native-reanimated';
import { toast } from 'sonner-native';
} from "react-native-reanimated";
import { toast } from "sonner-native";
import { Text } from '@/components/ui/text';
import { useScanDetail } from '@/hooks/useScanDetail';
import { getCepageById } from '@/utils/cepages';
import { hapticSuccess } from '@/services/haptics';
import { colors } from '@/theme/colors';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/types/navigation';
import type { DetectionResult } from '@/types/detection';
import { Text } from "@/components/ui/text";
import { useScanDetail } from "@/hooks/useScanDetail";
import { getCepageById } from "@/utils/cepages";
import { hapticSuccess } from "@/services/haptics";
import { colors } from "@/theme/colors";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import type { RootStackParamList } from "@/types/navigation";
import type { DetectionResult } from "@/types/detection";
type Props = NativeStackScreenProps<RootStackParamList, 'ScanDetail'>;
type Props = NativeStackScreenProps<RootStackParamList, "ScanDetail">;
const RESULT_STYLES: Record<DetectionResult, { bg: string; text: string; Icon: typeof CheckCircle2; labelKey: string }> = {
vine: { bg: '#E8F5E9', text: '#2D6A4F', Icon: CheckCircle2, labelKey: 'myPlants.detail.results.vine' },
uncertain: { bg: '#FFF4E5', text: '#E67E22', Icon: HelpCircle, labelKey: 'myPlants.detail.results.uncertain' },
not_vine: { bg: '#FFEBEE', text: '#C62828', Icon: XCircle, labelKey: 'myPlants.detail.results.notVine' },
const RESULT_STYLES: Record<
DetectionResult,
{ bg: string; text: string; Icon: typeof CheckCircle2; labelKey: string }
> = {
vine: {
bg: "#E8F5E9",
text: "#2D6A4F",
Icon: CheckCircle2,
labelKey: "myPlants.detail.results.vine",
},
uncertain: {
bg: "#FFF4E5",
text: "#E67E22",
Icon: HelpCircle,
labelKey: "myPlants.detail.results.uncertain",
},
not_vine: {
bg: "#FFEBEE",
text: "#C62828",
Icon: XCircle,
labelKey: "myPlants.detail.results.notVine",
},
};
const FALLBACK_IMAGE = require('../../assets/logo.png');
const FALLBACK_IMAGE = require("../../assets/logo.png");
function formatDateLong(iso: string, locale: string): string {
const d = new Date(iso);
const dateStr = d.toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
const dateStr = d.toLocaleDateString(locale === "fr" ? "fr-FR" : "en-US", {
day: "numeric",
month: "long",
year: "numeric",
});
const timeStr = d.toLocaleTimeString(locale === 'fr' ? 'fr-FR' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
const timeStr = d.toLocaleTimeString(locale === "fr" ? "fr-FR" : "en-US", {
hour: "2-digit",
minute: "2-digit",
});
return locale === 'fr' ? `${dateStr} à ${timeStr}` : `${dateStr} at ${timeStr}`;
return locale === "fr"
? `${dateStr} à ${timeStr}`
: `${dateStr} at ${timeStr}`;
}
export default function ScanDetailScreen({ route }: Props) {
@ -74,7 +94,8 @@ export default function ScanDetailScreen({ route }: Props) {
const { t, i18n } = useTranslation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { scan, loading, error, toggleFavorite, deleteScan } = useScanDetail(scanId);
const { scan, loading, error, toggleFavorite, deleteScan } =
useScanDetail(scanId);
// Entry animation
const contentY = useSharedValue(30);
@ -82,7 +103,10 @@ export default function ScanDetailScreen({ route }: Props) {
useEffect(() => {
if (scan) {
const timing = { duration: 400, easing: Easing.bezier(0.25, 0.1, 0.25, 1) };
const timing = {
duration: 400,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
};
contentY.value = withTiming(0, timing);
contentOpacity.value = withTiming(1, timing);
}
@ -119,9 +143,12 @@ export default function ScanDetailScreen({ route }: Props) {
return (
<View style={styles.centered}>
<AlertCircle size={48} color={colors.neutral[400]} />
<Text style={styles.errorText}>{t('myPlants.detail.notFound')}</Text>
<TouchableOpacity style={styles.errorBtn} onPress={() => navigation.goBack()}>
<Text style={styles.errorBtnText}>{t('myPlants.detail.goBack')}</Text>
<Text style={styles.errorText}>{t("myPlants.detail.notFound")}</Text>
<TouchableOpacity
style={styles.errorBtn}
onPress={() => navigation.goBack()}
>
<Text style={styles.errorBtnText}>{t("myPlants.detail.goBack")}</Text>
</TouchableOpacity>
</View>
);
@ -135,29 +162,31 @@ export default function ScanDetailScreen({ route }: Props) {
const heroTitle = cepage
? cepage.name.fr
: detection.result === 'vine'
? t('myPlants.detail.results.vine')
: t('myPlants.detail.results.unidentified');
: detection.result === "vine"
? t("myPlants.detail.results.vine")
: t("myPlants.detail.results.unidentified");
async function handleToggleFavorite() {
await toggleFavorite();
hapticSuccess();
toast.success(isFav ? t('myPlants.toasts.unfavorited') : t('myPlants.toasts.favorited'));
toast.success(
isFav ? t("myPlants.toasts.unfavorited") : t("myPlants.toasts.favorited"),
);
}
function handleDelete() {
Alert.alert(
t('myPlants.actions.deleteConfirmTitle'),
t('myPlants.actions.deleteConfirmMessage'),
t("myPlants.actions.deleteConfirmTitle"),
t("myPlants.actions.deleteConfirmMessage"),
[
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
{ text: t("myPlants.actions.cancel"), style: "cancel" },
{
text: t('myPlants.actions.delete'),
style: 'destructive',
text: t("myPlants.actions.delete"),
style: "destructive",
onPress: async () => {
await deleteScan();
hapticSuccess();
toast.success(t('myPlants.toasts.deleted'));
toast.success(t("myPlants.toasts.deleted"));
navigation.goBack();
},
},
@ -167,24 +196,25 @@ export default function ScanDetailScreen({ route }: Props) {
function handleShare() {
Alert.alert(
t('myPlants.detail.shareConfirmTitle'),
t('myPlants.detail.shareConfirmMessage'),
t("myPlants.detail.shareConfirmTitle"),
t("myPlants.detail.shareConfirmMessage"),
[
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
{ text: t("myPlants.actions.cancel"), style: "cancel" },
{
text: t('myPlants.detail.shareAction'),
text: t("myPlants.detail.shareAction"),
onPress: async () => {
if (!scan) return;
const name = cepage?.name.fr ?? t('myPlants.detail.results.unidentified');
const name =
cepage?.name.fr ?? t("myPlants.detail.results.unidentified");
const date = formatDateLong(scan.createdAt, i18n.language);
const text = `${t('myPlants.detail.shareText')}\n\n${name}\n${t('myPlants.detail.confidence')} : ${detection.confidence}%\n${date}`;
const text = `${t("myPlants.detail.shareText")}\n\n${name}\n${t("myPlants.detail.confidence")} : ${detection.confidence}%\n${date}`;
try {
await Share.share({
message: text,
...(detection.imageUri ? { url: detection.imageUri } : {}),
});
} catch {
toast.error(t('myPlants.detail.shareError'));
toast.error(t("myPlants.detail.shareError"));
}
},
},
@ -202,15 +232,34 @@ export default function ScanDetailScreen({ route }: Props) {
>
{/* ── Hero ── */}
<View style={styles.heroContainer}>
<Image
source={hasImage ? { uri: detection.imageUri } : FALLBACK_IMAGE}
style={StyleSheet.absoluteFillObject}
contentFit={hasImage ? 'cover' : 'contain'}
transition={300}
{hasImage ? (
<Image
source={{ uri: detection.imageUri }}
style={StyleSheet.absoluteFillObject}
contentFit="cover"
transition={300}
/>
) : (
<View style={styles.heroFallback}>
<Image
source={FALLBACK_IMAGE}
style={styles.heroFallbackLogo}
contentFit="contain"
transition={300}
/>
</View>
)}
<LinearGradient
colors={["rgba(255,255,255,0.55)", "transparent"]}
style={styles.gradientTop}
/>
<LinearGradient colors={['rgba(0,0,0,0.35)', 'transparent']} style={styles.gradientTop} />
<LinearGradient colors={['transparent', '#F8F9FB']} style={styles.gradientBottom} />
<Text style={styles.heroTitle}>{heroTitle}</Text>
<LinearGradient
colors={["transparent", "#F8F9FB"]}
style={styles.gradientBottom}
/>
<Text style={[styles.heroTitle, !hasImage && styles.heroTitleDark]}>
{heroTitle}
</Text>
</View>
{/* ── Floating buttons ── */}
@ -226,45 +275,75 @@ export default function ScanDetailScreen({ route }: Props) {
activeOpacity={0.8}
onPress={handleToggleFavorite}
>
<Star size={20} color={isFav ? '#FFB800' : '#1A1A1A'} fill={isFav ? '#FFB800' : 'none'} />
<Star
size={20}
color={isFav ? "#FFB800" : "#1A1A1A"}
fill={isFav ? "#FFB800" : "none"}
/>
</TouchableOpacity>
{/* ── Content ── */}
<Animated.View style={contentAnim}>
{/* Result Card */}
<View style={styles.resultCard}>
<View style={[styles.badgePill, { backgroundColor: resultStyle.bg }]}>
<View
style={[styles.badgePill, { backgroundColor: resultStyle.bg }]}
>
<ResultIcon size={16} color={resultStyle.text} />
<Text style={[styles.badgeText, { color: resultStyle.text }]}>
{t(resultStyle.labelKey)}
</Text>
</View>
<View style={styles.confidenceRow}>
<Text style={styles.confidenceLabel}>{t('myPlants.detail.confidence')}</Text>
<Text style={[styles.confidenceValue, { color: resultStyle.text }]}>
<Text style={styles.confidenceLabel}>
{t("myPlants.detail.confidence")}
</Text>
<Text
style={[styles.confidenceValue, { color: resultStyle.text }]}
>
{detection.confidence}%
</Text>
</View>
<View style={styles.barTrack}>
<Animated.View style={[styles.barFill, { backgroundColor: resultStyle.text }, barAnim]} />
<Animated.View
style={[
styles.barFill,
{ backgroundColor: resultStyle.text },
barAnim,
]}
/>
</View>
</View>
{/* Cepage Card */}
{detection.result === 'vine' && cepage && (
{detection.result === "vine" && cepage && (
<View style={styles.card}>
<Text style={styles.cardTitle}>{t('myPlants.detail.cepageSection')}</Text>
<Text style={styles.cardTitle}>
{t("myPlants.detail.cepageSection")}
</Text>
<Text style={styles.cepageName}>{cepage.name.fr}</Text>
<Text style={styles.cepageNameEn}>{cepage.name.en}</Text>
<View style={styles.tagsRow}>
<View style={[styles.tag, { backgroundColor: 'rgba(45,106,79,0.1)' }]}>
<Text style={[styles.tagText, { color: '#2D6A4F' }]}>
{cepage.color === 'rouge' ? '🍷 Rouge' : cepage.color === 'blanc' ? '🥂 Blanc' : '🌸 Rosé'}
<View
style={[
styles.tag,
{ backgroundColor: "rgba(45,106,79,0.1)" },
]}
>
<Text style={[styles.tagText, { color: "#2D6A4F" }]}>
{cepage.color === "rouge"
? "🍷 Rouge"
: cepage.color === "blanc"
? "🥂 Blanc"
: "🌸 Rosé"}
</Text>
</View>
{cepage.regions.slice(0, 2).map((r) => (
<View key={r} style={[styles.tag, { backgroundColor: '#F0F0F0' }]}>
<Text style={[styles.tagText, { color: '#444' }]}>{r}</Text>
<View
key={r}
style={[styles.tag, { backgroundColor: "#F0F0F0" }]}
>
<Text style={[styles.tagText, { color: "#444" }]}>{r}</Text>
</View>
))}
</View>
@ -277,15 +356,21 @@ export default function ScanDetailScreen({ route }: Props) {
<View style={styles.metaRow}>
<Calendar size={18} color={colors.primary[700]} />
<View style={styles.metaContent}>
<Text style={styles.metaLabel}>{t('myPlants.detail.scannedOn')}</Text>
<Text style={styles.metaValue}>{formatDateLong(scan.createdAt, i18n.language)}</Text>
<Text style={styles.metaLabel}>
{t("myPlants.detail.scannedOn")}
</Text>
<Text style={styles.metaValue}>
{formatDateLong(scan.createdAt, i18n.language)}
</Text>
</View>
</View>
<View style={styles.metaDivider} />
<View style={styles.metaRow}>
<Award size={18} color={colors.primary[700]} />
<View style={styles.metaContent}>
<Text style={styles.metaLabel}>{t('myPlants.detail.xpEarned')}</Text>
<Text style={styles.metaLabel}>
{t("myPlants.detail.xpEarned")}
</Text>
<Text style={styles.metaValue}>+{scan.xpEarned} XP</Text>
</View>
</View>
@ -295,27 +380,38 @@ export default function ScanDetailScreen({ route }: Props) {
<View style={styles.card}>
<View style={styles.metaRow}>
<MapPin size={18} color={colors.primary[700]} />
<Text style={styles.cardTitle}>{t('myPlants.detail.location')}</Text>
<Text style={styles.cardTitle}>
{t("myPlants.detail.location")}
</Text>
</View>
{scan.location ? (
<View style={{ marginTop: 8 }}>
<Text style={styles.locationName}>
{scan.location.placeName ?? 'Lieu inconnu'}
{scan.location.placeName ?? "Lieu inconnu"}
</Text>
<Text style={styles.locationCoords}>
{scan.location.latitude.toFixed(6)}, {scan.location.longitude.toFixed(6)}
{scan.location.latitude.toFixed(6)},{" "}
{scan.location.longitude.toFixed(6)}
</Text>
</View>
) : (
<View style={{ marginTop: 8 }}>
<Text style={styles.noLocation}>{t('myPlants.detail.noLocation')}</Text>
<Text style={styles.noLocation}>
{t("myPlants.detail.noLocation")}
</Text>
<TouchableOpacity
style={styles.addLocationBtn}
onPress={() => console.warn('[ScanDetail] add location — to be implemented in prompt 3')}
onPress={() =>
console.warn(
"[ScanDetail] add location — to be implemented in prompt 3",
)
}
activeOpacity={0.7}
>
<MapPin size={14} color={colors.primary[700]} />
<Text style={styles.addLocationText}>{t('myPlants.detail.addLocation')}</Text>
<Text style={styles.addLocationText}>
{t("myPlants.detail.addLocation")}
</Text>
</TouchableOpacity>
</View>
)}
@ -325,13 +421,23 @@ export default function ScanDetailScreen({ route }: Props) {
{/* ── Bottom Action Bar ── */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
<TouchableOpacity style={styles.shareBtn} onPress={handleShare} activeOpacity={0.7}>
<TouchableOpacity
style={styles.shareBtn}
onPress={handleShare}
activeOpacity={0.7}
>
<Share2 size={18} color="#1A1A1A" />
<Text style={styles.shareBtnText}>{t('myPlants.detail.share')}</Text>
<Text style={styles.shareBtnText}>{t("myPlants.detail.share")}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.deleteBottomBtn} onPress={handleDelete} activeOpacity={0.7}>
<TouchableOpacity
style={styles.deleteBottomBtn}
onPress={handleDelete}
activeOpacity={0.7}
>
<Trash2 size={18} color="#C62828" />
<Text style={styles.deleteBtnText}>{t('myPlants.detail.delete')}</Text>
<Text style={styles.deleteBtnText}>
{t("myPlants.detail.delete")}
</Text>
</TouchableOpacity>
</View>
</View>
@ -339,73 +445,209 @@ export default function ScanDetailScreen({ route }: Props) {
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#F8F9FB' },
centered: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#F8F9FB', gap: 16 },
errorText: { fontSize: 16, fontWeight: '600', color: '#1A1A1A' },
errorBtn: { paddingHorizontal: 24, paddingVertical: 12, backgroundColor: colors.primary[700], borderRadius: 100 },
errorBtnText: { fontSize: 15, fontWeight: '600', color: '#FFFFFF' },
root: { flex: 1, backgroundColor: "#F8F9FB" },
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#F8F9FB",
gap: 16,
},
errorText: { fontSize: 16, fontWeight: "600", color: "#1A1A1A" },
errorBtn: {
paddingHorizontal: 24,
paddingVertical: 12,
backgroundColor: colors.primary[700],
borderRadius: 100,
},
errorBtnText: { fontSize: 15, fontWeight: "600", color: "#FFFFFF" },
// Hero
heroContainer: { height: 380, position: 'relative', backgroundColor: '#E0E0E0', borderBottomLeftRadius: 32, borderBottomRightRadius: 32, overflow: 'hidden' },
gradientTop: { position: 'absolute', top: 0, left: 0, right: 0, height: 100 },
gradientBottom: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 100 },
heroTitle: { position: 'absolute', bottom: 24, left: 20, right: 20, fontSize: 28, fontWeight: '700', color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 4 },
heroContainer: {
height: 380,
position: "relative",
backgroundColor: "#ffffffff",
borderBottomLeftRadius: 32,
borderBottomRightRadius: 32,
overflow: "hidden",
},
heroFallback: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.primary[100],
alignItems: "center",
justifyContent: "center",
},
heroFallbackLogo: { width: 120, height: 120 },
gradientTop: { position: "absolute", top: 0, left: 0, right: 0, height: 100 },
gradientBottom: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 100,
},
heroTitle: {
position: "absolute",
bottom: 24,
left: 20,
right: 20,
fontSize: 28,
fontWeight: "700",
color: "#FFFFFF",
textShadowColor: "rgba(0,0,0,0.5)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 4,
},
heroTitleDark: { color: colors.primary[900], textShadowColor: "transparent" },
// Floating buttons
floatingBtn: {
position: 'absolute',
position: "absolute",
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.9)',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: "rgba(255,255,255,0.9)",
alignItems: "center",
justifyContent: "center",
...Platform.select({
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: { elevation: 3 },
}),
},
// Result card
resultCard: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 20, marginHorizontal: 16, marginTop: -24, borderWidth: 1, borderColor: '#F0F0F0', gap: 12 },
badgePill: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: 6, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20 },
badgeText: { fontSize: 13, fontWeight: '600' },
confidenceRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
confidenceLabel: { fontSize: 14, color: '#8E8E93' },
confidenceValue: { fontSize: 20, fontWeight: '700' },
barTrack: { height: 8, borderRadius: 4, backgroundColor: '#F0F0F0', overflow: 'hidden' },
resultCard: {
backgroundColor: "#FFFFFF",
borderRadius: 24,
padding: 20,
marginHorizontal: 16,
marginTop: -24,
borderWidth: 1,
borderColor: "#F0F0F0",
gap: 12,
},
badgePill: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
badgeText: { fontSize: 13, fontWeight: "600" },
confidenceRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
confidenceLabel: { fontSize: 14, color: "#8E8E93" },
confidenceValue: { fontSize: 20, fontWeight: "700" },
barTrack: {
height: 8,
borderRadius: 4,
backgroundColor: "#F0F0F0",
overflow: "hidden",
},
barFill: { height: 8, borderRadius: 4 },
// Generic card
card: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 20, marginHorizontal: 16, marginTop: 12, borderWidth: 1, borderColor: '#F0F0F0' },
cardTitle: { fontSize: 16, fontWeight: '600', color: '#1A1A1A' },
card: {
backgroundColor: "#FFFFFF",
borderRadius: 24,
padding: 20,
marginHorizontal: 16,
marginTop: 12,
borderWidth: 1,
borderColor: "#F0F0F0",
},
cardTitle: { fontSize: 16, fontWeight: "600", color: "#1A1A1A" },
// Cepage
cepageName: { fontSize: 22, fontWeight: '700', color: '#1A1A1A', marginTop: 8 },
cepageNameEn: { fontSize: 14, color: '#8E8E93', marginTop: 2 },
tagsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 12 },
cepageName: {
fontSize: 22,
fontWeight: "700",
color: "#1A1A1A",
marginTop: 8,
},
cepageNameEn: { fontSize: 14, color: "#8E8E93", marginTop: 2 },
tagsRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 },
tag: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16 },
tagText: { fontSize: 13, fontWeight: '600' },
cepageDesc: { fontSize: 14, lineHeight: 22, color: '#444444', marginTop: 12 },
tagText: { fontSize: 13, fontWeight: "600" },
cepageDesc: { fontSize: 14, lineHeight: 22, color: "#444444", marginTop: 12 },
// Meta
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
metaRow: { flexDirection: "row", alignItems: "center", gap: 12 },
metaContent: { flex: 1, gap: 2 },
metaLabel: { fontSize: 12, color: '#8E8E93' },
metaValue: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
metaDivider: { height: 1, backgroundColor: '#F0F0F0', marginVertical: 14 },
metaLabel: { fontSize: 12, color: "#8E8E93" },
metaValue: { fontSize: 15, fontWeight: "600", color: "#1A1A1A" },
metaDivider: { height: 1, backgroundColor: "#F0F0F0", marginVertical: 14 },
// Location
locationName: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
locationCoords: { fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', color: '#8E8E93', marginTop: 4 },
noLocation: { fontSize: 14, color: '#8E8E93' },
addLocationBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 12, paddingVertical: 10, paddingHorizontal: 16, backgroundColor: 'rgba(45,106,79,0.08)', borderRadius: 12, alignSelf: 'flex-start' },
addLocationText: { fontSize: 14, fontWeight: '600', color: colors.primary[700] },
locationName: { fontSize: 15, fontWeight: "600", color: "#1A1A1A" },
locationCoords: {
fontSize: 12,
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
color: "#8E8E93",
marginTop: 4,
},
noLocation: { fontSize: 14, color: "#8E8E93" },
addLocationBtn: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginTop: 12,
paddingVertical: 10,
paddingHorizontal: 16,
backgroundColor: "rgba(45,106,79,0.08)",
borderRadius: 12,
alignSelf: "flex-start",
},
addLocationText: {
fontSize: 14,
fontWeight: "600",
color: colors.primary[700],
},
// Bottom bar
bottomBar: { position: 'absolute', bottom: 0, left: 0, right: 0, flexDirection: 'row', gap: 12, paddingTop: 12, paddingHorizontal: 16, backgroundColor: '#FFFFFF', borderTopWidth: 1, borderTopColor: '#F0F0F0' },
shareBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, backgroundColor: '#F0F0F0', borderRadius: 16, paddingVertical: 14 },
shareBtnText: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
deleteBottomBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, backgroundColor: '#FFEBEE', borderRadius: 16, paddingVertical: 14 },
deleteBtnText: { fontSize: 15, fontWeight: '600', color: '#C62828' },
bottomBar: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
flexDirection: "row",
gap: 12,
paddingTop: 12,
paddingHorizontal: 16,
backgroundColor: "#FFFFFF",
borderTopWidth: 1,
borderTopColor: "#F0F0F0",
},
shareBtn: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
backgroundColor: "#F0F0F0",
borderRadius: 16,
paddingVertical: 14,
},
shareBtnText: { fontSize: 15, fontWeight: "600", color: "#1A1A1A" },
deleteBottomBtn: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
backgroundColor: "#FFEBEE",
borderRadius: 16,
paddingVertical: 14,
},
deleteBtnText: { fontSize: 15, fontWeight: "600", color: "#C62828" },
});

View file

@ -1,23 +1,30 @@
import { useEffect, useState } from "react";
import {
View,
ScrollView,
StyleSheet,
Platform,
Alert,
TouchableOpacity,
Switch,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import i18n from "@/i18n";
import { ChevronRight } from "lucide-react-native";
import { toast } from "sonner-native";
import i18n from "@/i18n";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import { useGameProgress } from "@/hooks/useGameProgress";
import { useHistory } from "@/hooks/useHistory";
import { storage } from "@/services/storage";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
interface MenuItem {
icon: string;
@ -26,14 +33,34 @@ interface MenuItem {
rightColor?: string;
danger?: boolean;
onPress?: () => void;
toggleValue?: boolean;
onToggle?: (value: boolean) => void;
}
export default function SettingsScreen() {
const { t } = useTranslation();
const navigation = useNavigation();
const { resetProgress } = useGameProgress();
const navigation = useNavigation<Nav>();
const { progress, resetProgress } = useGameProgress();
const { clearHistory, seedTestData } = useHistory();
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
useEffect(() => {
storage
.get<boolean>(storage.KEYS.NOTIFICATIONS_ENABLED)
.then((v) => setNotificationsEnabled(v ?? true));
}, []);
async function handleNotificationsToggle(value: boolean) {
setNotificationsEnabled(value);
await storage.set(storage.KEYS.NOTIFICATIONS_ENABLED, value);
toast.success(
value
? t("settings.notifications.enabled")
: t("settings.notifications.disabled"),
);
}
async function handleSeed() {
await seedTestData();
toast.success(t("settings.seedDone"));
@ -59,10 +86,6 @@ export default function SettingsScreen() {
}
const generalItems: MenuItem[] = [
{
icon: "person-outline",
label: t("settings.editProfile"),
},
{
icon: "globe-outline",
label: t("profile.language"),
@ -71,7 +94,9 @@ export default function SettingsScreen() {
},
{
icon: "notifications-outline",
label: t("common.notifications"),
label: t("settings.notifications.label"),
toggleValue: notificationsEnabled,
onToggle: handleNotificationsToggle,
},
{
icon: "shield-outline",
@ -79,7 +104,9 @@ export default function SettingsScreen() {
},
];
// Premium status + Apparence désactivés pour le moment — décommenter quand prêts
const appItems: MenuItem[] = [
/*
{
icon: "diamond-outline",
label: t("settings.premiumStatus"),
@ -90,6 +117,7 @@ export default function SettingsScreen() {
icon: "color-palette-outline",
label: t("settings.appearance"),
},
*/
{
icon: "help-circle-outline",
label: t("settings.helpCenter"),
@ -121,88 +149,136 @@ export default function SettingsScreen() {
const renderMenuGroup = (items: MenuItem[]) => (
<View style={styles.menuCard}>
{items.map((item, index) => (
<View key={item.label}>
{index > 0 && <View style={styles.divider} />}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.5}
onPress={item.onPress}
>
<View
style={[
styles.iconBox,
{ backgroundColor: item.danger ? "#FEF2F2" : "#F8F9FA" },
]}
>
<Ionicons
name={item.icon as any}
size={20}
color={item.danger ? "#EF4444" : "#636E72"}
/>
</View>
{items.map((item, index) => {
const isToggle = typeof item.onToggle === "function";
const Wrapper = isToggle ? View : TouchableOpacity;
<Text
style={[styles.menuLabel, item.danger && styles.menuLabelDanger]}
return (
<View key={item.label}>
{index > 0 && <View style={styles.divider} />}
<Wrapper
style={styles.menuRow}
{...(isToggle
? {}
: { activeOpacity: 0.5, onPress: item.onPress })}
>
{item.label}
</Text>
<View
style={[
styles.iconBox,
{ backgroundColor: item.danger ? "#FEF2F2" : "#F8F9FA" },
]}
>
<Ionicons
name={item.icon as keyof typeof Ionicons.glyphMap}
size={20}
color={item.danger ? "#EF4444" : "#636E72"}
/>
</View>
<View style={styles.menuRight}>
{item.rightText && (
<Text
style={[
styles.menuRightText,
item.rightColor && { color: item.rightColor },
]}
>
{item.rightText}
</Text>
)}
<Ionicons name="chevron-forward" size={14} color="#D1D1D6" />
</View>
</TouchableOpacity>
</View>
))}
<Text
style={[
styles.menuLabel,
item.danger && styles.menuLabelDanger,
]}
>
{item.label}
</Text>
<View style={styles.menuRight}>
{isToggle ? (
<Switch
value={item.toggleValue ?? false}
onValueChange={item.onToggle}
trackColor={{
false: colors.neutral[300],
true: colors.primary[700],
}}
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
ios_backgroundColor={colors.neutral[300]}
/>
) : (
<>
{item.rightText && (
<Text
style={[
styles.menuRightText,
item.rightColor && { color: item.rightColor },
]}
>
{item.rightText}
</Text>
)}
<ChevronRight size={16} color="#D1D1D6" strokeWidth={2} />
</>
)}
</View>
</Wrapper>
</View>
);
})}
</View>
);
const userLevel = progress?.level ?? 1;
const userXp = progress?.xp ?? 0;
return (
<SafeAreaView style={styles.safe} edges={["top"]}>
{/* Header épuré style Bumble/Apple */}
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backBtn}
activeOpacity={0.7}
>
<Ionicons name="chevron-back" size={24} color="#1A1A1A" />
<Ionicons name="chevron-back" size={22} color="#1A1A1A" />
</TouchableOpacity>
<Text style={styles.headerTitle}>{t("common.settings")}</Text>
<View style={{ width: 44 }} />
<View style={{ width: 40 }} />
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* Hero Profile button → ProfileScreen */}
<TouchableOpacity
style={styles.profileHero}
activeOpacity={0.85}
onPress={() => navigation.navigate("Profile")}
>
<View style={styles.avatarWrap}>
<Ionicons name="person" size={28} color="#FFFFFF" />
</View>
<View style={styles.profileText}>
<Text style={styles.profileName}>{t("settings.editProfile")}</Text>
<Text style={styles.profileMeta}>
{t("profile.level", { level: userLevel })} · {userXp} XP
</Text>
</View>
<View style={styles.profileChevronWrap}>
<ChevronRight size={20} color={colors.primary[800]} strokeWidth={2.4} />
</View>
</TouchableOpacity>
<Text style={styles.sectionLabel}>{t("settings.general")}</Text>
{renderMenuGroup(generalItems)}
<Text style={styles.sectionLabel}>{t("settings.app")}</Text>
{renderMenuGroup(appItems)}
{/* Banner Referral plus "Flat" et moderne */}
{/* Banner Referral désactivé — gardé commenté pour usage futur */}
{/*
<TouchableOpacity style={styles.referCard} activeOpacity={0.9}>
<View style={styles.referContent}>
<Text style={styles.referTitle}>Refer a friend</Text>
<Text style={styles.referBody}>
Get $50 per successful referral
</Text>
<Text style={styles.referBody}>Get $50 per successful referral</Text>
</View>
<View style={styles.referIconWrap}>
<Ionicons name="gift" size={28} color="#FFFFFF" />
</View>
</TouchableOpacity>
*/}
{devItems.length > 0 && (
<>
@ -223,48 +299,88 @@ export default function SettingsScreen() {
const styles = StyleSheet.create({
safe: {
flex: 1,
backgroundColor: "#F8F9FB", // Gris encore plus clair/bleuté
backgroundColor: "#FAFAFA",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "transparent", // Pas de démarcation brutale
paddingVertical: 8,
backgroundColor: "transparent",
},
backBtn: {
width: 44,
height: 44,
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
borderRadius: 14,
borderRadius: 12,
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#F0F0F0",
},
headerTitle: {
fontSize: 18,
fontWeight: "600", // Pas de Bold 900 ici, juste Medium/SemiBold
fontSize: 17,
fontWeight: "600",
color: "#1A1A1A",
letterSpacing: -0.4,
},
scrollContent: {
paddingHorizontal: 20,
paddingTop: 10,
paddingTop: 12,
},
profileHero: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF",
borderRadius: 24,
padding: 16,
marginBottom: 24,
borderWidth: 1,
borderColor: "#F0F0F0",
gap: 14,
shadowColor: "#000",
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.05,
shadowRadius: 16,
elevation: 4,
},
avatarWrap: {
width: 56,
height: 56,
borderRadius: 999,
backgroundColor: colors.primary[800],
alignItems: "center",
justifyContent: "center",
},
profileText: { flex: 1, gap: 2 },
profileName: {
fontSize: 16,
fontWeight: "700",
color: "#1A1A1A",
letterSpacing: -0.2,
},
profileMeta: { fontSize: 13, color: "#8E8E93" },
profileChevronWrap: {
width: 36,
height: 36,
borderRadius: 999,
backgroundColor: colors.primary[100],
alignItems: "center",
justifyContent: "center",
},
sectionLabel: {
fontSize: 12,
fontWeight: "500",
fontSize: 11,
fontWeight: "600",
color: "#A0A0A0",
marginBottom: 12,
marginBottom: 10,
marginLeft: 4,
textTransform: "uppercase",
letterSpacing: 1,
letterSpacing: 1.2,
},
menuCard: {
backgroundColor: "#FFFFFF",
borderRadius: 24,
borderRadius: 20,
marginBottom: 20,
borderWidth: 1,
borderColor: "#F2F2F2",
@ -272,8 +388,8 @@ const styles = StyleSheet.create({
menuRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
paddingVertical: 13,
paddingHorizontal: 14,
},
iconBox: {
width: 36,
@ -286,26 +402,26 @@ const styles = StyleSheet.create({
menuLabel: {
flex: 1,
fontSize: 15,
fontWeight: "400", // On reste sur du Regular
fontWeight: "500",
color: "#2D3436",
},
menuLabelDanger: {
color: "#EF4444",
},
menuLabelDanger: { color: "#EF4444" },
menuRight: {
flexDirection: "row",
alignItems: "center",
gap: 8,
gap: 6,
},
menuRightText: {
fontSize: 14,
fontSize: 13,
color: "#B2B2B2",
fontWeight: "500",
},
divider: {
height: 1,
backgroundColor: "#F8F9FA",
marginLeft: 60, // Aligné avec le texte, pas l'icône
backgroundColor: "#F5F5F5",
marginLeft: 60,
},
/* Styles du Referral card — gardés au cas où on réactive */
referCard: {
backgroundColor: "#F97316",
borderRadius: 24,
@ -314,19 +430,9 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
},
referContent: {
flex: 1,
},
referTitle: {
fontSize: 17,
fontWeight: "700",
color: "#FFFFFF",
},
referBody: {
fontSize: 13,
color: "rgba(255,255,255,0.7)",
marginTop: 2,
},
referContent: { flex: 1 },
referTitle: { fontSize: 17, fontWeight: "700", color: "#FFFFFF" },
referBody: { fontSize: 13, color: "rgba(255,255,255,0.7)", marginTop: 2 },
referIconWrap: {
width: 50,
height: 50,
@ -338,7 +444,8 @@ const styles = StyleSheet.create({
versionText: {
textAlign: "center",
fontSize: 12,
color: "#D1D1D6",
color: "#C7C7CC",
marginTop: 10,
fontWeight: "500",
},
});

View file

@ -13,6 +13,7 @@ const DISEASE_SLUG_MAP: Record<string, string> = {
botrytis: "botrytis",
"flavescence-doree": "flavescence",
"chlorose-ferrique": "chlorose",
"leaf-blight": "leafBlight",
};
const GUIDE_SLUG_MAP: Record<string, string> = {

View file

@ -0,0 +1,20 @@
import type { DiseaseClass } from '@/types/detection';
export const ML_CLASSES: DiseaseClass[] = ['black_rot', 'esca', 'healthy', 'leaf_blight'];
export const CLASS_TO_SLUG: Record<DiseaseClass, string | null> = {
healthy: null,
black_rot: 'black-rot',
esca: 'esca',
leaf_blight: 'leaf-blight',
};
export const CLASS_TO_LABEL_KEY: Record<DiseaseClass, string> = {
healthy: 'detection.healthy',
black_rot: 'diseases.blackRot.name',
esca: 'diseases.esca.name',
leaf_blight: 'diseases.leafBlight.name',
};
export const CONFIDENCE_THRESHOLD_VINE = 0.7;
export const CONFIDENCE_THRESHOLD_UNCERTAIN = 0.4;

View file

@ -0,0 +1,85 @@
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
import * as jpeg from 'jpeg-js';
export const MODEL_INPUT_SIZE = 224;
export async function preprocessImage(uri: string): Promise<Float32Array> {
const resized = await manipulateAsync(
uri,
[{ resize: { width: MODEL_INPUT_SIZE, height: MODEL_INPUT_SIZE } }],
{ format: SaveFormat.JPEG, base64: true, compress: 0.92 },
);
if (!resized.base64) {
throw new Error('Image manipulation did not return base64');
}
const jpegBytes = base64ToBytes(resized.base64);
const decoded = jpeg.decode(jpegBytes, { useTArray: true });
if (decoded.width !== MODEL_INPUT_SIZE || decoded.height !== MODEL_INPUT_SIZE) {
throw new Error(
`Decoded image is ${decoded.width}x${decoded.height}, expected ${MODEL_INPUT_SIZE}x${MODEL_INPUT_SIZE}`,
);
}
const rgba = decoded.data;
const pixelCount = MODEL_INPUT_SIZE * MODEL_INPUT_SIZE;
const input = new Float32Array(pixelCount * 3);
for (let i = 0; i < pixelCount; i++) {
input[i * 3 + 0] = rgba[i * 4 + 0] / 255;
input[i * 3 + 1] = rgba[i * 4 + 1] / 255;
input[i * 3 + 2] = rgba[i * 4 + 2] / 255;
}
return input;
}
function base64ToBytes(base64: string): Uint8Array {
const cleaned = base64.replace(/[\r\n]/g, '');
const bin = globalThis.atob ? globalThis.atob(cleaned) : decodeBase64Fallback(cleaned);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
function decodeBase64Fallback(input: string): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let out = '';
let buffer = 0;
let bits = 0;
for (let i = 0; i < input.length; i++) {
const c = input[i];
if (c === '=') break;
const v = chars.indexOf(c);
if (v < 0) continue;
buffer = (buffer << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out += String.fromCharCode((buffer >> bits) & 0xff);
}
}
return out;
}
export function softmax(logits: Float32Array | number[]): number[] {
const arr = Array.from(logits);
const max = Math.max(...arr);
const exps = arr.map((v) => Math.exp(v - max));
const sum = exps.reduce((a, b) => a + b, 0);
return exps.map((v) => v / sum);
}
export function argmax(values: number[] | Float32Array): number {
let best = 0;
let bestVal = -Infinity;
for (let i = 0; i < values.length; i++) {
if (values[i] > bestVal) {
bestVal = values[i];
best = i;
}
}
return best;
}

View file

@ -5,6 +5,8 @@ const KEYS = {
SCAN_HISTORY: '@vineye:scan_history',
LANGUAGE: '@vineye:language',
LOCATION_PERMISSION_ASKED: '@vineye:location-permission-asked',
USER_PROFILE: '@vineye:user_profile',
NOTIFICATIONS_ENABLED: '@vineye:notifications_enabled',
} as const;
async function get<T>(key: string): Promise<T | null> {

View file

@ -1,52 +1,139 @@
// TODO: Remplacer par le vrai modèle TFLite (MobileNetV2 fine-tuné sur dataset vignes)
import type { Detection, DetectionResult } from '@/types/detection';
import { cepages } from '@/utils/cepages';
import type { Detection, DiseaseClass, ClassProbability } from '@/types/detection';
import { ML_CLASSES, CLASS_TO_SLUG, CONFIDENCE_THRESHOLD_VINE, CONFIDENCE_THRESHOLD_UNCERTAIN } from '@/services/ml/classes';
import { preprocessImage, argmax, softmax } from '@/services/ml/preprocessing';
const WEIGHTED_RESULTS: { result: DetectionResult; weight: number }[] = [
{ result: 'vine', weight: 70 },
{ result: 'uncertain', weight: 20 },
{ result: 'not_vine', weight: 10 },
];
type FastTfliteModel = {
runSync: (inputs: (Float32Array | Int32Array | Uint8Array)[]) => (Float32Array | Int32Array | Uint8Array)[];
};
function weightedRandom(): DetectionResult {
const total = WEIGHTED_RESULTS.reduce((sum, r) => sum + r.weight, 0);
let rand = Math.random() * total;
for (const r of WEIGHTED_RESULTS) {
rand -= r.weight;
if (rand <= 0) return r.result;
let cachedModel: FastTfliteModel | null = null;
let modelLoadFailed = false;
async function getModel(): Promise<FastTfliteModel | null> {
if (cachedModel) return cachedModel;
if (modelLoadFailed) return null;
try {
const tflite = require('react-native-fast-tflite');
const asset = require('@/assets/models/grapevine_v1.tflite');
cachedModel = await tflite.loadTensorflowModel(asset);
return cachedModel;
} catch (err) {
if (__DEV__) {
console.warn('[TFLite] Failed to load model — falling back to mock:', err);
}
modelLoadFailed = true;
return null;
}
return 'vine';
}
// TODO: Remplacer par le vrai modèle TFLite
export async function loadModel(): Promise<boolean> {
// Simule le chargement du modèle (1-2 secondes)
await new Promise((resolve) => setTimeout(resolve, 1200 + Math.random() * 800));
return true;
const m = await getModel();
return m !== null;
}
// TODO: Remplacer par le vrai modèle TFLite
export async function runInference(imageUri?: string): Promise<Detection> {
// Simule l'inférence (200-600ms)
await new Promise((resolve) => setTimeout(resolve, 200 + Math.random() * 400));
const timestamp = Date.now();
const result = weightedRandom();
const confidence = result === 'vine'
? Math.floor(70 + Math.random() * 30) // 70100%
: result === 'uncertain'
? Math.floor(40 + Math.random() * 30) // 4070%
: Math.floor(10 + Math.random() * 30); // 1040%
if (!imageUri) {
return mockDetection(timestamp);
}
const cepageId =
result === 'vine'
? cepages[Math.floor(Math.random() * cepages.length)].id
: undefined;
const model = await getModel();
if (!model) {
return mockDetection(timestamp, imageUri);
}
try {
const input = await preprocessImage(imageUri);
const outputs = model.runSync([input]);
const raw = outputs[0];
const rawArr = raw instanceof Float32Array ? Array.from(raw) : Array.from(raw as ArrayLike<number>);
const probs = isProbabilityVector(rawArr) ? rawArr : softmax(rawArr);
const idx = argmax(probs);
const topClass = ML_CLASSES[idx];
const topProb = probs[idx];
const allProbabilities: ClassProbability[] = ML_CLASSES.map((cls, i) => ({
class: cls,
probability: probs[i],
}));
return buildDetection({
timestamp,
imageUri,
topClass,
topProb,
allProbabilities,
});
} catch (err) {
if (__DEV__) {
console.warn('[TFLite] Inference failed — falling back to mock:', err);
}
return mockDetection(timestamp, imageUri);
}
}
function buildDetection(args: {
timestamp: number;
imageUri?: string;
topClass: DiseaseClass;
topProb: number;
allProbabilities: ClassProbability[];
}): Detection {
const { timestamp, imageUri, topClass, topProb, allProbabilities } = args;
const confidence = Math.round(topProb * 100);
const result =
topProb >= CONFIDENCE_THRESHOLD_VINE
? 'vine'
: topProb >= CONFIDENCE_THRESHOLD_UNCERTAIN
? 'uncertain'
: 'not_vine';
return {
result,
confidence,
cepageId,
timestamp: Date.now(),
diseaseClass: topClass,
diseaseSlug: CLASS_TO_SLUG[topClass] ?? undefined,
allProbabilities,
timestamp,
imageUri,
};
}
function isProbabilityVector(values: number[]): boolean {
if (values.length === 0) return false;
const sum = values.reduce((a, b) => a + b, 0);
if (Math.abs(sum - 1) > 0.05) return false;
return values.every((v) => v >= 0 && v <= 1);
}
function mockDetection(timestamp: number, imageUri?: string): Detection {
const probs = randomProbabilities();
const idx = argmax(probs);
const topClass = ML_CLASSES[idx];
const allProbabilities: ClassProbability[] = ML_CLASSES.map((cls, i) => ({
class: cls,
probability: probs[i],
}));
return buildDetection({
timestamp,
imageUri,
topClass,
topProb: probs[idx],
allProbabilities,
});
}
function randomProbabilities(): number[] {
const raw = ML_CLASSES.map(() => Math.random());
const sum = raw.reduce((a, b) => a + b, 0);
const normalized = raw.map((v) => v / sum);
const boostIdx = Math.floor(Math.random() * ML_CLASSES.length);
normalized[boostIdx] = Math.min(1, normalized[boostIdx] + 0.5);
const newSum = normalized.reduce((a, b) => a + b, 0);
return normalized.map((v) => v / newSum);
}

29
VinEye/src/types/user.ts Normal file
View file

@ -0,0 +1,29 @@
export type AvatarEmoji = '🧑‍🌾' | '👨‍🌾' | '👩‍🌾' | '🌱' | '🍇' | '🍷' | '🌿';
export interface UserProfile {
displayName: string;
email: string;
avatar: AvatarEmoji;
}
export const AVATAR_OPTIONS: AvatarEmoji[] = [
'🧑‍🌾',
'👨‍🌾',
'👩‍🌾',
'🌱',
'🍇',
'🍷',
'🌿',
];
export const DEFAULT_PROFILE: UserProfile = {
displayName: '',
email: '',
avatar: '🧑‍🌾',
};
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(value: string): boolean {
return EMAIL_REGEX.test(value.trim());
}

View file

@ -205,6 +205,33 @@ async function main() {
{ url: "https://images.unsplash.com/photo-1566903451935-7bc0ddd0e8e6?w=800&h=600&fit=crop", alt: "Flavescence vigne", order: 1 },
],
},
{
slug: "leaf-blight",
name: "Brulure des feuilles", nameEn: "Leaf Blight", scientificName: "Pseudocercospora vitis (Isariopsis Leaf Spot)",
type: "FUNGAL" as const, severity: "MEDIUM" as const,
description: "La brulure des feuilles (Isariopsis Leaf Spot) est causee par le champignon Pseudocercospora vitis. Elle provoque des taches angulaires brun-rougeatre delimitees par les nervures.",
descriptionEn: "Leaf blight (Isariopsis Leaf Spot) is caused by the fungus Pseudocercospora vitis. It produces angular reddish-brown spots delimited by leaf veins.",
symptoms: ["Taches angulaires brun-rougeatre delimitees par les nervures", "Halo jaune autour des taches", "Defoliation precoce en cas d'attaque severe"],
symptomsEn: ["Angular reddish-brown spots delimited by veins", "Yellow halo around spots", "Early defoliation in severe attacks"],
treatment: "Traitement fongicide preventif a base de cuivre ou mancozebe. Eliminer les feuilles tombees a l'automne.",
treatmentEn: "Preventive copper or mancozeb-based fungicide treatment. Remove fallen leaves in autumn.",
season: "Juillet a septembre", seasonEn: "July to September",
iconName: "leaf", iconColor: "#A0522D", bgColor: "#F5E6D8",
startMonth: 6, endMonth: 9, peakMonth: 8,
conditions: ["Humidite elevee prolongee", "Temperatures entre 20 et 28°C", "Vignes affaiblies ou stressees"],
conditionsEn: ["Prolonged high humidity", "Temperatures between 20 and 28°C", "Weakened or stressed vines"],
preventiveActions: ["Traitement cuivrique preventif", "Eliminer les feuilles infectees a l'automne", "Maintenir une bonne aeration du feuillage"],
preventiveActionsEn: ["Preventive copper treatment", "Remove infected leaves in autumn", "Maintain good foliage ventilation"],
curativeActions: ["Appliquer un fongicide a base de mancozebe", "Retirer les feuilles severement atteintes"],
curativeActionsEn: ["Apply a mancozeb-based fungicide", "Remove severely affected leaves"],
impactedParts: ["Feuilles", "Rameaux (rare)"],
impactedPartsEn: ["Leaves", "Shoots (rare)"],
spreadMethod: "Spores disseminees par la pluie et le vent",
spreadMethodEn: "Spores spread by rain and wind",
images: [
{ url: "https://images.unsplash.com/photo-1507434965515-61970f2bd7c6?w=800&h=600&fit=crop", alt: "Leaf blight", order: 0 },
],
},
{
slug: "chlorose-ferrique",
name: "Chlorose ferrique", nameEn: "Iron Chlorosis", scientificName: "",