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 |
|
| 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
|
## 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 |
|
| Styling | **NativeWind v4** (Tailwind) prioritaire, StyleSheet pour ombres/gradients |
|
||||||
| Icones | **lucide-react-native** (bottom bar) + **Ionicons** (reste de l'app) |
|
| Icones | **lucide-react-native** (bottom bar) + **Ionicons** (reste de l'app) |
|
||||||
| Animations | React Native Reanimated v4 |
|
| Animations | React Native Reanimated v4 |
|
||||||
| IA | TFLite mock (weighted random) |
|
| IA | **react-native-fast-tflite** + MobileNetV2 (.tflite, 9.4 MB, 4 classes) |
|
||||||
| Persistance | AsyncStorage |
|
| Persistance | AsyncStorage |
|
||||||
| i18n | i18next + react-i18next (FR + EN) |
|
| i18n | i18next + react-i18next (FR + EN) |
|
||||||
| Camera | expo-camera |
|
| Camera | expo-camera |
|
||||||
|
|
@ -186,5 +186,89 @@ pnpm ios # Build iOS
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Version** : 2.0.0
|
**Version** : 2.1.0
|
||||||
**Derniere mise a jour** : 2026-04-02
|
**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.INTERNET",
|
||||||
"android.permission.ACCESS_NETWORK_STATE",
|
"android.permission.ACCESS_NETWORK_STATE",
|
||||||
"android.permission.READ_EXTERNAL_STORAGE",
|
"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": {
|
"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);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
config.resolver.assetExts.push('tflite', 'bin');
|
||||||
|
|
||||||
module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 });
|
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 { 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 { Text } from "@/components/ui/text";
|
||||||
|
import { HeaderActionButtons } from "@/components/shared/HeaderActionButtons";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
import type { RootStackParamList } from "@/types/navigation";
|
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|
||||||
|
|
||||||
export default function SearchHeader() {
|
export default function SearchHeader() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation<Nav>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.headerContainer}>
|
<View style={styles.headerContainer}>
|
||||||
|
|
@ -21,32 +15,7 @@ export default function SearchHeader() {
|
||||||
<Text style={styles.greetingText}>{t("home.greeting")}</Text>
|
<Text style={styles.greetingText}>{t("home.greeting")}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.buttonsGroup}>
|
<HeaderActionButtons />
|
||||||
<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>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -66,9 +35,9 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
brandTitle: {
|
brandTitle: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: "900", // Très gras pour l'identité
|
fontWeight: "900",
|
||||||
color: colors.primary[900],
|
color: colors.primary[900],
|
||||||
letterSpacing: -1, // Look "Logo"
|
letterSpacing: -1,
|
||||||
},
|
},
|
||||||
greetingText: {
|
greetingText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
@ -76,31 +45,4 @@ const styles = StyleSheet.create({
|
||||||
color: colors.neutral[500],
|
color: colors.neutral[500],
|
||||||
marginTop: -2,
|
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,
|
activeAction,
|
||||||
}: FloatingActionsProps) {
|
}: FloatingActionsProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.column}>
|
<View style={styles.column} collapsable={false}>
|
||||||
<ActionButton active={activeAction === "layers"} onPress={onLayers}>
|
<ActionButton active={activeAction === "layers"} onPress={onLayers}>
|
||||||
<Layers
|
<Layers
|
||||||
size={22}
|
size={22}
|
||||||
|
|
@ -69,6 +69,7 @@ function ActionButton({ children, onPress, active }: ActionButtonProps) {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
column: {
|
column: {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
elevation: 24,
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
width: 56,
|
width: 56,
|
||||||
|
|
@ -80,12 +81,12 @@ const styles = StyleSheet.create({
|
||||||
buttonInactive: {
|
buttonInactive: {
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#F0F0F0",
|
borderColor: "#E5E7EB",
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.08,
|
shadowOpacity: 0.18,
|
||||||
shadowRadius: 10,
|
shadowRadius: 12,
|
||||||
elevation: 4,
|
elevation: 24,
|
||||||
},
|
},
|
||||||
buttonActive: {
|
buttonActive: {
|
||||||
backgroundColor: colors.primary[900],
|
backgroundColor: colors.primary[900],
|
||||||
|
|
@ -93,6 +94,6 @@ const styles = StyleSheet.create({
|
||||||
shadowOffset: { width: 0, height: 6 },
|
shadowOffset: { width: 0, height: 6 },
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.3,
|
||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
elevation: 8,
|
elevation: 24,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Search, MapPin } from "lucide-react-native";
|
import { Search, MapPin } from "lucide-react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { HeaderActionButtons } from "@/components/shared/HeaderActionButtons";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
import { WINE_REGIONS } from "@/data/wineRegions";
|
import { WINE_REGIONS } from "@/data/wineRegions";
|
||||||
|
|
||||||
|
|
@ -21,33 +22,47 @@ interface FloatingSearchProps {
|
||||||
onFilterPress?: (id: MapFilterId) => void;
|
onFilterPress?: (id: MapFilterId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FloatingSearch({ activeFilter, onFilterPress }: FloatingSearchProps) {
|
export function FloatingSearch({
|
||||||
|
activeFilter,
|
||||||
|
onFilterPress,
|
||||||
|
}: FloatingSearchProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const filters = [
|
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 (
|
return (
|
||||||
<View>
|
<View style={styles.root} collapsable={false}>
|
||||||
<View style={styles.searchBar}>
|
<View style={styles.searchRow}>
|
||||||
<Search size={20} color={colors.primary[800]} strokeWidth={2} />
|
<View style={styles.searchBar}>
|
||||||
<TextInput
|
<Search size={20} color={colors.primary[800]} strokeWidth={2} />
|
||||||
value={query}
|
<TextInput
|
||||||
onChangeText={setQuery}
|
value={query}
|
||||||
placeholder={t("map.searchPlaceholder")}
|
onChangeText={setQuery}
|
||||||
placeholderTextColor={colors.neutral[500]}
|
placeholder={t("map.searchPlaceholder")}
|
||||||
style={styles.input}
|
placeholderTextColor={colors.neutral[500]}
|
||||||
/>
|
style={styles.input}
|
||||||
<View style={styles.logoWrap}>
|
|
||||||
<Image
|
|
||||||
source={require("../../../assets/logo.png")}
|
|
||||||
style={styles.logo}
|
|
||||||
resizeMode="cover"
|
|
||||||
/>
|
/>
|
||||||
|
{/* <View style={styles.logoWrap}>
|
||||||
|
<Image
|
||||||
|
source={require("../../../assets/logo.png")}
|
||||||
|
style={styles.logo}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View> */}
|
||||||
</View>
|
</View>
|
||||||
|
<HeaderActionButtons />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|
@ -71,10 +86,7 @@ export function FloatingSearch({ activeFilter, onFilterPress }: FloatingSearchPr
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[styles.chipText, isActive && styles.chipTextActive]}
|
||||||
styles.chipText,
|
|
||||||
isActive && styles.chipTextActive,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{t(filter.labelKey)}
|
{t(filter.labelKey)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -87,11 +99,20 @@ export function FloatingSearch({ activeFilter, onFilterPress }: FloatingSearchPr
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
elevation: 24,
|
||||||
|
},
|
||||||
|
searchRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
searchBar: {
|
searchBar: {
|
||||||
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
borderRadius: 20,
|
borderRadius: 75,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
|
@ -99,7 +120,7 @@ const styles = StyleSheet.create({
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.12,
|
shadowOpacity: 0.12,
|
||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
elevation: 6,
|
elevation: 24,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.neutral[200],
|
borderColor: colors.neutral[200],
|
||||||
},
|
},
|
||||||
|
|
@ -140,13 +161,13 @@ const styles = StyleSheet.create({
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.04,
|
||||||
shadowRadius: 6,
|
shadowRadius: 6,
|
||||||
elevation: 2,
|
elevation: 24,
|
||||||
},
|
},
|
||||||
chipActive: {
|
chipActive: {
|
||||||
backgroundColor: colors.primary[800],
|
backgroundColor: colors.primary[800],
|
||||||
borderColor: colors.primary[800],
|
borderColor: colors.primary[800],
|
||||||
shadowOpacity: 0.12,
|
shadowOpacity: 0.12,
|
||||||
elevation: 4,
|
elevation: 24,
|
||||||
},
|
},
|
||||||
chipText: {
|
chipText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ export const VineyardMapView = forwardRef<VineyardMapHandle, VineyardMapViewProp
|
||||||
return (
|
return (
|
||||||
<WebView
|
<WebView
|
||||||
ref={webRef}
|
ref={webRef}
|
||||||
style={StyleSheet.absoluteFill}
|
style={[StyleSheet.absoluteFill, { backgroundColor: "transparent" }]}
|
||||||
originWhitelist={["*"]}
|
originWhitelist={["*"]}
|
||||||
source={{ html }}
|
source={{ html }}
|
||||||
onMessage={handleMessage}
|
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 { Star, StarOff, Trash2 } from 'lucide-react-native';
|
||||||
import { toast } from 'sonner-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 { getCepageById } from '@/utils/cepages';
|
||||||
import { hapticLight, hapticSuccess } from '@/services/haptics';
|
import { hapticLight, hapticSuccess } from '@/services/haptics';
|
||||||
import { colors } from '@/theme/colors';
|
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 {
|
interface ScanListItemProps {
|
||||||
scan: ScanRecord;
|
scan: ScanRecord;
|
||||||
|
|
@ -18,12 +21,22 @@ interface ScanListItemProps {
|
||||||
onDelete: () => void;
|
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 {
|
function formatTime(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlantName(scan: ScanRecord, t: (key: string) => string): string {
|
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) {
|
if (scan.detection.cepageId) {
|
||||||
const c = getCepageById(scan.detection.cepageId);
|
const c = getCepageById(scan.detection.cepageId);
|
||||||
if (c) return c.name.fr;
|
if (c) return c.name.fr;
|
||||||
|
|
@ -33,19 +46,12 @@ function getPlantName(scan: ScanRecord, t: (key: string) => string): string {
|
||||||
return t('result.notVine');
|
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) {
|
export function ScanListItem({ scan, onPress, onToggleFavorite, onDelete }: ScanListItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const swipeableRef = useRef<Swipeable>(null);
|
const swipeableRef = useRef<Swipeable>(null);
|
||||||
const isFav = scan.isFavorite === true;
|
const isFav = scan.isFavorite === true;
|
||||||
|
const status = getScanStatus(scan);
|
||||||
|
const hasImage = !!scan.detection.imageUri;
|
||||||
|
|
||||||
function handleFavorite() {
|
function handleFavorite() {
|
||||||
onToggleFavorite();
|
onToggleFavorite();
|
||||||
|
|
@ -118,12 +124,12 @@ export function ScanListItem({ scan, onPress, onToggleFavorite, onDelete }: Scan
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{/* Image */}
|
{/* Image (cover for real captures, contain for fallback) */}
|
||||||
<View style={styles.imageWrapper}>
|
<View style={styles.imageWrapper}>
|
||||||
<Image
|
<Image
|
||||||
source={scan.detection.imageUri ? { uri: scan.detection.imageUri } : FALLBACK_IMAGE}
|
source={hasImage ? { uri: scan.detection.imageUri } : FALLBACK_IMAGE}
|
||||||
style={styles.image}
|
style={hasImage ? styles.image : styles.fallbackImage}
|
||||||
contentFit={scan.detection.imageUri ? 'cover' : 'contain'}
|
contentFit={hasImage ? 'cover' : 'contain'}
|
||||||
transition={200}
|
transition={200}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -133,18 +139,26 @@ export function ScanListItem({ scan, onPress, onToggleFavorite, onDelete }: Scan
|
||||||
<Text style={styles.plantName} numberOfLines={1}>
|
<Text style={styles.plantName} numberOfLines={1}>
|
||||||
{getPlantName(scan, t)}
|
{getPlantName(scan, t)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.status} numberOfLines={1}>
|
<View style={styles.metaRow}>
|
||||||
{getStatusLabel(scan, t)}
|
<StatusTag status={status} />
|
||||||
</Text>
|
</View>
|
||||||
<Text style={styles.time}>{formatTime(scan.createdAt)}</Text>
|
<Text style={styles.time}>{formatTime(scan.createdAt)}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Favorite star */}
|
{/* Confidence score on the right */}
|
||||||
{isFav && (
|
<View style={styles.rightSlot}>
|
||||||
<View style={styles.starWrapper}>
|
<ConfidenceTile
|
||||||
<Star size={18} color="#FFB800" fill="#FFB800" />
|
confidence={scan.detection.confidence}
|
||||||
</View>
|
fillColor={STATUS_FILL[status]}
|
||||||
)}
|
size={44}
|
||||||
|
scoreSize={13}
|
||||||
|
/>
|
||||||
|
{isFav && (
|
||||||
|
<View style={styles.favStarMini}>
|
||||||
|
<Star size={14} color="#FFB800" fill="#FFB800" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Swipeable>
|
</Swipeable>
|
||||||
);
|
);
|
||||||
|
|
@ -168,33 +182,48 @@ const styles = StyleSheet.create({
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: '#F8F9FB',
|
backgroundColor: '#F8F9FB',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
},
|
},
|
||||||
|
fallbackImage: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
gap: 2,
|
gap: 4,
|
||||||
},
|
},
|
||||||
plantName: {
|
plantName: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#1A1A1A',
|
color: '#1A1A1A',
|
||||||
},
|
},
|
||||||
status: {
|
metaRow: {
|
||||||
fontSize: 13,
|
flexDirection: 'row',
|
||||||
color: '#8E8E93',
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#8E8E93',
|
color: '#8E8E93',
|
||||||
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
starWrapper: {
|
rightSlot: {
|
||||||
paddingLeft: 8,
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
favStarMini: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
// Swipe actions
|
|
||||||
actionsRow: {
|
actionsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
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 { runInference } from '@/services/tflite/model';
|
||||||
import type { Detection } from '@/types/detection';
|
import type { Detection } from '@/types/detection';
|
||||||
|
|
||||||
// TODO: Remplacer par le vrai hook TFLite avec react-native-fast-tflite
|
|
||||||
export function useDetection() {
|
export function useDetection() {
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [lastDetection, setLastDetection] = useState<Detection | null>(null);
|
const [lastDetection, setLastDetection] = useState<Detection | null>(null);
|
||||||
|
|
@ -17,7 +16,7 @@ export function useDetection() {
|
||||||
setLastDetection(detection);
|
setLastDetection(detection);
|
||||||
return detection;
|
return detection;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Erreur lors de l\'analyse. Veuillez réessayer.');
|
setError("Erreur lors de l'analyse. Veuillez reessayer.");
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setIsAnalyzing(false);
|
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",
|
"unfavorited": "Removed from favorites",
|
||||||
"deleted": "Scan deleted"
|
"deleted": "Scan deleted"
|
||||||
},
|
},
|
||||||
|
"status": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"infected": "Diseased",
|
||||||
|
"uncertain": "Uncertain"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No plants scanned yet",
|
"title": "No plants scanned yet",
|
||||||
"subtitle": "Scan your first plant to start your collection",
|
"subtitle": "Scan your first plant to start your collection",
|
||||||
|
|
@ -316,6 +321,11 @@
|
||||||
"moderate": "Moderate",
|
"moderate": "Moderate",
|
||||||
"low": "Low"
|
"low": "Low"
|
||||||
},
|
},
|
||||||
|
"riskLevel": {
|
||||||
|
"high": "High risk",
|
||||||
|
"medium": "Medium risk",
|
||||||
|
"low": "Low risk"
|
||||||
|
},
|
||||||
"healthyLeaf": {
|
"healthyLeaf": {
|
||||||
"title": "Recognizing a healthy leaf",
|
"title": "Recognizing a healthy leaf",
|
||||||
"subtitle": "Basics for beginners",
|
"subtitle": "Basics for beginners",
|
||||||
|
|
@ -446,7 +456,18 @@
|
||||||
"resetData": "Reset data",
|
"resetData": "Reset data",
|
||||||
"resetConfirm": "Are you sure you want to reset all data?",
|
"resetConfirm": "Are you sure you want to reset all data?",
|
||||||
"days": "days",
|
"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": {
|
"settings": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
|
|
@ -462,7 +483,12 @@
|
||||||
"referBody": "Share VinEye and earn bonus XP for every friend you invite.",
|
"referBody": "Share VinEye and earn bonus XP for every friend you invite.",
|
||||||
"developer": "Developer",
|
"developer": "Developer",
|
||||||
"seedTestData": "Add mock plants",
|
"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": {
|
"achievements": {
|
||||||
"firstScan": "First Scan",
|
"firstScan": "First Scan",
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,11 @@
|
||||||
"unfavorited": "Retiré des favoris",
|
"unfavorited": "Retiré des favoris",
|
||||||
"deleted": "Scan supprimé"
|
"deleted": "Scan supprimé"
|
||||||
},
|
},
|
||||||
|
"status": {
|
||||||
|
"healthy": "Saine",
|
||||||
|
"infected": "Malade",
|
||||||
|
"uncertain": "Incertain"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Aucune plante scannée",
|
"title": "Aucune plante scannée",
|
||||||
"subtitle": "Scannez votre première plante pour commencer votre collection",
|
"subtitle": "Scannez votre première plante pour commencer votre collection",
|
||||||
|
|
@ -316,6 +321,11 @@
|
||||||
"moderate": "Modéré",
|
"moderate": "Modéré",
|
||||||
"low": "Faible"
|
"low": "Faible"
|
||||||
},
|
},
|
||||||
|
"riskLevel": {
|
||||||
|
"high": "Risque Élevé",
|
||||||
|
"medium": "Risque Modéré",
|
||||||
|
"low": "Risque Faible"
|
||||||
|
},
|
||||||
"healthyLeaf": {
|
"healthyLeaf": {
|
||||||
"title": "Reconnaître une feuille saine",
|
"title": "Reconnaître une feuille saine",
|
||||||
"subtitle": "Les bases pour débutants",
|
"subtitle": "Les bases pour débutants",
|
||||||
|
|
@ -446,7 +456,18 @@
|
||||||
"resetData": "Réinitialiser les données",
|
"resetData": "Réinitialiser les données",
|
||||||
"resetConfirm": "Êtes-vous sûr de vouloir réinitialiser toutes les données ?",
|
"resetConfirm": "Êtes-vous sûr de vouloir réinitialiser toutes les données ?",
|
||||||
"days": "jours",
|
"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": {
|
"settings": {
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
|
|
@ -462,7 +483,12 @@
|
||||||
"referBody": "Partagez VinEye et gagnez des XP bonus pour chaque ami invité.",
|
"referBody": "Partagez VinEye et gagnez des XP bonus pour chaque ami invité.",
|
||||||
"developer": "Développeur",
|
"developer": "Développeur",
|
||||||
"seedTestData": "Ajouter des plantes fictives",
|
"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": {
|
"achievements": {
|
||||||
"firstScan": "Premier Scan",
|
"firstScan": "Premier Scan",
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: colors.neutral[300],
|
borderTopColor: colors.neutral[300],
|
||||||
paddingBottom: insets.bottom,
|
paddingBottom: insets.bottom,
|
||||||
paddingTop: 8,
|
paddingTop: 6,
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -84,7 +84,7 @@ function MyCustomTabBar({ state, descriptors, navigation }: any) {
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
marginTop: -25,
|
marginTop: -25,
|
||||||
shadowColor: colors.primary[900],
|
shadowColor: colors.primary[900],
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.3,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Text } from "@/components/ui/text";
|
||||||
import SearchHeader from "@/components/home/SearchHeader";
|
import SearchHeader from "@/components/home/SearchHeader";
|
||||||
import SearchSection from "@/components/home/SearchSection";
|
import SearchSection from "@/components/home/SearchSection";
|
||||||
import AnimatedSegmentedControl from "@/components/guides/AnimatedSegmentedControl";
|
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 GuideListItem from "@/components/ui/GuideListItem";
|
||||||
import { DiseaseCardSkeleton, GuideListItemSkeleton } from "@/components/ui/Skeleton";
|
import { DiseaseCardSkeleton, GuideListItemSkeleton } from "@/components/ui/Skeleton";
|
||||||
import { useDiseases } from "@/hooks/useDiseases";
|
import { useDiseases } from "@/hooks/useDiseases";
|
||||||
|
|
@ -76,24 +76,20 @@ export default function GuidesScreen() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeTab === 0 ? (
|
{activeTab === 0 ? (
|
||||||
<View style={styles.grid}>
|
<View style={styles.diseaseList}>
|
||||||
{showDiseasesSkeleton
|
{showDiseasesSkeleton
|
||||||
? Array.from({ length: 4 }).map((_, i) => (
|
? Array.from({ length: 3 }).map((_, i) => (
|
||||||
<View key={i} style={styles.gridItem}>
|
<DiseaseCardSkeleton key={i} style={{ height: 260, borderRadius: 32 }} />
|
||||||
<DiseaseCardSkeleton style={{ height: 160 }} />
|
|
||||||
</View>
|
|
||||||
))
|
))
|
||||||
: diseases.map((disease, index) => (
|
: diseases.map((disease, index) => (
|
||||||
<View key={disease.id} style={styles.gridItem}>
|
<LargeDiseaseCard
|
||||||
<SmallDiseaseCard
|
key={disease.id}
|
||||||
disease={disease}
|
disease={disease}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigation.navigate("DiseaseDetail", { diseaseId: disease.id })
|
navigation.navigate("DiseaseDetail", { diseaseId: disease.id })
|
||||||
}
|
}
|
||||||
index={index}
|
index={index}
|
||||||
size="grid"
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -124,16 +120,11 @@ export default function GuidesScreen() {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: {
|
root: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#F8F9FB",
|
backgroundColor: "#FAFAFA",
|
||||||
},
|
},
|
||||||
grid: {
|
diseaseList: {
|
||||||
flexDirection: "row",
|
paddingHorizontal: 20,
|
||||||
flexWrap: "wrap",
|
gap: 16,
|
||||||
paddingHorizontal: 16,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
gridItem: {
|
|
||||||
width: "48%",
|
|
||||||
},
|
},
|
||||||
guidesSection: {
|
guidesSection: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,7 @@ export default function MapScreen() {
|
||||||
<View
|
<View
|
||||||
style={[styles.searchSlot, { paddingTop: insets.top + 8 }]}
|
style={[styles.searchSlot, { paddingTop: insets.top + 8 }]}
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
|
collapsable={false}
|
||||||
>
|
>
|
||||||
<FloatingSearch activeFilter={activeFilter} onFilterPress={handleFilterPress} />
|
<FloatingSearch activeFilter={activeFilter} onFilterPress={handleFilterPress} />
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -194,12 +195,16 @@ export default function MapScreen() {
|
||||||
defaultIndex={isEmpty ? 1 : 0}
|
defaultIndex={isEmpty ? 1 : 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.actionsSlot} pointerEvents="box-none">
|
<View
|
||||||
|
style={styles.actionsSlot}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
collapsable={false}
|
||||||
|
>
|
||||||
<FloatingActions
|
<FloatingActions
|
||||||
onLocate={handleLocateUser}
|
onLocate={handleLocateUser}
|
||||||
onLayers={handleComingSoon}
|
onLayers={handleComingSoon}
|
||||||
onSatellite={handleComingSoon}
|
onSatellite={handleComingSoon}
|
||||||
activeAction={activeFilter === "myLocation" ? "locate" : undefined}
|
activeAction={activeFilter === "myLocation" ? "locate" : "layers"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -223,12 +228,14 @@ const styles = StyleSheet.create({
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
zIndex: 20,
|
||||||
|
elevation: 24,
|
||||||
},
|
},
|
||||||
actionsSlot: {
|
actionsSlot: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 16,
|
right: 16,
|
||||||
top: "30%",
|
top: "30%",
|
||||||
zIndex: 10,
|
zIndex: 20,
|
||||||
elevation: 10,
|
elevation: 24,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { Search, ScanLine } from 'lucide-react-native';
|
||||||
|
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { DateGroupAccordion } from '@/components/my-plants/DateGroupAccordion';
|
import { DateGroupAccordion } from '@/components/my-plants/DateGroupAccordion';
|
||||||
|
import { HeaderActionButtons } from '@/components/shared/HeaderActionButtons';
|
||||||
import { useHistory } from '@/hooks/useHistory';
|
import { useHistory } from '@/hooks/useHistory';
|
||||||
import { getCepageById } from '@/utils/cepages';
|
import { getCepageById } from '@/utils/cepages';
|
||||||
import { groupScansByDate } from '@/utils/dateGrouping';
|
import { groupScansByDate } from '@/utils/dateGrouping';
|
||||||
|
|
@ -136,6 +137,7 @@ export default function MyPlantsScreen() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{t('myPlants.title')}</Text>
|
<Text style={styles.title}>{t('myPlants.title')}</Text>
|
||||||
|
<HeaderActionButtons />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
|
|
@ -205,9 +207,13 @@ const styles = StyleSheet.create({
|
||||||
backgroundColor: '#F8F9FB',
|
backgroundColor: '#F8F9FB',
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingTop: 12,
|
paddingTop: 12,
|
||||||
paddingBottom: 4,
|
paddingBottom: 4,
|
||||||
|
gap: 12,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 28,
|
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 { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Pencil } from "lucide-react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { EditProfileModal } from "@/components/profile/EditProfileModal";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
import { useGameProgress } from "@/hooks/useGameProgress";
|
import { useGameProgress } from "@/hooks/useGameProgress";
|
||||||
|
import { useUserProfile } from "@/hooks/useUserProfile";
|
||||||
import type { RootStackParamList } from "@/types/navigation";
|
import type { RootStackParamList } from "@/types/navigation";
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
const { width } = Dimensions.get("window");
|
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 = [
|
const BENTO_STATS = [
|
||||||
{ key: "scans", icon: "scan-outline", iconColor: "#F59E0B", label: "profile.totalScans" },
|
{ key: "scans", icon: "scan-outline", iconColor: "#F59E0B", label: "profile.totalScans" },
|
||||||
|
|
@ -26,50 +37,61 @@ export default function ProfileScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { progress } = useGameProgress();
|
const { progress } = useGameProgress();
|
||||||
|
const { profile, updateProfile } = useUserProfile();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
if (navigation.canGoBack()) {
|
if (navigation.canGoBack()) {
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
} else {
|
} else {
|
||||||
navigation.navigate("Main" as any);
|
navigation.navigate("Main" as never);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
{/* Hero Header - Style Courbé */}
|
{/* Hero Header */}
|
||||||
<View style={styles.heroBlock}>
|
<View style={styles.heroBlock}>
|
||||||
<SafeAreaView edges={["top"]} style={styles.heroSafeArea}>
|
<SafeAreaView edges={["top"]} style={styles.heroSafeArea}>
|
||||||
<View style={styles.heroTopRow}>
|
<View style={styles.heroTopRow}>
|
||||||
<TouchableOpacity onPress={handleBack} style={styles.heroBackBtn}>
|
<TouchableOpacity onPress={handleBack} style={styles.heroBackBtn} activeOpacity={0.7}>
|
||||||
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
|
<Ionicons name="chevron-back" size={22} color="#FFFFFF" />
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={() => navigation.navigate("Settings")} style={styles.heroSettingsBtn}>
|
|
||||||
<Ionicons name="settings-outline" size={22} color={colors.primary[800]} />
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
{/* Avatar avec bague de séparation */}
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
<View style={styles.avatarContainer}>
|
<View style={styles.avatarContainer}>
|
||||||
<View style={styles.avatarRing}>
|
<View style={styles.avatarRing}>
|
||||||
<View style={styles.avatar}>
|
<View style={styles.avatar}>
|
||||||
<Text style={styles.avatarEmoji}>🧑🌾</Text>
|
<Text style={styles.avatarEmoji}>{profile.avatar}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* User Info - Focus sur la clarté */}
|
{/* User Info */}
|
||||||
<View style={styles.infoCard}>
|
<View style={styles.infoCard}>
|
||||||
<Text style={styles.userName}>Yanis Cyrius</Text>
|
<Text style={styles.userName}>
|
||||||
<Text style={styles.userEmail}>yanis@vineye.app</Text>
|
{profile.displayName || t("profile.namePlaceholder")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.userEmail}>
|
||||||
|
{profile.email || t("profile.emailPlaceholder")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View style={styles.actionRow}>
|
<View style={styles.actionRow}>
|
||||||
<TouchableOpacity style={styles.friendBtn} activeOpacity={0.8}>
|
<TouchableOpacity
|
||||||
<Text style={styles.friendBtnText}>+ Friends</Text>
|
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>
|
</TouchableOpacity>
|
||||||
<View style={styles.xpBadge}>
|
<View style={styles.xpBadge}>
|
||||||
<Text style={styles.xpBadgeText}>{progress.xp} XP</Text>
|
<Text style={styles.xpBadgeText}>{progress.xp} XP</Text>
|
||||||
|
|
@ -77,15 +99,19 @@ export default function ProfileScreen() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Stats Grid - Bento Style Pur */}
|
{/* Stats Grid */}
|
||||||
<View style={styles.statsGrid}>
|
<View style={styles.statsGrid}>
|
||||||
{BENTO_STATS.map((stat) => (
|
{BENTO_STATS.map((stat) => (
|
||||||
<View key={stat.key} style={styles.statCard}>
|
<View key={stat.key} style={styles.statCard}>
|
||||||
<View style={[styles.statIconWrap, { backgroundColor: `${stat.iconColor}15` }]}>
|
<View
|
||||||
<Ionicons name={stat.icon as any} size={22} color={stat.iconColor} />
|
style={[styles.statIconWrap, { backgroundColor: `${stat.iconColor}15` }]}
|
||||||
|
>
|
||||||
|
<Ionicons name={stat.icon as keyof typeof Ionicons.glyphMap} size={22} color={stat.iconColor} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statValue}>
|
<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>
|
||||||
<Text style={styles.statLabel}>{t(stat.label)}</Text>
|
<Text style={styles.statLabel}>{t(stat.label)}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -94,6 +120,13 @@ export default function ProfileScreen() {
|
||||||
|
|
||||||
<View style={{ height: 60 }} />
|
<View style={{ height: 60 }} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<EditProfileModal
|
||||||
|
visible={editing}
|
||||||
|
initialProfile={profile}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
onSave={(next) => updateProfile(next)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +134,7 @@ export default function ProfileScreen() {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: {
|
root: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#F8F9FB", // Gris très clair bleuté
|
backgroundColor: "#FAFAFA",
|
||||||
},
|
},
|
||||||
heroBlock: {
|
heroBlock: {
|
||||||
height: 200,
|
height: 200,
|
||||||
|
|
@ -114,23 +147,14 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
heroTopRow: {
|
heroTopRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
},
|
},
|
||||||
heroBackBtn: {
|
heroBackBtn: {
|
||||||
width: 44,
|
width: 40,
|
||||||
height: 44,
|
height: 40,
|
||||||
borderRadius: 16,
|
borderRadius: 14,
|
||||||
backgroundColor: "rgba(255,255,255,0.15)",
|
backgroundColor: "rgba(255,255,255,0.18)",
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
heroSettingsBtn: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 16,
|
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
|
@ -146,27 +170,36 @@ const styles = StyleSheet.create({
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
avatarRing: {
|
avatarRing: {
|
||||||
width: 110,
|
width: 116,
|
||||||
height: 110,
|
height: 116,
|
||||||
borderRadius: 55,
|
borderRadius: 58,
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
...Platform.select({
|
...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 },
|
android: { elevation: 8 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
width: 96,
|
width: 104,
|
||||||
height: 96,
|
height: 104,
|
||||||
borderRadius: 48,
|
borderRadius: 52,
|
||||||
backgroundColor: colors.primary[50],
|
backgroundColor: colors.primary[100],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
avatarEmoji: {
|
avatarEmoji: {
|
||||||
fontSize: 48,
|
fontSize: 56,
|
||||||
|
lineHeight: 72,
|
||||||
|
textAlign: "center",
|
||||||
|
includeFontPadding: false,
|
||||||
},
|
},
|
||||||
infoCard: {
|
infoCard: {
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
|
|
@ -175,47 +208,49 @@ const styles = StyleSheet.create({
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#F0F0F0",
|
borderColor: colors.neutral[200],
|
||||||
},
|
},
|
||||||
userName: {
|
userName: {
|
||||||
fontSize: 24,
|
fontSize: 22,
|
||||||
fontWeight: "800",
|
fontWeight: "700",
|
||||||
color: "#1A1A1A",
|
color: colors.neutral[900],
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.4,
|
||||||
},
|
},
|
||||||
userEmail: {
|
userEmail: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: "#A0A0A0",
|
color: colors.neutral[500],
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
marginBottom: 20,
|
marginBottom: 18,
|
||||||
},
|
},
|
||||||
actionRow: {
|
actionRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
friendBtn: {
|
editBtn: {
|
||||||
backgroundColor: "#FFFFFF",
|
flexDirection: "row",
|
||||||
borderWidth: 1.5,
|
alignItems: "center",
|
||||||
borderColor: "#F97316",
|
gap: 6,
|
||||||
|
backgroundColor: colors.primary[100],
|
||||||
borderRadius: 100,
|
borderRadius: 100,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
friendBtnText: {
|
editBtnText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#F97316",
|
color: colors.primary[800],
|
||||||
},
|
},
|
||||||
xpBadge: {
|
xpBadge: {
|
||||||
backgroundColor: colors.primary[600],
|
backgroundColor: colors.primary[700],
|
||||||
borderRadius: 100,
|
borderRadius: 100,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 18,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
xpBadgeText: {
|
xpBadgeText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
},
|
},
|
||||||
statsGrid: {
|
statsGrid: {
|
||||||
|
|
@ -230,7 +265,7 @@ const styles = StyleSheet.create({
|
||||||
padding: 20,
|
padding: 20,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#F2F2F2",
|
borderColor: colors.neutral[200],
|
||||||
},
|
},
|
||||||
statIconWrap: {
|
statIconWrap: {
|
||||||
width: 44,
|
width: 44,
|
||||||
|
|
@ -242,12 +277,13 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
statValue: {
|
statValue: {
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: "500", // Medium au lieu de Bold pour le look premium
|
fontWeight: "700",
|
||||||
color: "#1A1A1A",
|
color: colors.neutral[900],
|
||||||
},
|
},
|
||||||
statLabel: {
|
statLabel: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: "#9A9A9A",
|
fontWeight: "500",
|
||||||
|
color: colors.neutral[500],
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,38 +17,37 @@ import { ProgressCircle } from '@/components/ui/ProgressCircle';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { getCepageById } from '@/utils/cepages';
|
|
||||||
import { colors } from '@/theme/colors';
|
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 { RootStackParamList } from '@/types/navigation';
|
||||||
import type { DetectionResult } from '@/types/detection';
|
import type { Detection, DetectionResult, DiseaseClass } from '@/types/detection';
|
||||||
|
|
||||||
type ResultNav = NativeStackNavigationProp<RootStackParamList, 'Result'>;
|
type ResultNav = NativeStackNavigationProp<RootStackParamList, 'Result'>;
|
||||||
type ResultRoute = RouteProp<RootStackParamList, 'Result'>;
|
type ResultRoute = RouteProp<RootStackParamList, 'Result'>;
|
||||||
|
|
||||||
function getResultColor(result: DetectionResult): string {
|
function getStatusColor(detection: Detection): string {
|
||||||
if (result === 'vine') return colors.success;
|
if (detection.diseaseClass === 'healthy' && detection.result === 'vine') return colors.success;
|
||||||
if (result === 'uncertain') return colors.warning;
|
if (detection.result === 'vine') return colors.danger;
|
||||||
return colors.danger;
|
if (detection.result === 'uncertain') return colors.warning;
|
||||||
|
return colors.neutral[500];
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({ icon, iconColor, label, value }: {
|
function getStatusIcon(detection: Detection): keyof typeof Ionicons.glyphMap {
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
if (detection.diseaseClass === 'healthy' && detection.result === 'vine') return 'checkmark-circle';
|
||||||
iconColor: string;
|
if (detection.result === 'vine') return 'alert-circle';
|
||||||
label: string;
|
if (detection.result === 'uncertain') return 'help-circle';
|
||||||
value: string;
|
return 'close-circle';
|
||||||
}) {
|
}
|
||||||
return (
|
|
||||||
<View className="w-[48%] gap-1 rounded-[14px] bg-white p-[14px] shadow-sm" style={{ elevation: 1 }}>
|
function getStatusLabel(detection: Detection, t: (k: string) => string): string {
|
||||||
<View
|
if (detection.diseaseClass === 'healthy' && detection.result === 'vine') {
|
||||||
className="h-8 w-8 items-center justify-center rounded-lg"
|
return t('result.healthy');
|
||||||
style={{ backgroundColor: iconColor + '18' }}
|
}
|
||||||
>
|
if (detection.result === 'vine' && detection.diseaseClass) {
|
||||||
<Ionicons name={icon} size={20} color={iconColor} />
|
return t(CLASS_TO_LABEL_KEY[detection.diseaseClass]);
|
||||||
</View>
|
}
|
||||||
<Text className="text-[11px] text-neutral-500">{label}</Text>
|
if (detection.result === 'uncertain') return t('result.uncertain');
|
||||||
<Text className="text-[15px] font-semibold text-neutral-900">{value}</Text>
|
return t('result.notVine');
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultScreen() {
|
export default function ResultScreen() {
|
||||||
|
|
@ -57,8 +56,9 @@ export default function ResultScreen() {
|
||||||
const route = useRoute<ResultRoute>();
|
const route = useRoute<ResultRoute>();
|
||||||
const { detection } = route.params;
|
const { detection } = route.params;
|
||||||
|
|
||||||
const cepage = detection.cepageId ? getCepageById(detection.cepageId) : undefined;
|
const statusColor = getStatusColor(detection);
|
||||||
const resultColor = getResultColor(detection.result);
|
const statusIcon = getStatusIcon(detection);
|
||||||
|
const statusLabel = getStatusLabel(detection, t);
|
||||||
|
|
||||||
const headerOpacity = useSharedValue(0);
|
const headerOpacity = useSharedValue(0);
|
||||||
const headerScale = useSharedValue(0.8);
|
const headerScale = useSharedValue(0.8);
|
||||||
|
|
@ -82,17 +82,18 @@ export default function ResultScreen() {
|
||||||
transform: [{ translateY: cardTranslateY.value }],
|
transform: [{ translateY: cardTranslateY.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const resultLabel =
|
const diseaseSlug = detection.diseaseSlug;
|
||||||
detection.result === 'vine'
|
const showDiseaseCta = detection.result === 'vine' && diseaseSlug;
|
||||||
? t('result.vineDetected')
|
const showHealthy = detection.result === 'vine' && detection.diseaseClass === 'healthy';
|
||||||
: detection.result === 'uncertain'
|
|
||||||
? t('result.uncertain')
|
function handleViewDisease() {
|
||||||
: t('result.notVine');
|
if (!diseaseSlug) return;
|
||||||
|
navigation.navigate('DiseaseDetail', { diseaseId: diseaseSlug.replace(/-/g, '_') });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-[#FAFAFA]">
|
<SafeAreaView className="flex-1 bg-[#FAFAFA]">
|
||||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-5 p-4 pb-12">
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-5 p-4 pb-12">
|
||||||
{/* Close button */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="h-8 w-8 items-center justify-center self-end rounded-full bg-neutral-200"
|
className="h-8 w-8 items-center justify-center self-end rounded-full bg-neutral-200"
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
|
|
@ -100,91 +101,145 @@ export default function ResultScreen() {
|
||||||
<Ionicons name="close" size={20} color={colors.neutral[700]} />
|
<Ionicons name="close" size={20} color={colors.neutral[700]} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Confidence circle */}
|
|
||||||
<Animated.View className="items-center gap-3 py-4" style={headerStyle}>
|
<Animated.View className="items-center gap-3 py-4" style={headerStyle}>
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
size={100}
|
size={100}
|
||||||
strokeWidth={8}
|
strokeWidth={8}
|
||||||
progress={detection.confidence / 100}
|
progress={detection.confidence / 100}
|
||||||
color={resultColor}
|
color={statusColor}
|
||||||
trackColor={resultColor + '25'}
|
trackColor={statusColor + '25'}
|
||||||
>
|
>
|
||||||
<Text className="text-[20px] font-extrabold" style={{ color: resultColor }}>
|
<Text className="text-[20px] font-extrabold" style={{ color: statusColor }}>
|
||||||
{detection.confidence}%
|
{detection.confidence}%
|
||||||
</Text>
|
</Text>
|
||||||
</ProgressCircle>
|
</ProgressCircle>
|
||||||
|
|
||||||
{/* Success message with checkmark */}
|
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="flex-row items-center gap-1.5">
|
||||||
<Ionicons
|
<Ionicons name={statusIcon} size={20} color={statusColor} />
|
||||||
name={detection.result === 'vine' ? 'checkmark-circle' : detection.result === 'uncertain' ? 'help-circle' : 'close-circle'}
|
<Text className="text-[13px] font-medium" style={{ color: statusColor }}>
|
||||||
size={20}
|
{statusLabel}
|
||||||
color={resultColor}
|
|
||||||
/>
|
|
||||||
<Text className="text-[13px] font-medium" style={{ color: resultColor }}>
|
|
||||||
{resultLabel}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Plant name + tags + description + info grid */}
|
{showHealthy && (
|
||||||
{cepage && detection.result === 'vine' && (
|
<Animated.View style={cardStyle} className="gap-2 rounded-[20px] bg-white p-5 shadow-sm">
|
||||||
<Animated.View style={cardStyle}>
|
<Text className="text-[20px] font-bold text-neutral-900">
|
||||||
<Text className="mb-1 text-[24px] font-bold text-neutral-900">{cepage.name.fr}</Text>
|
{t('result.healthyTitle')}
|
||||||
|
</Text>
|
||||||
{/* Tags */}
|
<Text className="text-[13px] leading-[22px] text-neutral-600">
|
||||||
<View className="mb-5 flex-row flex-wrap gap-2">
|
{t('result.healthyMessage')}
|
||||||
<Badge
|
</Text>
|
||||||
label={cepage.color === 'rouge' ? '🍷 Rouge' : cepage.color === 'blanc' ? '🥂 Blanc' : '🌸 Rosé'}
|
</Animated.View>
|
||||||
color="neutral"
|
)}
|
||||||
size="sm"
|
|
||||||
/>
|
{showDiseaseCta && !showHealthy && (
|
||||||
{cepage.regions.slice(0, 2).map((r) => (
|
<Animated.View style={cardStyle}>
|
||||||
<Badge key={r} label={r} color="neutral" size="sm" />
|
<Text className="mb-1 text-[24px] font-bold text-neutral-900">
|
||||||
))}
|
{statusLabel}
|
||||||
</View>
|
</Text>
|
||||||
|
|
||||||
{/* Description */}
|
<View className="mb-5 flex-row flex-wrap gap-2">
|
||||||
<View className="mb-4 gap-1">
|
<Badge label={t('result.detectedDisease')} color="warning" size="sm" />
|
||||||
<Text className="text-[17px] font-semibold text-neutral-900">
|
{detection.allProbabilities && (
|
||||||
{t('result.characteristics')}
|
<Badge
|
||||||
</Text>
|
label={`${detection.confidence}% ${t('result.confidence')}`}
|
||||||
<Text className="text-[13px] leading-[22px] text-neutral-600">
|
color="neutral"
|
||||||
{cepage.characteristics.fr}
|
size="sm"
|
||||||
</Text>
|
/>
|
||||||
</View>
|
)}
|
||||||
|
</View>
|
||||||
{/* 2x2 info grid */}
|
|
||||||
<View className="flex-row flex-wrap gap-[10px]">
|
{detection.allProbabilities && (
|
||||||
<InfoCard icon="leaf" iconColor={colors.primary[700]} label={t('result.origin')} value={cepage.origin.fr} />
|
<View className="mb-4 gap-2 rounded-[14px] bg-white p-4 shadow-sm">
|
||||||
<InfoCard icon="water" iconColor="#2196F3" label={t('scanner.confidence')} value={`${detection.confidence}%`} />
|
<Text className="text-[13px] font-semibold text-neutral-700">
|
||||||
<InfoCard icon="sunny" iconColor="#FF9800" label={t('result.regions')} value={cepage.regions[0] ?? '—'} />
|
{t('result.allProbabilities')}
|
||||||
<InfoCard icon="wine" iconColor="#E91E63" label="Type" value={cepage.color === 'rouge' ? 'Rouge' : cepage.color === 'blanc' ? 'Blanc' : 'Rosé'} />
|
</Text>
|
||||||
</View>
|
{detection.allProbabilities
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.probability - a.probability)
|
||||||
|
.map((p) => (
|
||||||
|
<ProbabilityRow
|
||||||
|
key={p.class}
|
||||||
|
label={t(CLASS_TO_LABEL_KEY[p.class])}
|
||||||
|
value={p.probability}
|
||||||
|
isTop={p.class === detection.diseaseClass}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detection.result === 'uncertain' && (
|
||||||
|
<Animated.View style={cardStyle} className="gap-2 rounded-[20px] bg-white p-5 shadow-sm">
|
||||||
|
<Text className="text-[20px] font-bold text-neutral-900">
|
||||||
|
{t('result.uncertainTitle')}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[13px] leading-[22px] text-neutral-600">
|
||||||
|
{t('result.uncertainMessage')}
|
||||||
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<Animated.View className="mt-2 gap-2" style={cardStyle}>
|
<Animated.View className="mt-2 gap-2" style={cardStyle}>
|
||||||
|
{showDiseaseCta && !showHealthy && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
className="w-full rounded-[14px]"
|
||||||
|
onPress={handleViewDisease}
|
||||||
|
>
|
||||||
|
<Ionicons name="information-circle" size={18} color={colors.surface} />
|
||||||
|
<Text className="text-white">{t('result.viewDiseaseDetail')}</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant={showDiseaseCta && !showHealthy ? 'ghost' : 'default'}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full rounded-[14px]"
|
className="w-full rounded-[14px]"
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
>
|
>
|
||||||
<Ionicons name="scan" size={18} color={colors.surface} />
|
<Ionicons
|
||||||
<Text className="text-white">{t('result.scanAgain')}</Text>
|
name="scan"
|
||||||
</Button>
|
size={18}
|
||||||
<Button
|
color={showDiseaseCta && !showHealthy ? colors.primary[700] : colors.surface}
|
||||||
variant="ghost"
|
/>
|
||||||
size="lg"
|
<Text style={{ color: showDiseaseCta && !showHealthy ? colors.primary[700] : '#fff' }}>
|
||||||
className="w-full rounded-[14px]"
|
{t('result.scanAgain')}
|
||||||
onPress={() => navigation.goBack()}
|
</Text>
|
||||||
>
|
|
||||||
<Text style={{ color: colors.primary[700] }}>{t('result.viewHistory')}</Text>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</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 {
|
import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
|
@ -8,13 +8,13 @@ import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { Image } from 'expo-image';
|
import { Image } from "expo-image";
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Star,
|
Star,
|
||||||
|
|
@ -27,46 +27,66 @@ import {
|
||||||
Share2,
|
Share2,
|
||||||
Trash2,
|
Trash2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react-native';
|
} from "lucide-react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
withTiming,
|
withTiming,
|
||||||
Easing,
|
Easing,
|
||||||
} from 'react-native-reanimated';
|
} from "react-native-reanimated";
|
||||||
import { toast } from 'sonner-native';
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from "@/components/ui/text";
|
||||||
import { useScanDetail } from '@/hooks/useScanDetail';
|
import { useScanDetail } from "@/hooks/useScanDetail";
|
||||||
import { getCepageById } from '@/utils/cepages';
|
import { getCepageById } from "@/utils/cepages";
|
||||||
import { hapticSuccess } from '@/services/haptics';
|
import { hapticSuccess } from "@/services/haptics";
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from "@/theme/colors";
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
import type { RootStackParamList } from '@/types/navigation';
|
import type { RootStackParamList } from "@/types/navigation";
|
||||||
import type { DetectionResult } from '@/types/detection';
|
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 }> = {
|
const RESULT_STYLES: Record<
|
||||||
vine: { bg: '#E8F5E9', text: '#2D6A4F', Icon: CheckCircle2, labelKey: 'myPlants.detail.results.vine' },
|
DetectionResult,
|
||||||
uncertain: { bg: '#FFF4E5', text: '#E67E22', Icon: HelpCircle, labelKey: 'myPlants.detail.results.uncertain' },
|
{ bg: string; text: string; Icon: typeof CheckCircle2; labelKey: string }
|
||||||
not_vine: { bg: '#FFEBEE', text: '#C62828', Icon: XCircle, labelKey: 'myPlants.detail.results.notVine' },
|
> = {
|
||||||
|
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 {
|
function formatDateLong(iso: string, locale: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
const dateStr = d.toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
|
const dateStr = d.toLocaleDateString(locale === "fr" ? "fr-FR" : "en-US", {
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
month: 'long',
|
month: "long",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
});
|
});
|
||||||
const timeStr = d.toLocaleTimeString(locale === 'fr' ? 'fr-FR' : 'en-US', {
|
const timeStr = d.toLocaleTimeString(locale === "fr" ? "fr-FR" : "en-US", {
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '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) {
|
export default function ScanDetailScreen({ route }: Props) {
|
||||||
|
|
@ -74,7 +94,8 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { scan, loading, error, toggleFavorite, deleteScan } = useScanDetail(scanId);
|
const { scan, loading, error, toggleFavorite, deleteScan } =
|
||||||
|
useScanDetail(scanId);
|
||||||
|
|
||||||
// Entry animation
|
// Entry animation
|
||||||
const contentY = useSharedValue(30);
|
const contentY = useSharedValue(30);
|
||||||
|
|
@ -82,7 +103,10 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scan) {
|
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);
|
contentY.value = withTiming(0, timing);
|
||||||
contentOpacity.value = withTiming(1, timing);
|
contentOpacity.value = withTiming(1, timing);
|
||||||
}
|
}
|
||||||
|
|
@ -119,9 +143,12 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<AlertCircle size={48} color={colors.neutral[400]} />
|
<AlertCircle size={48} color={colors.neutral[400]} />
|
||||||
<Text style={styles.errorText}>{t('myPlants.detail.notFound')}</Text>
|
<Text style={styles.errorText}>{t("myPlants.detail.notFound")}</Text>
|
||||||
<TouchableOpacity style={styles.errorBtn} onPress={() => navigation.goBack()}>
|
<TouchableOpacity
|
||||||
<Text style={styles.errorBtnText}>{t('myPlants.detail.goBack')}</Text>
|
style={styles.errorBtn}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
>
|
||||||
|
<Text style={styles.errorBtnText}>{t("myPlants.detail.goBack")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -135,29 +162,31 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
|
|
||||||
const heroTitle = cepage
|
const heroTitle = cepage
|
||||||
? cepage.name.fr
|
? cepage.name.fr
|
||||||
: detection.result === 'vine'
|
: detection.result === "vine"
|
||||||
? t('myPlants.detail.results.vine')
|
? t("myPlants.detail.results.vine")
|
||||||
: t('myPlants.detail.results.unidentified');
|
: t("myPlants.detail.results.unidentified");
|
||||||
|
|
||||||
async function handleToggleFavorite() {
|
async function handleToggleFavorite() {
|
||||||
await toggleFavorite();
|
await toggleFavorite();
|
||||||
hapticSuccess();
|
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() {
|
function handleDelete() {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('myPlants.actions.deleteConfirmTitle'),
|
t("myPlants.actions.deleteConfirmTitle"),
|
||||||
t('myPlants.actions.deleteConfirmMessage'),
|
t("myPlants.actions.deleteConfirmMessage"),
|
||||||
[
|
[
|
||||||
{ text: t('myPlants.actions.cancel'), style: 'cancel' },
|
{ text: t("myPlants.actions.cancel"), style: "cancel" },
|
||||||
{
|
{
|
||||||
text: t('myPlants.actions.delete'),
|
text: t("myPlants.actions.delete"),
|
||||||
style: 'destructive',
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await deleteScan();
|
await deleteScan();
|
||||||
hapticSuccess();
|
hapticSuccess();
|
||||||
toast.success(t('myPlants.toasts.deleted'));
|
toast.success(t("myPlants.toasts.deleted"));
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -167,24 +196,25 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
|
|
||||||
function handleShare() {
|
function handleShare() {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('myPlants.detail.shareConfirmTitle'),
|
t("myPlants.detail.shareConfirmTitle"),
|
||||||
t('myPlants.detail.shareConfirmMessage'),
|
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 () => {
|
onPress: async () => {
|
||||||
if (!scan) return;
|
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 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 {
|
try {
|
||||||
await Share.share({
|
await Share.share({
|
||||||
message: text,
|
message: text,
|
||||||
...(detection.imageUri ? { url: detection.imageUri } : {}),
|
...(detection.imageUri ? { url: detection.imageUri } : {}),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('myPlants.detail.shareError'));
|
toast.error(t("myPlants.detail.shareError"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -202,15 +232,34 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
>
|
>
|
||||||
{/* ── Hero ── */}
|
{/* ── Hero ── */}
|
||||||
<View style={styles.heroContainer}>
|
<View style={styles.heroContainer}>
|
||||||
<Image
|
{hasImage ? (
|
||||||
source={hasImage ? { uri: detection.imageUri } : FALLBACK_IMAGE}
|
<Image
|
||||||
style={StyleSheet.absoluteFillObject}
|
source={{ uri: detection.imageUri }}
|
||||||
contentFit={hasImage ? 'cover' : 'contain'}
|
style={StyleSheet.absoluteFillObject}
|
||||||
transition={300}
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.heroFallback}>
|
||||||
|
<Image
|
||||||
|
source={FALLBACK_IMAGE}
|
||||||
|
style={styles.heroFallbackLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(255,255,255,0.55)", "transparent"]}
|
||||||
|
style={styles.gradientTop}
|
||||||
/>
|
/>
|
||||||
<LinearGradient colors={['rgba(0,0,0,0.35)', 'transparent']} style={styles.gradientTop} />
|
<LinearGradient
|
||||||
<LinearGradient colors={['transparent', '#F8F9FB']} style={styles.gradientBottom} />
|
colors={["transparent", "#F8F9FB"]}
|
||||||
<Text style={styles.heroTitle}>{heroTitle}</Text>
|
style={styles.gradientBottom}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.heroTitle, !hasImage && styles.heroTitleDark]}>
|
||||||
|
{heroTitle}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Floating buttons ── */}
|
{/* ── Floating buttons ── */}
|
||||||
|
|
@ -226,45 +275,75 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
onPress={handleToggleFavorite}
|
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>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
<Animated.View style={contentAnim}>
|
<Animated.View style={contentAnim}>
|
||||||
{/* Result Card */}
|
{/* Result Card */}
|
||||||
<View style={styles.resultCard}>
|
<View style={styles.resultCard}>
|
||||||
<View style={[styles.badgePill, { backgroundColor: resultStyle.bg }]}>
|
<View
|
||||||
|
style={[styles.badgePill, { backgroundColor: resultStyle.bg }]}
|
||||||
|
>
|
||||||
<ResultIcon size={16} color={resultStyle.text} />
|
<ResultIcon size={16} color={resultStyle.text} />
|
||||||
<Text style={[styles.badgeText, { color: resultStyle.text }]}>
|
<Text style={[styles.badgeText, { color: resultStyle.text }]}>
|
||||||
{t(resultStyle.labelKey)}
|
{t(resultStyle.labelKey)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.confidenceRow}>
|
<View style={styles.confidenceRow}>
|
||||||
<Text style={styles.confidenceLabel}>{t('myPlants.detail.confidence')}</Text>
|
<Text style={styles.confidenceLabel}>
|
||||||
<Text style={[styles.confidenceValue, { color: resultStyle.text }]}>
|
{t("myPlants.detail.confidence")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.confidenceValue, { color: resultStyle.text }]}
|
||||||
|
>
|
||||||
{detection.confidence}%
|
{detection.confidence}%
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.barTrack}>
|
<View style={styles.barTrack}>
|
||||||
<Animated.View style={[styles.barFill, { backgroundColor: resultStyle.text }, barAnim]} />
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.barFill,
|
||||||
|
{ backgroundColor: resultStyle.text },
|
||||||
|
barAnim,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Cepage Card */}
|
{/* Cepage Card */}
|
||||||
{detection.result === 'vine' && cepage && (
|
{detection.result === "vine" && cepage && (
|
||||||
<View style={styles.card}>
|
<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.cepageName}>{cepage.name.fr}</Text>
|
||||||
<Text style={styles.cepageNameEn}>{cepage.name.en}</Text>
|
<Text style={styles.cepageNameEn}>{cepage.name.en}</Text>
|
||||||
<View style={styles.tagsRow}>
|
<View style={styles.tagsRow}>
|
||||||
<View style={[styles.tag, { backgroundColor: 'rgba(45,106,79,0.1)' }]}>
|
<View
|
||||||
<Text style={[styles.tagText, { color: '#2D6A4F' }]}>
|
style={[
|
||||||
{cepage.color === 'rouge' ? '🍷 Rouge' : cepage.color === 'blanc' ? '🥂 Blanc' : '🌸 Rosé'}
|
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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{cepage.regions.slice(0, 2).map((r) => (
|
{cepage.regions.slice(0, 2).map((r) => (
|
||||||
<View key={r} style={[styles.tag, { backgroundColor: '#F0F0F0' }]}>
|
<View
|
||||||
<Text style={[styles.tagText, { color: '#444' }]}>{r}</Text>
|
key={r}
|
||||||
|
style={[styles.tag, { backgroundColor: "#F0F0F0" }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tagText, { color: "#444" }]}>{r}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -277,15 +356,21 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<Calendar size={18} color={colors.primary[700]} />
|
<Calendar size={18} color={colors.primary[700]} />
|
||||||
<View style={styles.metaContent}>
|
<View style={styles.metaContent}>
|
||||||
<Text style={styles.metaLabel}>{t('myPlants.detail.scannedOn')}</Text>
|
<Text style={styles.metaLabel}>
|
||||||
<Text style={styles.metaValue}>{formatDateLong(scan.createdAt, i18n.language)}</Text>
|
{t("myPlants.detail.scannedOn")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{formatDateLong(scan.createdAt, i18n.language)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.metaDivider} />
|
<View style={styles.metaDivider} />
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<Award size={18} color={colors.primary[700]} />
|
<Award size={18} color={colors.primary[700]} />
|
||||||
<View style={styles.metaContent}>
|
<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>
|
<Text style={styles.metaValue}>+{scan.xpEarned} XP</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -295,27 +380,38 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<View style={styles.metaRow}>
|
<View style={styles.metaRow}>
|
||||||
<MapPin size={18} color={colors.primary[700]} />
|
<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>
|
</View>
|
||||||
{scan.location ? (
|
{scan.location ? (
|
||||||
<View style={{ marginTop: 8 }}>
|
<View style={{ marginTop: 8 }}>
|
||||||
<Text style={styles.locationName}>
|
<Text style={styles.locationName}>
|
||||||
{scan.location.placeName ?? 'Lieu inconnu'}
|
{scan.location.placeName ?? "Lieu inconnu"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.locationCoords}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ marginTop: 8 }}>
|
<View style={{ marginTop: 8 }}>
|
||||||
<Text style={styles.noLocation}>{t('myPlants.detail.noLocation')}</Text>
|
<Text style={styles.noLocation}>
|
||||||
|
{t("myPlants.detail.noLocation")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.addLocationBtn}
|
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}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<MapPin size={14} color={colors.primary[700]} />
|
<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>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -325,13 +421,23 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
|
|
||||||
{/* ── Bottom Action Bar ── */}
|
{/* ── Bottom Action Bar ── */}
|
||||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
|
<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" />
|
<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>
|
||||||
<TouchableOpacity style={styles.deleteBottomBtn} onPress={handleDelete} activeOpacity={0.7}>
|
<TouchableOpacity
|
||||||
|
style={styles.deleteBottomBtn}
|
||||||
|
onPress={handleDelete}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
<Trash2 size={18} color="#C62828" />
|
<Trash2 size={18} color="#C62828" />
|
||||||
<Text style={styles.deleteBtnText}>{t('myPlants.detail.delete')}</Text>
|
<Text style={styles.deleteBtnText}>
|
||||||
|
{t("myPlants.detail.delete")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -339,73 +445,209 @@ export default function ScanDetailScreen({ route }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: '#F8F9FB' },
|
root: { flex: 1, backgroundColor: "#F8F9FB" },
|
||||||
centered: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#F8F9FB', gap: 16 },
|
centered: {
|
||||||
errorText: { fontSize: 16, fontWeight: '600', color: '#1A1A1A' },
|
flex: 1,
|
||||||
errorBtn: { paddingHorizontal: 24, paddingVertical: 12, backgroundColor: colors.primary[700], borderRadius: 100 },
|
alignItems: "center",
|
||||||
errorBtnText: { fontSize: 15, fontWeight: '600', color: '#FFFFFF' },
|
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
|
// Hero
|
||||||
heroContainer: { height: 380, position: 'relative', backgroundColor: '#E0E0E0', borderBottomLeftRadius: 32, borderBottomRightRadius: 32, overflow: 'hidden' },
|
heroContainer: {
|
||||||
gradientTop: { position: 'absolute', top: 0, left: 0, right: 0, height: 100 },
|
height: 380,
|
||||||
gradientBottom: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 100 },
|
position: "relative",
|
||||||
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 },
|
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
|
// Floating buttons
|
||||||
floatingBtn: {
|
floatingBtn: {
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
backgroundColor: "rgba(255,255,255,0.9)",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
...Platform.select({
|
...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 },
|
android: { elevation: 3 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Result card
|
// Result card
|
||||||
resultCard: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 20, marginHorizontal: 16, marginTop: -24, borderWidth: 1, borderColor: '#F0F0F0', gap: 12 },
|
resultCard: {
|
||||||
badgePill: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', gap: 6, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20 },
|
backgroundColor: "#FFFFFF",
|
||||||
badgeText: { fontSize: 13, fontWeight: '600' },
|
borderRadius: 24,
|
||||||
confidenceRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
padding: 20,
|
||||||
confidenceLabel: { fontSize: 14, color: '#8E8E93' },
|
marginHorizontal: 16,
|
||||||
confidenceValue: { fontSize: 20, fontWeight: '700' },
|
marginTop: -24,
|
||||||
barTrack: { height: 8, borderRadius: 4, backgroundColor: '#F0F0F0', overflow: 'hidden' },
|
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 },
|
barFill: { height: 8, borderRadius: 4 },
|
||||||
|
|
||||||
// Generic card
|
// Generic card
|
||||||
card: { backgroundColor: '#FFFFFF', borderRadius: 24, padding: 20, marginHorizontal: 16, marginTop: 12, borderWidth: 1, borderColor: '#F0F0F0' },
|
card: {
|
||||||
cardTitle: { fontSize: 16, fontWeight: '600', color: '#1A1A1A' },
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#F0F0F0",
|
||||||
|
},
|
||||||
|
cardTitle: { fontSize: 16, fontWeight: "600", color: "#1A1A1A" },
|
||||||
|
|
||||||
// Cepage
|
// Cepage
|
||||||
cepageName: { fontSize: 22, fontWeight: '700', color: '#1A1A1A', marginTop: 8 },
|
cepageName: {
|
||||||
cepageNameEn: { fontSize: 14, color: '#8E8E93', marginTop: 2 },
|
fontSize: 22,
|
||||||
tagsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 12 },
|
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 },
|
tag: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16 },
|
||||||
tagText: { fontSize: 13, fontWeight: '600' },
|
tagText: { fontSize: 13, fontWeight: "600" },
|
||||||
cepageDesc: { fontSize: 14, lineHeight: 22, color: '#444444', marginTop: 12 },
|
cepageDesc: { fontSize: 14, lineHeight: 22, color: "#444444", marginTop: 12 },
|
||||||
|
|
||||||
// Meta
|
// Meta
|
||||||
metaRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
metaRow: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||||
metaContent: { flex: 1, gap: 2 },
|
metaContent: { flex: 1, gap: 2 },
|
||||||
metaLabel: { fontSize: 12, color: '#8E8E93' },
|
metaLabel: { fontSize: 12, color: "#8E8E93" },
|
||||||
metaValue: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
|
metaValue: { fontSize: 15, fontWeight: "600", color: "#1A1A1A" },
|
||||||
metaDivider: { height: 1, backgroundColor: '#F0F0F0', marginVertical: 14 },
|
metaDivider: { height: 1, backgroundColor: "#F0F0F0", marginVertical: 14 },
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
locationName: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
|
locationName: { fontSize: 15, fontWeight: "600", color: "#1A1A1A" },
|
||||||
locationCoords: { fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', color: '#8E8E93', marginTop: 4 },
|
locationCoords: {
|
||||||
noLocation: { fontSize: 14, color: '#8E8E93' },
|
fontSize: 12,
|
||||||
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' },
|
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||||
addLocationText: { fontSize: 14, fontWeight: '600', color: colors.primary[700] },
|
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
|
// 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' },
|
bottomBar: {
|
||||||
shareBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, backgroundColor: '#F0F0F0', borderRadius: 16, paddingVertical: 14 },
|
position: "absolute",
|
||||||
shareBtnText: { fontSize: 15, fontWeight: '600', color: '#1A1A1A' },
|
bottom: 0,
|
||||||
deleteBottomBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, backgroundColor: '#FFEBEE', borderRadius: 16, paddingVertical: 14 },
|
left: 0,
|
||||||
deleteBtnText: { fontSize: 15, fontWeight: '600', color: '#C62828' },
|
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 {
|
import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Platform,
|
|
||||||
Alert,
|
Alert,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
Switch,
|
||||||
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import i18n from "@/i18n";
|
import { ChevronRight } from "lucide-react-native";
|
||||||
|
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { colors } from "@/theme/colors";
|
import { colors } from "@/theme/colors";
|
||||||
import { useGameProgress } from "@/hooks/useGameProgress";
|
import { useGameProgress } from "@/hooks/useGameProgress";
|
||||||
import { useHistory } from "@/hooks/useHistory";
|
import { useHistory } from "@/hooks/useHistory";
|
||||||
|
import { storage } from "@/services/storage";
|
||||||
|
import type { RootStackParamList } from "@/types/navigation";
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|
@ -26,14 +33,34 @@ interface MenuItem {
|
||||||
rightColor?: string;
|
rightColor?: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
toggleValue?: boolean;
|
||||||
|
onToggle?: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation<Nav>();
|
||||||
const { resetProgress } = useGameProgress();
|
const { progress, resetProgress } = useGameProgress();
|
||||||
const { clearHistory, seedTestData } = useHistory();
|
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() {
|
async function handleSeed() {
|
||||||
await seedTestData();
|
await seedTestData();
|
||||||
toast.success(t("settings.seedDone"));
|
toast.success(t("settings.seedDone"));
|
||||||
|
|
@ -59,10 +86,6 @@ export default function SettingsScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const generalItems: MenuItem[] = [
|
const generalItems: MenuItem[] = [
|
||||||
{
|
|
||||||
icon: "person-outline",
|
|
||||||
label: t("settings.editProfile"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: "globe-outline",
|
icon: "globe-outline",
|
||||||
label: t("profile.language"),
|
label: t("profile.language"),
|
||||||
|
|
@ -71,7 +94,9 @@ export default function SettingsScreen() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "notifications-outline",
|
icon: "notifications-outline",
|
||||||
label: t("common.notifications"),
|
label: t("settings.notifications.label"),
|
||||||
|
toggleValue: notificationsEnabled,
|
||||||
|
onToggle: handleNotificationsToggle,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "shield-outline",
|
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[] = [
|
const appItems: MenuItem[] = [
|
||||||
|
/*
|
||||||
{
|
{
|
||||||
icon: "diamond-outline",
|
icon: "diamond-outline",
|
||||||
label: t("settings.premiumStatus"),
|
label: t("settings.premiumStatus"),
|
||||||
|
|
@ -90,6 +117,7 @@ export default function SettingsScreen() {
|
||||||
icon: "color-palette-outline",
|
icon: "color-palette-outline",
|
||||||
label: t("settings.appearance"),
|
label: t("settings.appearance"),
|
||||||
},
|
},
|
||||||
|
*/
|
||||||
{
|
{
|
||||||
icon: "help-circle-outline",
|
icon: "help-circle-outline",
|
||||||
label: t("settings.helpCenter"),
|
label: t("settings.helpCenter"),
|
||||||
|
|
@ -121,88 +149,136 @@ export default function SettingsScreen() {
|
||||||
|
|
||||||
const renderMenuGroup = (items: MenuItem[]) => (
|
const renderMenuGroup = (items: MenuItem[]) => (
|
||||||
<View style={styles.menuCard}>
|
<View style={styles.menuCard}>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => {
|
||||||
<View key={item.label}>
|
const isToggle = typeof item.onToggle === "function";
|
||||||
{index > 0 && <View style={styles.divider} />}
|
const Wrapper = isToggle ? View : TouchableOpacity;
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.menuRow}
|
|
||||||
activeOpacity={0.5}
|
|
||||||
onPress={item.onPress}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.iconBox,
|
|
||||||
{ backgroundColor: item.danger ? "#FEF2F2" : "#F8F9FA" },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={item.icon as any}
|
|
||||||
size={20}
|
|
||||||
color={item.danger ? "#EF4444" : "#636E72"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text
|
return (
|
||||||
style={[styles.menuLabel, item.danger && styles.menuLabelDanger]}
|
<View key={item.label}>
|
||||||
|
{index > 0 && <View style={styles.divider} />}
|
||||||
|
<Wrapper
|
||||||
|
style={styles.menuRow}
|
||||||
|
{...(isToggle
|
||||||
|
? {}
|
||||||
|
: { activeOpacity: 0.5, onPress: item.onPress })}
|
||||||
>
|
>
|
||||||
{item.label}
|
<View
|
||||||
</Text>
|
style={[
|
||||||
|
styles.iconBox,
|
||||||
|
{ backgroundColor: item.danger ? "#FEF2F2" : "#F8F9FA" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={item.icon as keyof typeof Ionicons.glyphMap}
|
||||||
|
size={20}
|
||||||
|
color={item.danger ? "#EF4444" : "#636E72"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.menuRight}>
|
<Text
|
||||||
{item.rightText && (
|
style={[
|
||||||
<Text
|
styles.menuLabel,
|
||||||
style={[
|
item.danger && styles.menuLabelDanger,
|
||||||
styles.menuRightText,
|
]}
|
||||||
item.rightColor && { color: item.rightColor },
|
>
|
||||||
]}
|
{item.label}
|
||||||
>
|
</Text>
|
||||||
{item.rightText}
|
|
||||||
</Text>
|
<View style={styles.menuRight}>
|
||||||
)}
|
{isToggle ? (
|
||||||
<Ionicons name="chevron-forward" size={14} color="#D1D1D6" />
|
<Switch
|
||||||
</View>
|
value={item.toggleValue ?? false}
|
||||||
</TouchableOpacity>
|
onValueChange={item.onToggle}
|
||||||
</View>
|
trackColor={{
|
||||||
))}
|
false: colors.neutral[300],
|
||||||
|
true: colors.primary[700],
|
||||||
|
}}
|
||||||
|
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
|
||||||
|
ios_backgroundColor={colors.neutral[300]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{item.rightText && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.menuRightText,
|
||||||
|
item.rightColor && { color: item.rightColor },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.rightText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={16} color="#D1D1D6" strokeWidth={2} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Wrapper>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const userLevel = progress?.level ?? 1;
|
||||||
|
const userXp = progress?.xp ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={["top"]}>
|
<SafeAreaView style={styles.safe} edges={["top"]}>
|
||||||
{/* Header épuré style Bumble/Apple */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
style={styles.backBtn}
|
style={styles.backBtn}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={24} color="#1A1A1A" />
|
<Ionicons name="chevron-back" size={22} color="#1A1A1A" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.headerTitle}>{t("common.settings")}</Text>
|
<Text style={styles.headerTitle}>{t("common.settings")}</Text>
|
||||||
<View style={{ width: 44 }} />
|
<View style={{ width: 40 }} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.scrollContent}
|
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>
|
<Text style={styles.sectionLabel}>{t("settings.general")}</Text>
|
||||||
{renderMenuGroup(generalItems)}
|
{renderMenuGroup(generalItems)}
|
||||||
|
|
||||||
<Text style={styles.sectionLabel}>{t("settings.app")}</Text>
|
<Text style={styles.sectionLabel}>{t("settings.app")}</Text>
|
||||||
{renderMenuGroup(appItems)}
|
{renderMenuGroup(appItems)}
|
||||||
|
|
||||||
{/* Banner Referral plus "Flat" et moderne */}
|
{/* Banner Referral désactivé — gardé commenté pour usage futur */}
|
||||||
|
{/*
|
||||||
<TouchableOpacity style={styles.referCard} activeOpacity={0.9}>
|
<TouchableOpacity style={styles.referCard} activeOpacity={0.9}>
|
||||||
<View style={styles.referContent}>
|
<View style={styles.referContent}>
|
||||||
<Text style={styles.referTitle}>Refer a friend</Text>
|
<Text style={styles.referTitle}>Refer a friend</Text>
|
||||||
<Text style={styles.referBody}>
|
<Text style={styles.referBody}>Get $50 per successful referral</Text>
|
||||||
Get $50 per successful referral
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.referIconWrap}>
|
<View style={styles.referIconWrap}>
|
||||||
<Ionicons name="gift" size={28} color="#FFFFFF" />
|
<Ionicons name="gift" size={28} color="#FFFFFF" />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
*/}
|
||||||
|
|
||||||
{devItems.length > 0 && (
|
{devItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -223,48 +299,88 @@ export default function SettingsScreen() {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
safe: {
|
safe: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#F8F9FB", // Gris encore plus clair/bleuté
|
backgroundColor: "#FAFAFA",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
backgroundColor: "transparent", // Pas de démarcation brutale
|
backgroundColor: "transparent",
|
||||||
},
|
},
|
||||||
backBtn: {
|
backBtn: {
|
||||||
width: 44,
|
width: 40,
|
||||||
height: 44,
|
height: 40,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#F0F0F0",
|
borderColor: "#F0F0F0",
|
||||||
},
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
fontSize: 18,
|
fontSize: 17,
|
||||||
fontWeight: "600", // Pas de Bold 900 ici, juste Medium/SemiBold
|
fontWeight: "600",
|
||||||
color: "#1A1A1A",
|
color: "#1A1A1A",
|
||||||
letterSpacing: -0.4,
|
letterSpacing: -0.4,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingHorizontal: 20,
|
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: {
|
sectionLabel: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: "500",
|
fontWeight: "600",
|
||||||
color: "#A0A0A0",
|
color: "#A0A0A0",
|
||||||
marginBottom: 12,
|
marginBottom: 10,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1.2,
|
||||||
},
|
},
|
||||||
menuCard: {
|
menuCard: {
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
borderRadius: 24,
|
borderRadius: 20,
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#F2F2F2",
|
borderColor: "#F2F2F2",
|
||||||
|
|
@ -272,8 +388,8 @@ const styles = StyleSheet.create({
|
||||||
menuRow: {
|
menuRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingVertical: 12,
|
paddingVertical: 13,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 14,
|
||||||
},
|
},
|
||||||
iconBox: {
|
iconBox: {
|
||||||
width: 36,
|
width: 36,
|
||||||
|
|
@ -286,26 +402,26 @@ const styles = StyleSheet.create({
|
||||||
menuLabel: {
|
menuLabel: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: "400", // On reste sur du Regular
|
fontWeight: "500",
|
||||||
color: "#2D3436",
|
color: "#2D3436",
|
||||||
},
|
},
|
||||||
menuLabelDanger: {
|
menuLabelDanger: { color: "#EF4444" },
|
||||||
color: "#EF4444",
|
|
||||||
},
|
|
||||||
menuRight: {
|
menuRight: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 6,
|
||||||
},
|
},
|
||||||
menuRightText: {
|
menuRightText: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
color: "#B2B2B2",
|
color: "#B2B2B2",
|
||||||
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
divider: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: "#F8F9FA",
|
backgroundColor: "#F5F5F5",
|
||||||
marginLeft: 60, // Aligné avec le texte, pas l'icône
|
marginLeft: 60,
|
||||||
},
|
},
|
||||||
|
/* Styles du Referral card — gardés au cas où on réactive */
|
||||||
referCard: {
|
referCard: {
|
||||||
backgroundColor: "#F97316",
|
backgroundColor: "#F97316",
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
|
|
@ -314,19 +430,9 @@ const styles = StyleSheet.create({
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
referContent: {
|
referContent: { flex: 1 },
|
||||||
flex: 1,
|
referTitle: { fontSize: 17, fontWeight: "700", color: "#FFFFFF" },
|
||||||
},
|
referBody: { fontSize: 13, color: "rgba(255,255,255,0.7)", marginTop: 2 },
|
||||||
referTitle: {
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: "700",
|
|
||||||
color: "#FFFFFF",
|
|
||||||
},
|
|
||||||
referBody: {
|
|
||||||
fontSize: 13,
|
|
||||||
color: "rgba(255,255,255,0.7)",
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
referIconWrap: {
|
referIconWrap: {
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
|
@ -338,7 +444,8 @@ const styles = StyleSheet.create({
|
||||||
versionText: {
|
versionText: {
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "#D1D1D6",
|
color: "#C7C7CC",
|
||||||
marginTop: 10,
|
marginTop: 10,
|
||||||
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const DISEASE_SLUG_MAP: Record<string, string> = {
|
||||||
botrytis: "botrytis",
|
botrytis: "botrytis",
|
||||||
"flavescence-doree": "flavescence",
|
"flavescence-doree": "flavescence",
|
||||||
"chlorose-ferrique": "chlorose",
|
"chlorose-ferrique": "chlorose",
|
||||||
|
"leaf-blight": "leafBlight",
|
||||||
};
|
};
|
||||||
|
|
||||||
const GUIDE_SLUG_MAP: Record<string, string> = {
|
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',
|
SCAN_HISTORY: '@vineye:scan_history',
|
||||||
LANGUAGE: '@vineye:language',
|
LANGUAGE: '@vineye:language',
|
||||||
LOCATION_PERMISSION_ASKED: '@vineye:location-permission-asked',
|
LOCATION_PERMISSION_ASKED: '@vineye:location-permission-asked',
|
||||||
|
USER_PROFILE: '@vineye:user_profile',
|
||||||
|
NOTIFICATIONS_ENABLED: '@vineye:notifications_enabled',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
async function get<T>(key: string): Promise<T | null> {
|
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, DiseaseClass, ClassProbability } from '@/types/detection';
|
||||||
import type { Detection, DetectionResult } from '@/types/detection';
|
import { ML_CLASSES, CLASS_TO_SLUG, CONFIDENCE_THRESHOLD_VINE, CONFIDENCE_THRESHOLD_UNCERTAIN } from '@/services/ml/classes';
|
||||||
import { cepages } from '@/utils/cepages';
|
import { preprocessImage, argmax, softmax } from '@/services/ml/preprocessing';
|
||||||
|
|
||||||
const WEIGHTED_RESULTS: { result: DetectionResult; weight: number }[] = [
|
type FastTfliteModel = {
|
||||||
{ result: 'vine', weight: 70 },
|
runSync: (inputs: (Float32Array | Int32Array | Uint8Array)[]) => (Float32Array | Int32Array | Uint8Array)[];
|
||||||
{ result: 'uncertain', weight: 20 },
|
};
|
||||||
{ result: 'not_vine', weight: 10 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function weightedRandom(): DetectionResult {
|
let cachedModel: FastTfliteModel | null = null;
|
||||||
const total = WEIGHTED_RESULTS.reduce((sum, r) => sum + r.weight, 0);
|
let modelLoadFailed = false;
|
||||||
let rand = Math.random() * total;
|
|
||||||
for (const r of WEIGHTED_RESULTS) {
|
async function getModel(): Promise<FastTfliteModel | null> {
|
||||||
rand -= r.weight;
|
if (cachedModel) return cachedModel;
|
||||||
if (rand <= 0) return r.result;
|
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> {
|
export async function loadModel(): Promise<boolean> {
|
||||||
// Simule le chargement du modèle (1-2 secondes)
|
const m = await getModel();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1200 + Math.random() * 800));
|
return m !== null;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remplacer par le vrai modèle TFLite
|
|
||||||
export async function runInference(imageUri?: string): Promise<Detection> {
|
export async function runInference(imageUri?: string): Promise<Detection> {
|
||||||
// Simule l'inférence (200-600ms)
|
const timestamp = Date.now();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200 + Math.random() * 400));
|
|
||||||
|
|
||||||
const result = weightedRandom();
|
if (!imageUri) {
|
||||||
const confidence = result === 'vine'
|
return mockDetection(timestamp);
|
||||||
? 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%
|
|
||||||
|
|
||||||
const cepageId =
|
const model = await getModel();
|
||||||
result === 'vine'
|
if (!model) {
|
||||||
? cepages[Math.floor(Math.random() * cepages.length)].id
|
return mockDetection(timestamp, imageUri);
|
||||||
: undefined;
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
result,
|
result,
|
||||||
confidence,
|
confidence,
|
||||||
cepageId,
|
diseaseClass: topClass,
|
||||||
timestamp: Date.now(),
|
diseaseSlug: CLASS_TO_SLUG[topClass] ?? undefined,
|
||||||
|
allProbabilities,
|
||||||
|
timestamp,
|
||||||
imageUri,
|
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 },
|
{ 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",
|
slug: "chlorose-ferrique",
|
||||||
name: "Chlorose ferrique", nameEn: "Iron Chlorosis", scientificName: "",
|
name: "Chlorose ferrique", nameEn: "Iron Chlorosis", scientificName: "",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue