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:
parent
06be3483d7
commit
a8b84472e6
|
|
@ -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
|
||||
|
|
|
|||
112
VinEye/.claude/notes/android-build/README.md
Normal file
112
VinEye/.claude/notes/android-build/README.md
Normal 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é) |
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
BIN
VinEye/assets/Capture d’écran 2026-04-30 095436.png
Normal file
BIN
VinEye/assets/Capture d’écran 2026-04-30 095436.png
Normal file
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 |
|
|
@ -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 });
|
||||
|
|
|
|||
BIN
VinEye/src/assets/image1.png
Normal file
BIN
VinEye/src/assets/image1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
VinEye/src/assets/image2.png
Normal file
BIN
VinEye/src/assets/image2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 504 KiB |
188
VinEye/src/components/guides/LargeDiseaseCard.tsx
Normal file
188
VinEye/src/components/guides/LargeDiseaseCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,17 +22,29 @@ 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.root} collapsable={false}>
|
||||
<View style={styles.searchRow}>
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={20} color={colors.primary[800]} strokeWidth={2} />
|
||||
<TextInput
|
||||
|
|
@ -41,13 +54,15 @@ export function FloatingSearch({ activeFilter, onFilterPress }: FloatingSearchPr
|
|||
placeholderTextColor={colors.neutral[500]}
|
||||
style={styles.input}
|
||||
/>
|
||||
<View style={styles.logoWrap}>
|
||||
{/* <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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
64
VinEye/src/components/my-plants/ConfidenceTile.tsx
Normal file
64
VinEye/src/components/my-plants/ConfidenceTile.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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 */}
|
||||
{/* 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.starWrapper}>
|
||||
<Star size={18} color="#FFB800" fill="#FFB800" />
|
||||
<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',
|
||||
|
|
|
|||
64
VinEye/src/components/my-plants/StatusTag.tsx
Normal file
64
VinEye/src/components/my-plants/StatusTag.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
258
VinEye/src/components/profile/EditProfileModal.tsx
Normal file
258
VinEye/src/components/profile/EditProfileModal.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
78
VinEye/src/components/shared/HeaderActionButtons.tsx
Normal file
78
VinEye/src/components/shared/HeaderActionButtons.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
30
VinEye/src/hooks/useUserProfile.ts
Normal file
30
VinEye/src/hooks/useUserProfile.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<LargeDiseaseCard
|
||||
key={disease.id}
|
||||
disease={disease}
|
||||
onPress={() =>
|
||||
navigation.navigate("DiseaseDetail", { diseaseId: disease.id })
|
||||
}
|
||||
index={index}
|
||||
size="grid"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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')}
|
||||
{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">
|
||||
{cepage.characteristics.fr}
|
||||
{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>
|
||||
</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>
|
||||
</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={() => navigation.goBack()}
|
||||
onPress={handleViewDisease}
|
||||
>
|
||||
<Ionicons name="scan" size={18} color={colors.surface} />
|
||||
<Text className="text-white">{t('result.scanAgain')}</Text>
|
||||
<Ionicons name="information-circle" size={18} color={colors.surface} />
|
||||
<Text className="text-white">{t('result.viewDiseaseDetail')}</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant={showDiseaseCta && !showHealthy ? 'ghost' : 'default'}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
{hasImage ? (
|
||||
<Image
|
||||
source={hasImage ? { uri: detection.imageUri } : FALLBACK_IMAGE}
|
||||
source={{ uri: detection.imageUri }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
contentFit={hasImage ? 'cover' : 'contain'}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
<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={["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" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,13 +149,18 @@ export default function SettingsScreen() {
|
|||
|
||||
const renderMenuGroup = (items: MenuItem[]) => (
|
||||
<View style={styles.menuCard}>
|
||||
{items.map((item, index) => (
|
||||
{items.map((item, index) => {
|
||||
const isToggle = typeof item.onToggle === "function";
|
||||
const Wrapper = isToggle ? View : TouchableOpacity;
|
||||
|
||||
return (
|
||||
<View key={item.label}>
|
||||
{index > 0 && <View style={styles.divider} />}
|
||||
<TouchableOpacity
|
||||
<Wrapper
|
||||
style={styles.menuRow}
|
||||
activeOpacity={0.5}
|
||||
onPress={item.onPress}
|
||||
{...(isToggle
|
||||
? {}
|
||||
: { activeOpacity: 0.5, onPress: item.onPress })}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
|
|
@ -136,19 +169,35 @@ export default function SettingsScreen() {
|
|||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={item.icon as any}
|
||||
name={item.icon as keyof typeof Ionicons.glyphMap}
|
||||
size={20}
|
||||
color={item.danger ? "#EF4444" : "#636E72"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[styles.menuLabel, item.danger && styles.menuLabelDanger]}
|
||||
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={[
|
||||
|
|
@ -159,50 +208,77 @@ export default function SettingsScreen() {
|
|||
{item.rightText}
|
||||
</Text>
|
||||
)}
|
||||
<Ionicons name="chevron-forward" size={14} color="#D1D1D6" />
|
||||
<ChevronRight size={16} color="#D1D1D6" strokeWidth={2} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
20
VinEye/src/services/ml/classes.ts
Normal file
20
VinEye/src/services/ml/classes.ts
Normal 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;
|
||||
85
VinEye/src/services/ml/preprocessing.ts
Normal file
85
VinEye/src/services/ml/preprocessing.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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) // 70–100%
|
||||
: result === 'uncertain'
|
||||
? Math.floor(40 + Math.random() * 30) // 40–70%
|
||||
: Math.floor(10 + Math.random() * 30); // 10–40%
|
||||
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
29
VinEye/src/types/user.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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: "",
|
||||
|
|
|
|||
Loading…
Reference in a new issue