maj 0.99 acc

This commit is contained in:
jhodi.avizara 2026-04-14 18:38:32 +02:00
commit 4faba481dc
196 changed files with 32043 additions and 1 deletions

10
.claude/memory/MEMORY.md Normal file
View file

@ -0,0 +1,10 @@
# MEMORY — Projet
## Vue d'ensemble
<!-- Remplir au fil des sessions -->
## Décisions techniques importantes
<!-- Patterns, choix d'architecture, gotchas découverts -->
## État actuel
<!-- Ce qui est fait, ce qui reste à faire -->

View file

@ -0,0 +1,29 @@
# Index des fonctionnalités — ce projet
> Mis à jour automatiquement. Compléter les README.md de chaque feature après implémentation.
> **Règle** : Avant de travailler sur une feature → lire son README. Après → le mettre à jour.
## Fonctionnalités détectées
| Feature | Documentation | Status | Dernière MAJ |
|---------|--------------|--------|-------------|
## Fichiers critiques globaux
| Fichier | Rôle |
|---------|------|
| `CLAUDE.md` | Contexte projet chargé automatiquement |
| `.claude/notes/_features.md` | Cet index |
| `.claude/rules/` | Rules spécifiques au projet |
## Stack détectée
## Convention de mise à jour
Après chaque feature implémentée :
1. Ouvrir `.claude/notes/<feature>/README.md`
2. Compléter : description, fichiers clés, endpoints, gotchas
3. Mettre à jour le status dans cet index (🟡 → ✅)

35
.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
*.egg
# Models (too large for git)
*.keras
*.tflite
*.h5
# Environment
.env
.env.*
# OS
.DS_Store
Thumbs.db
# VinEye (handled by VinEye/.gitignore)
VinEye/node_modules/
VinEye/.expo/
VinEye/dist/
VinEye/ios/
VinEye/android/
# Virtual environment
venv/
# Dependencies
node_modules/
# vineye-admin
vineye-admin/node_modules/
vineye-admin/.next/

65
AGENTS.md Normal file
View file

@ -0,0 +1,65 @@
# Repository Guidelines
Monorepo with two components: a **Python/TensorFlow CNN** for grapevine disease detection and a **React Native (Expo) mobile app** (VinEye) that runs the model on-device via TFLite.
## Project Structure & Module Organization
```
venv/src/ # Python ML pipeline (training, evaluation, attribution)
venv/models/ # Trained model artifacts (.keras, .tflite)
docs/images/ # Dataset & results visualizations
VinEye/ # Expo React Native mobile app
src/screens/ # 6 screens: Splash, Home, Scanner, Result, History, Profile
src/components/ # UI grouped by feature (gamification/, scanner/, history/, ui/)
src/services/ # TFLite inference, AsyncStorage, haptics
src/hooks/ # useDetection, useGameProgress, useHistory
src/navigation/ # React Navigation v7 (BottomTabs + NativeStack)
src/i18n/ # FR + EN translations (i18next)
src/theme/ # Design tokens (primary #2D6A4F, accent #7C3AED)
```
The ML model currently uses a mock TFLite detector in the mobile app (weighted random: 70% vine / 20% uncertain / 10% not_vine). The CNN trains on 9027 images (256x256) across 4 classes: Black Rot, ESCA, Healthy, Leaf Blight.
## Build, Test, and Development Commands
### VinEye (Mobile)
```bash
cd VinEye
pnpm install # Install dependencies (pnpm only, never npm/yarn)
pnpm start # Start Expo dev server
pnpm android # Run on Android
pnpm ios # Run on iOS
pnpm web # Run on web
```
### Python ML Pipeline
```bash
cd venv/src
python data_split.py # Split raw data into train/val/test (80/10/10)
python data_explore.py # EDA: class distribution, sample visualization
python model_train.py # Train CNN, exports .keras + .tflite to venv/models/
python evaluate_model.py # Accuracy/loss curves, confusion matrix, top-k predictions
python gradient.py # Integrated gradients attribution masks
```
Scripts must be run from `venv/src/` — paths are derived relative to that directory.
## Coding Style & Naming Conventions
**TypeScript (VinEye):**
- Strict mode enabled, path alias `@/*` maps to `src/*`
- Max 300 lines per file
- NativeWind (TailwindCSS) for styling — no inline styles
- `useEffect` must be imported from `react`, never from `react-native-reanimated`
- React Navigation v7 only (Expo Router is forbidden)
- RN-native UI components only (no web-based component libraries)
**Python:** TensorFlow/Keras Sequential API, scripts use `from module import *` pattern.
No linter or formatter configs are enforced.
## Commit Guidelines
Commit messages are informal, descriptive, lowercase. No conventional commits format is enforced. Examples from history: `add VinEye frontend app + fix hardcoded paths + gitignore`, `update`, `maj`.

44
CLAUDE.md Normal file
View file

@ -0,0 +1,44 @@
# Projet
> Généré par project-init. Compléter avec les spécificités du projet.
> **Limite : 200 lignes.** Aller à l'essentiel — les détails sont dans `.claude/notes/`.
---
## Stack
| Couche | Technologies |
|--------|-------------|
| Frontend | — |
| Backend | — |
| ORM | — |
| Auth | JWT (access + refresh tokens) |
---
## Architecture
```
docs/ venv/ VinEye/
```
---
## Fonctionnalités clés
> Voir `.claude/notes/_features.md` pour le détail de chaque feature.
---
## Conventions
- Package manager : **pnpm**
- Composants : shadcn/ui → Magic UI → custom (dans cet ordre)
- Server Components par défaut, `"use client"` uniquement si nécessaire
- Max 300 lignes par fichier
---
## À compléter
- [ ] Design tokens / palette couleurs
- [ ] Variables d'environnement nécessaires
- [ ] URLs de déploiement
- [ ] Spécificités métier importantes

View file

@ -12,7 +12,11 @@ The dataset originates from [Kaggle](https://kaggle.com/datasets/rm1000/grape-di
- **ESCA (Net Blight)** - **ESCA (Net Blight)**
- **Leaf Blight** - **Leaf Blight**
<<<<<<< HEAD
The dataset is **well-balanced** with a slight overrepresentation of **ESCA** and **Black Rot**. All images are in **.jpeg format** with dimensions **256x256 pixels**. The dataset is **well-balanced** with a slight overrepresentation of **ESCA** and **Black Rot**. All images are in **.jpeg format** with dimensions **256x256 pixels**.
=======
## Model Structure
>>>>>>> fe70005a86f7095d5e60f104bd6a3e22f50c2dac
![Dataset Overview](./docs/images/dataset_overview.png) <br> ![Dataset Overview](./docs/images/dataset_overview.png) <br>

View file

@ -0,0 +1,23 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "VinEye — Expo (all platforms)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["start"],
"port": 8081
},
{
"name": "VinEye — Android",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["android"],
"port": 8081
},
{
"name": "VinEye — Web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["web"],
"port": 8081
}
]
}

View file

@ -0,0 +1,10 @@
# MEMORY — vineye
## Vue d'ensemble
<!-- Remplir au fil des sessions -->
## Décisions techniques importantes
<!-- Patterns, choix d'architecture, gotchas découverts -->
## État actuel
<!-- Ce qui est fait, ce qui reste à faire -->

View file

@ -0,0 +1,29 @@
# Index des fonctionnalités — vineye
> Mis à jour automatiquement. Compléter les README.md de chaque feature après implémentation.
> **Règle** : Avant de travailler sur une feature → lire son README. Après → le mettre à jour.
## Fonctionnalités détectées
| Feature | Documentation | Status | Dernière MAJ |
|---------|--------------|--------|-------------|
## Fichiers critiques globaux
| Fichier | Rôle |
|---------|------|
| `CLAUDE.md` | Contexte projet chargé automatiquement |
| `.claude/notes/_features.md` | Cet index |
| `.claude/rules/` | Rules spécifiques au projet |
## Stack détectée
## Convention de mise à jour
Après chaque feature implémentée :
1. Ouvrir `.claude/notes/<feature>/README.md`
2. Compléter : description, fichiers clés, endpoints, gotchas
3. Mettre à jour le status dans cet index (🟡 → ✅)

41
VinEye/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

15
VinEye/App.tsx Normal file
View file

@ -0,0 +1,15 @@
import './global.css';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { PortalHost } from '@rn-primitives/portal';
import RootNavigator from '@/navigation/RootNavigator';
export default function App() {
return (
<SafeAreaProvider>
<StatusBar style="dark" />
<RootNavigator />
<PortalHost />
</SafeAreaProvider>
);
}

190
VinEye/CLAUDE.md Normal file
View file

@ -0,0 +1,190 @@
# VinEye
Application mobile React Native (Expo) de detection de maladies de la vigne.
Cible des amateurs de vin/jardinage. Scan par camera, identification de maladies, bibliotheque de cepages, gamification.
---
## Stack
| Couche | Technologies |
|--------|-------------|
| Framework | React Native + Expo SDK 54 (bare workflow) |
| Navigation | React Navigation v7 (NativeStack + BottomTabs) |
| Langage | TypeScript strict |
| Styling | **NativeWind v4** (Tailwind) prioritaire, StyleSheet pour ombres/gradients |
| Icones | **lucide-react-native** (bottom bar) + **Ionicons** (reste de l'app) |
| Animations | React Native Reanimated v4 |
| IA | TFLite mock (weighted random) |
| Persistance | AsyncStorage |
| i18n | i18next + react-i18next (FR + EN) |
| Camera | expo-camera |
| Haptics | expo-haptics |
| Package manager | **pnpm** |
---
## Architecture
```
VinEye/
├── App.tsx
├── src/
│ ├── components/
│ │ ├── ui/ # Text, Button, Card, Badge, ProgressCircle
│ │ ├── home/ # SearchHeader, SearchSection, HomeCta, FrequentDiseases,
│ │ │ # SeasonAlert, PracticalGuides, statssection, gamificationstat
│ │ │ └── components/ # homeheader (SectionHeader)
│ │ ├── scanner/ # DetectionFrame, CameraOverlay, ConfidenceMeter
│ │ ├── gamification/ # XPBar, BadgeCard, ProgressRing, LevelIndicator
│ │ └── history/ # ScanCard, ScanList
│ ├── data/ # diseases.ts (7 maladies), guides.ts (3 guides)
│ ├── hooks/ # useDetection, useGameProgress, useHistory
│ ├── i18n/ # fr.json, en.json, index.ts
│ ├── navigation/ # RootNavigator, BottomTabNavigator, linking.ts
│ ├── screens/ # 11 ecrans (voir Navigation)
│ ├── services/ # tflite/model.ts, storage.ts, haptics.ts
│ ├── theme/ # colors.ts, typography.ts, spacing.ts
│ ├── types/ # detection.ts, gamification.ts, navigation.ts
│ └── utils/ # cepages.ts, achievements.ts
```
---
## Navigation
```
RootNavigator (NativeStack)
├── Splash → SplashScreen (auto → Main apres 2.8s)
├── Main → BottomTabNavigator
│ ├── Home → HomeScreen
│ ├── Guides → GuidesScreen (tabs: Maladies / Guides Pratiques)
│ ├── Scanner → ScannerScreen (FAB central vert sureleve)
│ ├── Library → LibraryScreen (grille plantes scannees)
│ └── Map → MapScreen (placeholder)
├── Result (modal) → ResultScreen (slide_from_bottom)
├── Notifications → NotificationsScreen (slide_from_right)
├── Profile → ProfileScreen (slide_from_right)
├── Settings → SettingsScreen (slide_from_right)
├── Guides → GuidesScreen (aussi accessible via stack)
└── Library → LibraryScreen (aussi accessible via stack)
```
**Bottom Tab Bar** : Home | Guides | Scanner (FAB) | Library | Map
- Icones : lucide-react-native (House, BookOpen, ScanLine, Leaf, Map)
- FAB Scanner : cercle vert primary[800], 56px, sureleve -28px
- Haptic feedback sur chaque onglet
---
## Ecrans
| Ecran | Fichier | Description |
|-------|---------|-------------|
| Home | `screens/HomeScreen.tsx` | Header VinEye + search + CTA scan + maladies carousel + alerte saison + guides |
| Guides | `screens/GuidesScreen.tsx` | Segmented control (Maladies/Guides) + listes de cartes |
| Scanner | `screens/ScannerScreen.tsx` | Camera + detection IA |
| Library | `screens/LibraryScreen.tsx` | Grille 2 colonnes plantes scannees + favoris |
| Map | `screens/MapScreen.tsx` | Placeholder — a implementer |
| Result | `screens/ResultScreen.tsx` | Resultat scan + cepage + XP |
| Notifications | `screens/NotificationsScreen.tsx` | 3 types (alerte/conseil/systeme) + mock data |
| Profile | `screens/ProfileScreen.tsx` | Hero header vert + avatar + info card + stats Bento |
| Settings | `screens/SettingsScreen.tsx` | Menus groupes + referral card orange + reset |
| History | `screens/HistoryScreen.tsx` | Legacy — remplace par Notifications |
| Splash | `screens/SplashScreen.tsx` | Animation de demarrage |
---
## Composants Home
| Composant | Fichier | Role |
|-----------|---------|------|
| SearchHeader | `components/home/SearchHeader.tsx` | Branding VinEye + greeting + boutons notifs/profil |
| SearchSection | `components/home/SearchSection.tsx` | Barre de recherche rounded-full avec filtre |
| HomeCta | `components/home/HomeCta.tsx` | Banner scan avec animation pulse + CTA |
| FrequentDiseases | `components/home/FrequentDiseases.tsx` | Carousel horizontal maladies (160px cards) |
| SeasonAlert | `components/home/SeasonAlert.tsx` | Carte alerte saisonniere (fond vert lime) |
| PracticalGuides | `components/home/PracticalGuides.tsx` | Liste verticale guides avec chevron |
| SectionHeader | `components/home/components/homeheader.tsx` | Titre section + bouton "Voir tout" |
---
## Donnees (Mock)
| Fichier | Contenu |
|---------|---------|
| `data/diseases.ts` | 7 maladies : mildiou, oidium, black rot, esca, botrytis, flavescence doree, chlorose |
| `data/guides.ts` | 3 guides : feuille saine, calendrier traitement, cepages bordelais |
---
## Design System
- **Fond** : `#F8F9FB` (gris bleuté)
- **Cards** : `#FFFFFF`, borderRadius 24-32, border 1px `#F0F0F0`
- **Ombres** : shadowOpacity 0.04, shadowRadius 8-10 (iOS), elevation 2-3 (Android)
- **Typographie** : Regular (400) par defaut, Medium (500) titres menus, Bold (700) noms utilisateur uniquement
- **Couleurs texte** : `#1A1A1A` (titres), `#8E8E93` (sous-titres/labels)
- **Style** : Bento Box minimaliste, espaces, zen
---
## Conventions
- **Styling** : NativeWind (className) prioritaire, StyleSheet pour ombres/gradients/arrondis specifiques
- Package manager : **pnpm**
- Path alias : `@/*``src/*`
- `useEffect` depuis `react` (jamais depuis reanimated)
- Navigation : React Navigation v7, **jamais Expo Router**
- Max 300 lignes par fichier
- i18n : tous les textes via `t()`, cles dans fr.json et en.json
---
## Commandes
```bash
pnpm start # Metro bundler
pnpm web # Version web
pnpm android # Build Android
pnpm ios # Build iOS
```
---
## Changelog
### 2026-04-02 — Refonte navigation + nouveaux ecrans
#### Added
- Bottom tab bar classique avec FAB central (Home | Guides | Scanner FAB | Library | Map)
- Icones lucide-react-native pour la bottom bar
- SearchHeader : branding VinEye + greeting + boutons notifs/profil
- SearchSection : barre de recherche rounded-full avec filtre
- HomeCta : banner scan anime avec pulse reanimated
- FrequentDiseases : carousel horizontal 7 maladies (cards Bento 160px)
- SeasonAlert : carte alerte saisonniere
- PracticalGuides : liste verticale 3 guides
- NotificationsScreen : 3 types (alerte/conseil/systeme), 6 mock, mark all read, empty state
- ProfileScreen : hero header vert + avatar overlap + info card + stats Bento 2x2
- SettingsScreen : menus groupes + referral card orange + language toggle + reset
- GuidesScreen : segmented control (Maladies/Guides) + listes de cartes avec badges severite
- LibraryScreen : grille 2 colonnes plantes + toggle favoris coeur
- MapScreen : placeholder
- data/diseases.ts : 7 maladies de la vigne typees
- data/guides.ts : 3 guides pratiques types
- Traductions completes FR/EN pour tous les nouveaux ecrans
#### Changed
- Navigation restructuree : History/Profile retires du tab bar → accessibles via header
- HomeScreen simplifie : header + search + CTA + 3 sections contenu
- react-dom aligne sur react 19.1.0
#### Removed
- Ancien floating pill tab bar (LayoutAnimation buggue)
- StatisticsSection du HomeScreen (deplace vers ProfileScreen)
---
**Version** : 2.0.0
**Derniere mise a jour** : 2026-04-02

41
VinEye/app.json Normal file
View file

@ -0,0 +1,41 @@
{
"expo": {
"name": "VinEye",
"slug": "vineye",
"version": "1.0.0",
"scheme": "vineye",
"orientation": "portrait",
"icon": "./src/assets/images/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./src/assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#2D6A4F"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.vineye.app"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./src/assets/images/adaptive-icon.png",
"backgroundColor": "#2D6A4F"
},
"edgeToEdgeEnabled": true,
"package": "com.vineye.app"
},
"web": {
"favicon": "./src/assets/images/icon.png"
},
"plugins": [
"expo-localization",
[
"expo-camera",
{
"cameraPermission": "VinEye needs camera access to detect grapevines."
}
]
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
VinEye/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
VinEye/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
VinEye/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

9
VinEye/babel.config.js Normal file
View file

@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
],
plugins: ['react-native-reanimated/plugin'],
};
};

19
VinEye/components.json Normal file
View file

@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

50
VinEye/global.css Normal file
View file

@ -0,0 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 98%;
--foreground: 0 0% 10.6%;
--card: 0 0% 100%;
--card-foreground: 0 0% 10.6%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 10.6%;
--primary: 153 42% 30%;
--primary-foreground: 0 0% 100%;
--secondary: 147 46% 89%;
--secondary-foreground: 153 42% 30%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 42%;
--accent: 147 46% 89%;
--accent-foreground: 153 42% 30%;
--destructive: 14 100% 63%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 87.8%;
--input: 0 0% 87.8%;
--ring: 153 42% 30%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 6.7%;
--foreground: 0 0% 98%;
--card: 0 0% 10.6%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 10.6%;
--popover-foreground: 0 0% 98%;
--primary: 147 46% 89%;
--primary-foreground: 153 42% 30%;
--secondary: 153 42% 30%;
--secondary-foreground: 147 46% 89%;
--muted: 0 0% 17.6%;
--muted-foreground: 0 0% 61%;
--accent: 153 42% 30%;
--accent-foreground: 147 46% 89%;
--destructive: 14 100% 63%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 17.6%;
--input: 0 0% 17.6%;
--ring: 147 46% 89%;
}
}

8
VinEye/index.ts Normal file
View file

@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

6
VinEye/metro.config.js Normal file
View file

@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 });

1
VinEye/nativewind-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

60
VinEye/package.json Normal file
View file

@ -0,0 +1,60 @@
{
"name": "vineye",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10",
"@rn-primitives/portal": "^1.4.0",
"@rn-primitives/separator": "^1.4.0",
"@rn-primitives/slot": "^1.4.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linear-gradient": "~15.0.8",
"expo-localization": "~17.0.8",
"expo-status-bar": "~3.0.9",
"i18next": "^26.0.1",
"lottie-react-native": "^7.3.6",
"lucide-react-native": "^1.7.0",
"nativewind": "^4.2.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^17.0.1",
"react-lucid": "^0.0.1",
"react-native": "0.81.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.12.1",
"react-native-web": "^0.21.2",
"react-native-worklets": "0.5.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "3.4.17"
},
"devDependencies": {
"@types/react": "~19.1.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.2"
},
"private": true,
"pnpm": {
"onlyBuiltDependencies": [
"@expo/config-plugins",
"expo",
"expo-modules-core"
]
}
}

7261
VinEye/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="leafGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2D6A4F;stop-opacity:1" />
<stop offset="100%" style="stop-color:#6A0572;stop-opacity:1" />
</linearGradient>
<linearGradient id="eyeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#40916C;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8E24AA;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Feuille de vigne stylisée formant un œil -->
<!-- Corps de la feuille (forme ovale penchée = paupières de l'œil) -->
<ellipse cx="100" cy="95" rx="75" ry="45" fill="none" stroke="url(#leafGrad)" stroke-width="6"
transform="rotate(-15, 100, 95)" />
<!-- Iris/pupille centrale -->
<circle cx="100" cy="95" r="22" fill="url(#eyeGrad)" />
<circle cx="100" cy="95" r="10" fill="#1B4332" />
<circle cx="107" cy="88" r="4" fill="white" opacity="0.8" />
<!-- Lobes de la feuille de vigne (3 lobes comme une vraie feuille) -->
<!-- Lobe haut gauche -->
<path d="M 55 70 Q 40 45 60 35 Q 70 55 55 70 Z" fill="url(#leafGrad)" opacity="0.85" />
<!-- Lobe haut droit -->
<path d="M 145 70 Q 160 45 140 35 Q 130 55 145 70 Z" fill="url(#leafGrad)" opacity="0.85" />
<!-- Lobe haut centre -->
<path d="M 100 50 Q 100 25 100 20 Q 108 35 100 50 Z" fill="url(#leafGrad)" opacity="0.9" />
<!-- Pétiole (queue de la feuille) = pupille / tige centrale -->
<line x1="100" y1="140" x2="100" y2="170" stroke="url(#leafGrad)" stroke-width="5" stroke-linecap="round" />
<!-- Vrilles (spirales décoratives) -->
<path d="M 100 165 Q 115 160 120 150 Q 125 140 115 138" fill="none" stroke="url(#eyeGrad)" stroke-width="2.5" stroke-linecap="round" />
<path d="M 100 165 Q 85 160 80 150 Q 75 140 85 138" fill="none" stroke="url(#eyeGrad)" stroke-width="2.5" stroke-linecap="round" />
<!-- Nervures de la feuille -->
<line x1="100" y1="95" x2="60" y2="70" stroke="url(#leafGrad)" stroke-width="1.5" opacity="0.4" stroke-linecap="round" />
<line x1="100" y1="95" x2="140" y2="70" stroke="url(#leafGrad)" stroke-width="1.5" opacity="0.4" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -0,0 +1,39 @@
{
"v": "5.7.4",
"fr": 30,
"ip": 0,
"op": 60,
"w": 400,
"h": 400,
"nm": "confetti",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "particle",
"ks": {
"o": { "a": 1, "k": [{ "t": 0, "s": [100] }, { "t": 60, "s": [0] }] },
"r": { "a": 1, "k": [{ "t": 0, "s": [0] }, { "t": 60, "s": [360] }] },
"p": { "a": 1, "k": [{ "t": 0, "s": [200, 100, 0] }, { "t": 60, "s": [200, 380, 0] }] },
"s": { "a": 0, "k": [100, 100, 100] }
},
"shapes": [
{
"ty": "rc",
"nm": "rect",
"s": { "a": 0, "k": [10, 10] },
"r": { "a": 0, "k": 2 }
},
{
"ty": "fl",
"nm": "fill",
"c": { "a": 0, "k": [0.18, 0.42, 0.31, 1] },
"o": { "a": 0, "k": 100 }
}
]
}
]
}

View file

@ -0,0 +1,42 @@
{
"v": "5.7.4",
"fr": 30,
"ip": 0,
"op": 60,
"w": 300,
"h": 300,
"nm": "level-up",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "star",
"ks": {
"o": { "a": 1, "k": [{ "t": 0, "s": [0] }, { "t": 10, "s": [100] }, { "t": 50, "s": [100] }, { "t": 60, "s": [0] }] },
"p": { "a": 0, "k": [150, 150, 0] },
"s": { "a": 1, "k": [{ "t": 0, "s": [0, 0, 100] }, { "t": 20, "s": [120, 120, 100] }, { "t": 30, "s": [100, 100, 100] }] }
},
"shapes": [
{
"ty": "sr",
"nm": "star",
"pt": { "a": 0, "k": 5 },
"p": { "a": 0, "k": [0, 0] },
"r": { "a": 0, "k": 0 },
"ir": { "a": 0, "k": 30 },
"or": { "a": 0, "k": 60 },
"os": { "a": 0, "k": 0 },
"is": { "a": 0, "k": 0 }
},
{
"ty": "fl",
"c": { "a": 0, "k": [0.416, 0.024, 0.447, 1] },
"o": { "a": 0, "k": 100 }
}
]
}
]
}

View file

@ -0,0 +1,38 @@
{
"v": "5.7.4",
"fr": 30,
"ip": 0,
"op": 45,
"w": 200,
"h": 200,
"nm": "scan-success",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "checkmark",
"ks": {
"o": { "a": 0, "k": 100 },
"r": { "a": 0, "k": 0 },
"p": { "a": 0, "k": [100, 100, 0] },
"s": { "a": 1, "k": [{ "t": 0, "s": [0, 0, 100] }, { "t": 20, "s": [110, 110, 100] }, { "t": 30, "s": [100, 100, 100] }] }
},
"shapes": [
{
"ty": "el",
"nm": "circle",
"s": { "a": 0, "k": [120, 120] },
"p": { "a": 0, "k": [0, 0] }
},
{
"ty": "fl",
"c": { "a": 0, "k": [0.298, 0.686, 0.314, 1] },
"o": { "a": 0, "k": 100 }
}
]
}
]
}

View file

@ -0,0 +1,38 @@
{
"v": "5.7.4",
"fr": 30,
"ip": 0,
"op": 90,
"w": 300,
"h": 300,
"nm": "vine-leaf",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "leaf",
"ks": {
"o": { "a": 1, "k": [{ "t": 0, "s": [0] }, { "t": 30, "s": [100] }, { "t": 90, "s": [100] }] },
"r": { "a": 1, "k": [{ "t": 0, "s": [-15] }, { "t": 30, "s": [0] }] },
"p": { "a": 0, "k": [150, 150, 0] },
"s": { "a": 1, "k": [{ "t": 0, "s": [0, 0, 100] }, { "t": 30, "s": [100, 100, 100] }] }
},
"shapes": [
{
"ty": "el",
"nm": "leaf-shape",
"s": { "a": 0, "k": [140, 100] },
"p": { "a": 0, "k": [0, 0] }
},
{
"ty": "fl",
"c": { "a": 0, "k": [0.176, 0.416, 0.31, 1] },
"o": { "a": 0, "k": 100 }
}
]
}
]
}

View file

@ -0,0 +1 @@
# TFLite model placeholder - replace with vine_detector.tflite

View file

@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import type { Badge } from '@/types/gamification';
interface BadgeCardProps {
badge: Badge;
showNewAnimation?: boolean;
}
export function BadgeCard({ badge, showNewAnimation = false }: BadgeCardProps) {
const { t } = useTranslation();
const scale = useSharedValue(1);
const glowOpacity = useSharedValue(0);
useEffect(() => {
if (showNewAnimation && badge.unlocked) {
scale.value = withSequence(
withSpring(1.2, { damping: 8 }),
withSpring(1, { damping: 12 })
);
glowOpacity.value = withSequence(
withTiming(1, { duration: 200 }),
withTiming(0.3, { duration: 600 }),
withTiming(1, { duration: 300 }),
withTiming(0, { duration: 800 })
);
}
}, [showNewAnimation, badge.unlocked]);
const animStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const glowStyle = useAnimatedStyle(() => ({
opacity: glowOpacity.value,
}));
return (
<Animated.View style={[styles.container, !badge.unlocked && styles.locked, animStyle]}>
{badge.unlocked && (
<Animated.View style={[styles.glow, glowStyle]} />
)}
<Text style={[styles.icon, !badge.unlocked && styles.lockedIcon]}>
{badge.icon}
</Text>
<Text style={[styles.name, !badge.unlocked && styles.lockedText]} numberOfLines={1}>
{t(badge.nameKey)}
</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
width: 88, alignItems: 'center', gap: spacing.xs, padding: spacing.sm,
borderRadius: 12, backgroundColor: colors.primary[100],
position: 'relative', overflow: 'hidden',
},
locked: { backgroundColor: colors.neutral[200] },
glow: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.accent[400], borderRadius: 12 },
icon: { fontSize: 32 },
lockedIcon: { opacity: 0.3 },
name: { fontSize: typography.fontSizes.xs, fontWeight: typography.fontWeights.medium, color: colors.primary[800], textAlign: 'center' },
lockedText: { color: colors.neutral[500] },
});

View file

@ -0,0 +1,54 @@
import { View, Text, StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { getLevelForXP, getLevelNumber } from '@/utils/achievements';
interface LevelIndicatorProps {
xp: number;
showLabel?: boolean;
}
export function LevelIndicator({ xp, showLabel = true }: LevelIndicatorProps) {
const { t } = useTranslation();
const level = getLevelForXP(xp);
const levelNumber = getLevelNumber(xp);
return (
<View style={styles.container}>
<View style={styles.badge}>
<Text style={styles.number}>{levelNumber}</Text>
</View>
{showLabel && (
<Text style={styles.label}>{t(level.labelKey)}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
badge: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.accent[700],
alignItems: 'center',
justifyContent: 'center',
},
number: {
fontSize: typography.fontSizes.sm,
fontWeight: typography.fontWeights.bold,
color: colors.surface,
},
label: {
fontSize: typography.fontSizes.sm,
fontWeight: typography.fontWeights.semibold,
color: colors.primary[800],
},
});

View file

@ -0,0 +1,74 @@
import { useEffect } from 'react';
import { View } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import Animated, {
useSharedValue,
useAnimatedProps,
withTiming,
} from 'react-native-reanimated';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
interface ProgressRingProps {
progress: number;
size?: number;
strokeWidth?: number;
color?: string;
trackColor?: string;
children?: React.ReactNode;
}
export function ProgressRing({
progress,
size = 100,
strokeWidth = 8,
color = '#40916C',
trackColor = '#E0E0E0',
children,
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const animatedProgress = useSharedValue(0);
useEffect(() => {
animatedProgress.value = withTiming(
Math.min(Math.max(progress, 0), 1),
{ duration: 1200 },
);
}, [progress]);
const animatedProps = useAnimatedProps(() => ({
strokeDashoffset: circumference * (1 - animatedProgress.value),
}));
return (
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
<Svg
width={size}
height={size}
style={{ position: 'absolute', transform: [{ rotate: '-90deg' }] }}
>
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={trackColor}
strokeWidth={strokeWidth}
fill="none"
/>
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
animatedProps={animatedProps}
strokeLinecap="round"
/>
</Svg>
{children}
</View>
);
}

View file

@ -0,0 +1,58 @@
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { useTranslation } from 'react-i18next';
interface StreakCounterProps {
streak: number;
compact?: boolean;
}
export function StreakCounter({ streak, compact = false }: StreakCounterProps) {
const { t } = useTranslation();
if (compact) {
return (
<View style={styles.compactRow}>
<Ionicons name="flame" size={16} color={colors.accent[700]} />
<Text style={styles.compactValue}>{streak}</Text>
</View>
);
}
return (
<View style={styles.container}>
<Ionicons name="flame" size={24} color={colors.accent[700]} />
<Text style={styles.value}>{streak}</Text>
<Text style={styles.label}>{t('profile.days')}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
gap: 2,
},
compactRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
value: {
fontSize: typography.fontSizes['2xl'],
fontWeight: typography.fontWeights.bold,
color: colors.accent[700],
},
label: {
fontSize: typography.fontSizes.xs,
color: colors.neutral[600],
},
compactValue: {
fontSize: typography.fontSizes.md,
fontWeight: typography.fontWeights.bold,
color: colors.accent[700],
},
});

View file

@ -0,0 +1,78 @@
import { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { getXPProgress, getNextLevel, getLevelForXP } from '@/utils/achievements';
interface XPBarProps {
xp: number;
compact?: boolean;
}
export function XPBar({ xp, compact = false }: XPBarProps) {
const { t } = useTranslation();
const { current, total, ratio } = getXPProgress(xp);
const nextLevel = getNextLevel(xp);
const currentLevel = getLevelForXP(xp);
const animatedWidth = useSharedValue(0);
useEffect(() => {
animatedWidth.value = withTiming(ratio, { duration: 800 });
}, [ratio]);
const barStyle = useAnimatedStyle(() => ({
width: `${animatedWidth.value * 100}%`,
}));
if (compact) {
return (
<View style={styles.compactContainer}>
<Text style={styles.compactLevel}>{t(currentLevel.labelKey)}</Text>
<View style={styles.trackCompact}>
<Animated.View style={[styles.bar, barStyle]} />
</View>
<Text style={styles.compactXP}>{xp} XP</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.levelLabel}>{t(currentLevel.labelKey)}</Text>
{nextLevel && (
<Text style={styles.xpLabel}>{current}/{total} XP</Text>
)}
</View>
<View style={styles.track}>
<Animated.View style={[styles.bar, barStyle]} />
</View>
{nextLevel && (
<Text style={styles.nextLevelLabel}>
{t('levels.xpToNext', { xp: total - current })}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xs },
compactContainer: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
labelRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
levelLabel: { fontSize: typography.fontSizes.sm, fontWeight: typography.fontWeights.semibold, color: colors.primary[800] },
xpLabel: { fontSize: typography.fontSizes.xs, color: colors.neutral[600] },
track: { height: 8, backgroundColor: colors.neutral[200], borderRadius: 4, overflow: 'hidden' },
trackCompact: { flex: 1, height: 5, backgroundColor: colors.neutral[200], borderRadius: 3, overflow: 'hidden' },
bar: { height: '100%', backgroundColor: colors.primary[700], borderRadius: 4 },
nextLevelLabel: { fontSize: typography.fontSizes.xs, color: colors.neutral[500], textAlign: 'right' },
compactLevel: { fontSize: typography.fontSizes.xs, color: colors.primary[700], fontWeight: typography.fontWeights.medium, minWidth: 70 },
compactXP: { fontSize: typography.fontSizes.xs, color: colors.neutral[600], minWidth: 50, textAlign: 'right' },
});

View file

@ -0,0 +1,126 @@
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useTranslation } from 'react-i18next';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { Badge } from '@/components/ui/Badge';
import { getCepageById } from '@/utils/cepages';
import type { ScanRecord } from '@/types/detection';
interface ScanCardProps {
record: ScanRecord;
onDelete?: () => void;
onPress?: () => void;
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' });
}
export function ScanCard({ record, onDelete, onPress }: ScanCardProps) {
const { t } = useTranslation();
const { detection } = record;
const cepage = detection.cepageId ? getCepageById(detection.cepageId) : undefined;
const resultColor =
detection.result === 'vine'
? 'success'
: detection.result === 'uncertain'
? 'warning'
: 'danger';
const resultLabel =
detection.result === 'vine'
? t('result.vineDetected')
: detection.result === 'uncertain'
? t('result.uncertain')
: t('result.notVine');
return (
<TouchableOpacity style={styles.container} onPress={onPress} activeOpacity={0.75}>
{/* Colored strip */}
<View style={[styles.strip, { backgroundColor: colors[detection.result === 'vine' ? 'success' : detection.result === 'uncertain' ? 'warning' : 'danger'] }]} />
<View style={styles.content}>
<View style={styles.top}>
<Text style={styles.date}>{formatDate(record.createdAt)}</Text>
<Badge label={`+${record.xpEarned} XP`} color="primary" size="sm" />
</View>
<View style={styles.middle}>
<Badge label={resultLabel} color={resultColor} />
{cepage && (
<Text style={styles.ceepage}>{cepage.name.fr}</Text>
)}
</View>
<View style={styles.bottom}>
<Text style={styles.confidence}>
{t('scanner.confidence')} : <Text style={{ fontWeight: typography.fontWeights.bold }}>{detection.confidence}%</Text>
</Text>
{onDelete && (
<TouchableOpacity onPress={onDelete} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.delete}>🗑</Text>
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: colors.card,
borderRadius: 12,
overflow: 'hidden',
shadowColor: colors.neutral[900],
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
},
strip: {
width: 5,
},
content: {
flex: 1,
padding: spacing.md,
gap: spacing.xs,
},
top: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
middle: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
flexWrap: 'wrap',
},
bottom: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
date: {
fontSize: typography.fontSizes.xs,
color: colors.neutral[600],
},
ceepage: {
fontSize: typography.fontSizes.sm,
fontWeight: typography.fontWeights.medium,
color: colors.primary[800],
},
confidence: {
fontSize: typography.fontSizes.xs,
color: colors.neutral[600],
},
delete: {
fontSize: 16,
opacity: 0.6,
},
});

View file

@ -0,0 +1,68 @@
import { FlatList, View, Text, StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next';
import { ScanCard } from './ScanCard';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import type { ScanRecord } from '@/types/detection';
interface ScanListProps {
records: ScanRecord[];
onDelete?: (id: string) => void;
onPressItem?: (record: ScanRecord) => void;
}
export function ScanList({ records, onDelete, onPressItem }: ScanListProps) {
const { t } = useTranslation();
if (records.length === 0) {
return (
<View style={styles.empty}>
<Text style={styles.emptyEmoji}>🍇</Text>
<Text style={styles.emptyText}>{t('history.empty')}</Text>
</View>
);
}
return (
<FlatList
data={records}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ScanCard
record={item}
onDelete={onDelete ? () => onDelete(item.id) : undefined}
onPress={onPressItem ? () => onPressItem(item) : undefined}
/>
)}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.separator} />}
showsVerticalScrollIndicator={false}
/>
);
}
const styles = StyleSheet.create({
list: {
padding: spacing.base,
},
separator: {
height: spacing.sm,
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.base,
paddingVertical: spacing['5xl'],
},
emptyEmoji: {
fontSize: 48,
opacity: 0.5,
},
emptyText: {
fontSize: typography.fontSizes.base,
color: colors.neutral[500],
textAlign: 'center',
},
});

View file

@ -0,0 +1,149 @@
import { View, FlatList, TouchableOpacity, StyleSheet, Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import { VINE_DISEASES } from "@/data/diseases";
import type { Disease } from "@/data/diseases";
const DISEASE_TYPE_KEYS: Record<Disease["type"], string> = {
fungal: "diseases.types.fungal",
bacterial: "diseases.types.bacterial",
pest: "diseases.types.pest",
abiotic: "diseases.types.abiotic",
};
const SEVERITY_LEVELS: Record<Disease["severity"], { color: string; label: string }> = {
high: { color: "#EF4444", label: "high" },
medium: { color: "#F59E0B", label: "medium" },
low: { color: "#10B981", label: "low" },
};
export default function FrequentDiseases() {
const { t } = useTranslation();
return (
<FlatList
data={VINE_DISEASES}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContainer}
renderItem={({ item }) => {
const severity = SEVERITY_LEVELS[item.severity];
return (
<TouchableOpacity
activeOpacity={0.8}
style={[styles.card, { shadowColor: item.iconColor }]}
>
{/* Header: Icon & Severity Badge */}
<View style={styles.cardHeader}>
<View style={[styles.iconWrapper, { backgroundColor: `${item.iconColor}15` }]}>
<Ionicons name={item.icon as any} size={24} color={item.iconColor} />
</View>
<View style={[styles.severityBadge, { backgroundColor: `${severity.color}15` }]}>
<View style={[styles.dot, { backgroundColor: severity.color }]} />
</View>
</View>
{/* Content */}
<View style={styles.cardBody}>
<Text style={styles.typeText}>
{t(DISEASE_TYPE_KEYS[item.type]).toUpperCase()}
</Text>
<Text numberOfLines={2} style={styles.nameText}>
{t(item.name)}
</Text>
</View>
{/* Footer: Action hint */}
<View style={styles.cardFooter}>
<Text style={styles.moreInfo}>{t("common.details")}</Text>
<Ionicons name="chevron-forward" size={12} color={colors.neutral[400]} />
</View>
</TouchableOpacity>
);
}}
/>
);
}
const styles = StyleSheet.create({
listContainer: {
paddingHorizontal: 20,
paddingVertical: 10,
gap: 16,
},
card: {
width: 160,
backgroundColor: "#FFFFFF",
borderRadius: 24,
padding: 16,
justifyContent: "space-between",
// Shadow logic
...Platform.select({
ios: {
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 12,
},
android: {
elevation: 6,
},
}),
},
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 12,
},
iconWrapper: {
width: 44,
height: 44,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
},
severityBadge: {
padding: 6,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
cardBody: {
flex: 1,
},
typeText: {
fontSize: 10,
fontWeight: "800",
color: colors.neutral[400],
letterSpacing: 0.5,
marginBottom: 4,
},
nameText: {
fontSize: 15,
fontWeight: "700",
color: colors.neutral[900],
lineHeight: 20,
},
cardFooter: {
flexDirection: "row",
alignItems: "center",
marginTop: 12,
gap: 4,
},
moreInfo: {
fontSize: 12,
fontWeight: "600",
color: colors.neutral[400],
}
});

View file

@ -0,0 +1,191 @@
import { useEffect } from "react";
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
export default function HeroScanner() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const pulse = useSharedValue(1);
useEffect(() => {
pulse.value = withRepeat(
withSequence(
withTiming(1.06, { duration: 1200 }),
withTiming(1, { duration: 1200 }),
),
-1,
false,
);
}, []);
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulse.value }],
}));
return (
<View style={styles.bannerContainer}>
{/* Decorative background */}
<View style={styles.gridOverlay} />
<View style={styles.leafDecorator}>
<Ionicons name="leaf" size={140} color={colors.primary[300]} />
</View>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{t("home.bannerTitle")}</Text>
<Text style={styles.subtitle}>{t("home.bannerSubtitle")}</Text>
</View>
{/* Animated scan zone */}
<View style={styles.scanZone}>
<Animated.View style={[styles.pulseOuter, pulseStyle]}>
<View style={styles.pulseInner}>
<View style={styles.iconCircle}>
<Ionicons name="scan-outline" size={38} color={colors.surface} />
</View>
</View>
</Animated.View>
</View>
{/* Main CTA button */}
<TouchableOpacity
activeOpacity={0.9}
onPress={() => navigation.navigate("Scanner")}
style={styles.mainButton}
>
<Text style={styles.buttonText}>{t("home.scanButton")}</Text>
<View style={styles.buttonIconWrapper}>
<MaterialIcons
name="arrow-forward"
size={18}
color={colors.primary[800]}
/>
</View>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
bannerContainer: {
marginHorizontal: 20,
marginBottom: 24,
borderRadius: 32,
padding: 24,
backgroundColor: colors.primary[600],
overflow: "hidden",
position: "relative",
...Platform.select({
ios: {
shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.2,
shadowRadius: 16,
},
android: { elevation: 8 },
}),
},
leafDecorator: {
position: "absolute",
top: -20,
right: -20,
opacity: 0.15,
transform: [{ rotate: "-15deg" }],
},
gridOverlay: {
...StyleSheet.absoluteFillObject,
opacity: 0.05,
},
header: {
marginBottom: 20,
},
title: {
fontSize: 22,
fontWeight: "900",
color: colors.surface,
letterSpacing: -0.5,
},
subtitle: {
fontSize: 14,
color: colors.primary[200],
marginTop: 4,
maxWidth: "80%",
lineHeight: 20,
},
scanZone: {
alignItems: "center",
justifyContent: "center",
marginVertical: 20,
},
pulseOuter: {
width: 110,
height: 110,
borderRadius: 55,
backgroundColor: colors.primary[500] + "26",
alignItems: "center",
justifyContent: "center",
},
pulseInner: {
width: 85,
height: 85,
borderRadius: 42,
backgroundColor: colors.primary[400] + "40",
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: colors.primary[300] + "4D",
},
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.primary[400] + "30",
alignItems: "center",
justifyContent: "center",
},
hintText: {
marginTop: 12,
fontSize: 11,
fontWeight: "700",
color: colors.primary[200],
textTransform: "uppercase",
letterSpacing: 1,
},
mainButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surface,
borderRadius: 20,
paddingVertical: 14,
marginTop: 10,
},
buttonText: {
fontSize: 16,
fontWeight: "800",
color: colors.primary[900],
marginRight: 8,
},
buttonIconWrapper: {
backgroundColor: colors.primary[100],
padding: 4,
borderRadius: 8,
},
});

View file

@ -0,0 +1,112 @@
import { View, TouchableOpacity, StyleSheet, Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import { PRACTICAL_GUIDES } from "@/data/guides";
export default function PracticalGuides() {
const { t } = useTranslation();
return (
<View style={styles.container}>
{PRACTICAL_GUIDES.map((guide) => (
<TouchableOpacity
key={guide.id}
activeOpacity={0.6}
style={styles.card}
>
{/* Icône avec fond translucide assorti */}
<View
style={[
styles.iconContainer,
{ backgroundColor: `${guide.iconColor}12` } // 12 = ~7% d'opacité
]}
>
<Ionicons
name={guide.icon as any}
size={24}
color={guide.iconColor}
/>
</View>
{/* Textes */}
<View style={styles.textStack}>
<Text numberOfLines={1} style={styles.title}>
{t(guide.title)}
</Text>
<Text numberOfLines={1} style={styles.subtitle}>
{t(guide.subtitle)}
</Text>
</View>
{/* Indicateur d'action discret */}
<View style={styles.chevronWrapper}>
<Ionicons
name="chevron-forward"
size={16}
color={colors.neutral[300]}
/>
</View>
</TouchableOpacity>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 12,
paddingHorizontal: 4, // Pour ne pas couper l'ombre
},
card: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF",
borderRadius: 24, // Arrondi plus prononcé style "Bento"
padding: 14,
borderWidth: 1,
borderColor: "#F1F1F1",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 8,
},
android: {
elevation: 2,
},
}),
},
iconContainer: {
width: 52,
height: 52,
borderRadius: 18,
alignItems: "center",
justifyContent: "center",
},
textStack: {
flex: 1,
marginLeft: 14,
},
title: {
fontSize: 15,
fontWeight: "700",
color: colors.neutral[900],
letterSpacing: -0.3,
marginBottom: 2,
},
subtitle: {
fontSize: 13,
fontWeight: "500",
color: colors.neutral[500],
},
chevronWrapper: {
marginLeft: 8,
backgroundColor: "#F8F9FA",
padding: 6,
borderRadius: 12,
},
});

View file

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

View file

@ -0,0 +1,80 @@
import { View, TextInput, StyleSheet, TouchableOpacity } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { colors } from "@/theme/colors";
export default function SearchSection() {
const { t } = useTranslation();
return (
<View style={styles.container}>
<View style={styles.searchWrapper}>
{/* Icône de recherche */}
<Ionicons
name="search"
size={20}
color={colors.neutral[400]}
style={styles.searchIcon}
/>
{/* Champ de saisie */}
<TextInput
style={styles.input}
placeholder={t("home.searchPlaceholder") ?? "Rechercher..."}
placeholderTextColor={colors.neutral[400]}
selectionColor={colors.primary[500]}
autoCorrect={false}
/>
{/* Optionnel: Petit séparateur + Icône Filtre pour le look Premium */}
<TouchableOpacity style={styles.filterButton} activeOpacity={0.7}>
<View style={styles.divider} />
<Ionicons name="options-outline" size={18} color={colors.primary[600]} />
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 20,
paddingBottom: 16,
paddingTop: 4,
},
searchWrapper: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#F5F7F9", // Un gris bleuté plus frais que neutral-200
borderRadius: 100, // On garde ton style "full"
paddingHorizontal: 16,
height: 52, // Hauteur standardisée pour le tactile
borderWidth: 1,
borderColor: "#EAECEF",
},
searchIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
fontWeight: "500",
color: colors.neutral[900],
// Évite le décalage de texte sur Android
paddingVertical: 0,
height: "100%",
},
filterButton: {
flexDirection: "row",
alignItems: "center",
paddingLeft: 12,
},
divider: {
width: 1,
height: 20,
backgroundColor: "#E2E4E7",
marginRight: 12,
},
});

View file

@ -0,0 +1,104 @@
import { View, TouchableOpacity, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
const CURRENT_ALERT = {
type: "warning" as const,
title: "Stay in the know",
message: "Get alerts on your money activity and budgets",
};
export default function SeasonAlert() {
const { t } = useTranslation();
return (
<View style={styles.container}>
<View style={styles.content}>
{/* Bouton Fermer */}
<TouchableOpacity style={styles.closeButton} activeOpacity={0.7}>
<Ionicons name="close" size={18} color={colors.primary[900]} />
</TouchableOpacity>
{/* Contenu Texte */}
<View style={styles.textContainer}>
<Text style={styles.title}>{t(CURRENT_ALERT.title)}</Text>
<Text style={styles.message}>{t(CURRENT_ALERT.message)}</Text>
</View>
{/* Bouton d'action */}
<TouchableOpacity style={styles.actionButton} activeOpacity={0.7}>
<Text style={styles.actionText}>{t("Allow notifications")}</Text>
</TouchableOpacity>
{/* Illustration decorative */}
<View style={styles.decoration}>
<Ionicons name="notifications" size={100} color={colors.primary[300]} />
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginHorizontal: 20,
marginBottom: 24,
borderRadius: 28,
backgroundColor: colors.primary[200],
overflow: "hidden",
},
content: {
position: "relative",
padding: 24,
},
closeButton: {
position: "absolute",
right: 16,
top: 16,
height: 32,
width: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
backgroundColor: colors.primary[300],
zIndex: 1,
},
textContainer: {
paddingRight: 48,
},
title: {
fontSize: 22,
fontWeight: "700",
letterSpacing: -0.5,
color: colors.primary[900],
},
message: {
marginTop: 8,
fontSize: 16,
lineHeight: 24,
color: colors.primary[800],
opacity: 0.6,
},
actionButton: {
marginTop: 24,
alignSelf: "flex-start",
borderRadius: 12,
backgroundColor: colors.primary[400],
paddingHorizontal: 24,
paddingVertical: 12,
elevation: 2,
},
actionText: {
fontSize: 16,
fontWeight: "700",
color: colors.primary[900],
},
decoration: {
position: "absolute",
bottom: -16,
right: -16,
opacity: 0.8,
},
});

View file

@ -0,0 +1,88 @@
import { View, TouchableOpacity, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
export default function SectionHeader({
title,
onViewAll,
}: {
title: string;
onViewAll?: () => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.container}>
{/* Titre avec graisse plus affirmée */}
<Text style={styles.title}>
{title}
</Text>
{onViewAll && (
<TouchableOpacity
onPress={onViewAll}
activeOpacity={0.6}
style={styles.button}
>
<Text style={styles.buttonText}>
{t("common.viewAll") ?? "View all"}
</Text>
{/* Petit chevron discret pour guider l'œil */}
<View style={styles.iconWrapper}>
<Ionicons
name="chevron-forward"
size={12}
color={colors.primary[600]}
/>
</View>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 4,
},
title: {
fontSize: 18,
fontWeight: "800", // Plus épais pour le style Bento
color: "#1A1A1A",
letterSpacing: -0.5, // Look moderne
},
button: {
flexDirection: "row",
alignItems: "center",
backgroundColor: `${colors.primary[50]}`, // Fond très léger
paddingVertical: 6,
paddingLeft: 12,
paddingRight: 6,
borderRadius: 12,
},
buttonText: {
fontSize: 13,
fontWeight: "700",
color: colors.primary[700],
marginRight: 4,
},
iconWrapper: {
backgroundColor: "#FFFFFF",
borderRadius: 6,
padding: 2,
// Légère ombre pour faire ressortir l'icône
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
});

View file

@ -0,0 +1,116 @@
import React from "react";
import { View, Text, Dimensions } from "react-native";
import { Svg, Circle } from "react-native-svg";
import { Ionicons } from "@expo/vector-icons";
interface StatCardProps {
title: string;
value: number; // Ex: 82 pour 82%
trend: string; // Ex: "+5.1%"
isUp?: boolean;
color?: string;
}
const StatCard = ({
title,
value,
trend,
isUp = true,
color = "#3B82F6",
}: StatCardProps) => {
// Config de l'arc
const size = 180;
const strokeWidth = 15;
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
// On veut un arc de 180 degrés (le haut du cercle)
const arcTotalLength = circumference / 2;
const progress = (value / 100) * arcTotalLength;
return (
<View className="bg-white p-6 rounded-[32px] shadow-sm overflow-hidden relative border border-gray-50">
{/* Header : Titre + Icône flèche */}
<View className="flex-row justify-between items-start mb-10">
<Text className="text-gray-400 font-medium text-[15px]">{title}</Text>
<View className="bg-gray-50 p-2 rounded-xl">
<Ionicons
name="arrow-up-outline"
size={18}
color="#9CA3AF"
style={{ transform: [{ rotate: "45deg" }] }}
/>
</View>
</View>
<View className="flex-row items-end justify-between">
{/* Section Chiffres */}
<View className="z-10">
<View className="flex-row items-center mb-1">
<Text
className={`text-xs font-bold ${isUp ? "text-blue-500" : "text-red-500"}`}
>
{trend}
</Text>
<Ionicons
name={isUp ? "caret-up" : "caret-down"}
size={12}
color={isUp ? "#3B82F6" : "#EF4444"}
className="ml-1"
/>
</View>
<Text className="text-5xl font-bold text-gray-900 tracking-tighter">
{value}%
</Text>
</View>
{/* L'arc de cercle (Gauge) */}
{/* On tourne de -180 deg pour que l'arc soit en haut/droite comme sur la photo */}
<View
className="absolute -right-6 -bottom-[92px]"
style={{ transform: [{ rotate: "-190deg" }] }}
>
<Svg width={size} height={size}>
{/* Rail gris */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#F3F4F6"
strokeWidth={strokeWidth}
strokeDasharray={`${arcTotalLength} ${circumference}`}
strokeLinecap="round"
fill="none"
/>
{/* Progression colorée */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={`${progress} ${circumference}`}
strokeLinecap="round"
fill="none"
/>
</Svg>
{/* Le petit curseur blanc au bout du remplissage */}
{/* Note: Le calcul de position exacte du point demande de la trigo,
ici on le place de façon fixe pour le style, ou on le retire pour plus de sobriété */}
<View
className="absolute bg-white rounded-full border-[3px] border-blue-500 shadow-sm"
style={{
width: 12,
height: 12,
top: "47%",
left: 1,
}}
/>
</View>
</View>
</View>
);
};
export default StatCard;

View file

@ -0,0 +1,97 @@
import React from "react";
import { View, Text } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useTranslation } from "react-i18next";
import StatCard from "./gamificationstat";
import SectionHeader from "./components/homeheader";
// import SectionHeader from '@/components/SectionHeader';
export default function StatisticsSection({ progress }: { progress: any }) {
const { t } = useTranslation();
// On ne garde que les deux stats essentielles
const STATS = [
{
label: t("home.daily_streak"),
value: `${progress.streak}`,
unit: t("profile.days"),
icon: "flame",
color: "#F59E0B", // Amber 500
bgColor: "#FFFBEB", // Amber 50
},
{
label: t("home.total_xp"),
value: `${progress.xpTotal}`,
unit: "XP",
icon: "flash",
color: "#3B82F6", // Blue 500
bgColor: "#EFF6FF", // Blue 50
},
];
return (
<View className="mx-5 mb-2">
<View>
<SectionHeader title={t("home.statistics") ?? "Statistics"} />
</View>
{/* Progression Section */}
<View className="my-2">
<StatCard
title="Task Execution"
value={82}
trend="+5.1%"
color="#3B82F6"
/>
</View>
{/* Container avec un gap réduit pour coller les cartes */}
<View className="flex-row gap-3">
{STATS.map((stat, i) => (
<View
key={i}
className="flex-1 bg-white p-4 rounded-[32px] shadow-sm overflow-hidden relative border border-gray-50"
style={{
elevation: 2,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 8,
}}
>
{/* Top Row: Icon + Unit */}
<View className="flex-row justify-between items-start mb-2">
<View
className="w-8 h-8 items-center justify-center rounded-xl"
style={{ backgroundColor: stat.bgColor }}
>
<Ionicons
name={stat.icon as any}
size={16}
color={stat.color}
/>
</View>
<Text className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
{stat.unit}
</Text>
</View>
{/* Value Section */}
<View>
<Text className="text-2xl font-bold text-gray-900 leading-tight">
{stat.value}
</Text>
<Text
className="text-[11px] font-medium mt-0.5"
style={{ color: stat.color }}
numberOfLines={1}
>
{stat.label}
</Text>
</View>
</View>
))}
</View>
</View>
);
}

View file

@ -0,0 +1,50 @@
import { View, Text, StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next';
import { DetectionFrame } from './DetectionFrame';
import { ConfidenceMeter } from './ConfidenceMeter';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
interface CameraOverlayProps {
isScanning: boolean;
confidence: number;
}
export function CameraOverlay({ isScanning, confidence }: CameraOverlayProps) {
const { t } = useTranslation();
return (
<View style={styles.overlay} pointerEvents="none">
{/* Center frame — dashed rectangle Figma style */}
<View style={styles.centerSection}>
<DetectionFrame size={260} active={isScanning} />
</View>
{/* Bottom — subtle confidence if scanning */}
{isScanning && confidence > 0 && (
<View style={styles.bottomSection}>
<ConfidenceMeter confidence={confidence} />
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
centerSection: {
alignItems: 'center',
justifyContent: 'center',
},
bottomSection: {
position: 'absolute',
bottom: spacing['5xl'],
left: spacing.xl,
right: spacing.xl,
},
});

View file

@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
import { useTranslation } from 'react-i18next';
interface ConfidenceMeterProps {
confidence: number; // 0100
}
function getConfidenceColor(confidence: number): string {
if (confidence >= 70) return colors.success;
if (confidence >= 40) return colors.warning;
return colors.danger;
}
export function ConfidenceMeter({ confidence }: ConfidenceMeterProps) {
const { t } = useTranslation();
const animatedWidth = useSharedValue(0);
useEffect(() => {
animatedWidth.value = withTiming(confidence / 100, { duration: 500 });
}, [confidence]);
const barStyle = useAnimatedStyle(() => ({
width: `${animatedWidth.value * 100}%`,
backgroundColor: getConfidenceColor(confidence),
}));
return (
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.label}>{t('scanner.confidence')}</Text>
<Text style={[styles.value, { color: getConfidenceColor(confidence) }]}>
{confidence}%
</Text>
</View>
<View style={styles.track}>
<Animated.View style={[styles.bar, barStyle]} />
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { width: '100%', paddingHorizontal: spacing.base, gap: spacing.xs },
labelRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
label: { fontSize: typography.fontSizes.sm, color: colors.surface, fontWeight: typography.fontWeights.medium, opacity: 0.9 },
value: { fontSize: typography.fontSizes.md, fontWeight: typography.fontWeights.bold },
track: { height: 6, backgroundColor: 'rgba(255,255,255,0.2)', borderRadius: 3, overflow: 'hidden' },
bar: { height: 6, borderRadius: 3 },
});

View file

@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { StyleSheet, type ViewStyle } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
interface DetectionFrameProps {
size?: number;
active?: boolean;
style?: ViewStyle;
}
export function DetectionFrame({ size = 240, active = true, style }: DetectionFrameProps) {
const scale = useSharedValue(1);
const opacity = useSharedValue(0.7);
useEffect(() => {
if (active) {
scale.value = withRepeat(
withSequence(withTiming(1.02, { duration: 1000 }), withTiming(1, { duration: 1000 })),
-1,
false
);
opacity.value = withRepeat(
withSequence(withTiming(1, { duration: 1000 }), withTiming(0.5, { duration: 1000 })),
-1,
false
);
} else {
scale.value = withTiming(1);
opacity.value = withTiming(0.7);
}
}, [active]);
const animStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return (
<Animated.View style={[styles.frame, { width: size, height: size }, animStyle, style]} />
);
}
const styles = StyleSheet.create({
frame: {
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.8)',
borderStyle: 'dashed',
borderRadius: 12,
},
});

View file

@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { Text, type TextStyle } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedProps,
withTiming,
} from 'react-native-reanimated';
import { typography } from '@/theme/typography';
import { colors } from '@/theme/colors';
const AnimatedText = Animated.createAnimatedComponent(Text);
interface AnimatedCounterProps {
value: number;
prefix?: string;
suffix?: string;
style?: TextStyle;
duration?: number;
}
export function AnimatedCounter({
value,
prefix = '',
suffix = '',
style,
duration = 600,
}: AnimatedCounterProps) {
const animatedValue = useSharedValue(0);
useEffect(() => {
animatedValue.value = withTiming(value, { duration });
}, [value, duration]);
const animatedProps = useAnimatedProps(() => ({
text: `${prefix}${Math.floor(animatedValue.value)}${suffix}`,
}));
return (
<AnimatedText
style={[{ fontSize: typography.fontSizes.lg, fontWeight: typography.fontWeights.bold, color: colors.primary[800] }, style]}
// @ts-expect-error animatedProps text property
animatedProps={animatedProps}
/>
);
}

View file

@ -0,0 +1,62 @@
import { View, Text, StyleSheet, type ViewStyle } from 'react-native';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import { spacing } from '@/theme/spacing';
type BadgeColor = 'success' | 'warning' | 'danger' | 'primary' | 'accent' | 'neutral';
interface BadgeProps {
label: string;
color?: BadgeColor;
size?: 'sm' | 'md';
style?: ViewStyle;
}
export function Badge({ label, color = 'primary', size = 'md', style }: BadgeProps) {
return (
<View style={[styles.base, COLOR_STYLES[color], size === 'sm' && styles.small, style]}>
<Text style={[styles.text, COLOR_TEXT[color], size === 'sm' && styles.smallText]}>
{label}
</Text>
</View>
);
}
const styles = StyleSheet.create({
base: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: 20,
alignSelf: 'flex-start',
},
small: {
paddingHorizontal: spacing.xs + 2,
paddingVertical: 2,
},
text: {
fontSize: typography.fontSizes.xs,
fontWeight: typography.fontWeights.semibold,
letterSpacing: 0.3,
},
smallText: {
fontSize: 10,
},
});
const COLOR_STYLES: Record<BadgeColor, ViewStyle> = {
success: { backgroundColor: '#E8F5E9' },
warning: { backgroundColor: '#FFF3E0' },
danger: { backgroundColor: '#FBE9E7' },
primary: { backgroundColor: colors.primary[200] },
accent: { backgroundColor: colors.accent[300] },
neutral: { backgroundColor: colors.neutral[200] },
};
const COLOR_TEXT: Record<BadgeColor, { color: string }> = {
success: { color: '#2E7D32' },
warning: { color: '#E65100' },
danger: { color: '#BF360C' },
primary: { color: colors.primary[800] },
accent: { color: colors.accent[800] },
neutral: { color: colors.neutral[700] },
};

View file

@ -0,0 +1,106 @@
import { TextClassContext } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Platform, Pressable } from 'react-native';
const buttonVariants = cva(
cn(
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
Platform.select({
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
})
),
{
variants: {
variant: {
default: cn(
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-primary/90' })
),
destructive: cn(
'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5',
Platform.select({
web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
})
),
outline: cn(
'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5',
Platform.select({
web: 'hover:bg-accent dark:hover:bg-input/50',
})
),
secondary: cn(
'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-secondary/80' })
),
ghost: cn(
'active:bg-accent dark:active:bg-accent/50',
Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })
),
link: '',
},
size: {
default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),
lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })),
icon: 'h-10 w-10 sm:h-9 sm:w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const buttonTextVariants = cva(
cn(
'text-foreground text-sm font-medium',
Platform.select({ web: 'pointer-events-none transition-colors' })
),
{
variants: {
variant: {
default: 'text-primary-foreground',
destructive: 'text-white',
outline: cn(
'group-active:text-accent-foreground',
Platform.select({ web: 'group-hover:text-accent-foreground' })
),
secondary: 'text-secondary-foreground',
ghost: 'group-active:text-accent-foreground',
link: cn(
'text-primary group-active:underline',
Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
),
},
size: {
default: '',
sm: '',
lg: '',
icon: '',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
type ButtonProps = React.ComponentProps<typeof Pressable> & VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
<Pressable
className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
role="button"
{...props}
/>
</TextClassContext.Provider>
);
}
export { Button, buttonTextVariants, buttonVariants };
export type { ButtonProps };

View file

@ -0,0 +1,48 @@
import { View, StyleSheet, type ViewStyle } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
interface CardProps {
children: React.ReactNode;
style?: ViewStyle;
variant?: 'default' | 'elevated' | 'outlined';
padding?: keyof typeof spacing;
}
export function Card({ children, style, variant = 'default', padding = 'base' }: CardProps) {
return (
<View style={[styles.base, VARIANT_STYLES[variant], { padding: spacing[padding] }, style]}>
{children}
</View>
);
}
const styles = StyleSheet.create({
base: {
borderRadius: 20,
backgroundColor: colors.card,
},
});
const VARIANT_STYLES: Record<'default' | 'elevated' | 'outlined', ViewStyle> = {
default: {
shadowColor: colors.neutral[900],
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
elevated: {
shadowColor: colors.neutral[900],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 16,
elevation: 4,
},
outlined: {
borderWidth: 1,
borderColor: colors.neutral[300],
shadowOpacity: 0,
elevation: 0,
},
};

View file

@ -0,0 +1,81 @@
import { useEffect } from 'react';
import { View, StyleSheet, type ViewStyle } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import Animated, {
useAnimatedProps,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { colors } from '@/theme/colors';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
interface ProgressCircleProps {
size?: number;
strokeWidth?: number;
progress: number; // 01
color?: string;
trackColor?: string;
children?: React.ReactNode;
style?: ViewStyle;
}
export function ProgressCircle({
size = 80,
strokeWidth = 8,
progress,
color = colors.primary[700],
trackColor = colors.neutral[300],
children,
style,
}: ProgressCircleProps) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const animatedProgress = useSharedValue(0);
useEffect(() => {
animatedProgress.value = withTiming(progress, { duration: 700 });
}, [progress]);
const animatedProps = useAnimatedProps(() => ({
strokeDashoffset: circumference * (1 - animatedProgress.value),
}));
return (
<View style={[{ width: size, height: size }, style]}>
<Svg width={size} height={size} style={StyleSheet.absoluteFill}>
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={trackColor}
strokeWidth={strokeWidth}
fill="none"
/>
<AnimatedCircle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
animatedProps={animatedProps}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</Svg>
{children && (
<View style={styles.content}>{children}</View>
)}
</View>
);
}
const styles = StyleSheet.create({
content: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
});

View file

@ -0,0 +1,24 @@
import { cn } from '@/lib/utils';
import * as SeparatorPrimitive from '@rn-primitives/separator';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
);
}
export { Separator };

View file

@ -0,0 +1,88 @@
import { cn } from '@/lib/utils';
import * as Slot from '@rn-primitives/slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Platform, Text as RNText, type Role } from 'react-native';
const textVariants = cva(
cn(
'text-foreground text-base',
Platform.select({
web: 'select-text',
})
),
{
variants: {
variant: {
default: '',
h1: cn(
'text-center text-4xl font-extrabold tracking-tight',
Platform.select({ web: 'scroll-m-20 text-balance' })
),
h2: cn(
'border-border border-b pb-2 text-3xl font-semibold tracking-tight',
Platform.select({ web: 'scroll-m-20 first:mt-0' })
),
h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
p: 'mt-3 leading-7 sm:mt-6',
blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6',
code: cn(
'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold'
),
lead: 'text-muted-foreground text-xl',
large: 'text-lg font-semibold',
small: 'text-sm font-medium leading-none',
muted: 'text-muted-foreground text-sm',
},
},
defaultVariants: {
variant: 'default',
},
}
);
type TextVariantProps = VariantProps<typeof textVariants>;
type TextVariant = NonNullable<TextVariantProps['variant']>;
const ROLE: Partial<Record<TextVariant, Role>> = {
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
blockquote: Platform.select({ web: 'blockquote' as Role }),
code: Platform.select({ web: 'code' as Role }),
};
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
h1: '1',
h2: '2',
h3: '3',
h4: '4',
};
const TextClassContext = React.createContext<string | undefined>(undefined);
function Text({
className,
asChild = false,
variant = 'default',
...props
}: React.ComponentProps<typeof RNText> &
TextVariantProps & {
asChild?: boolean;
}) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}
role={variant ? ROLE[variant] : undefined}
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
{...props}
/>
);
}
export { Text, TextClassContext };

129
VinEye/src/data/diseases.ts Normal file
View file

@ -0,0 +1,129 @@
export interface Disease {
id: string;
name: string;
type: "fungal" | "bacterial" | "pest" | "abiotic";
icon: string;
iconColor: string;
bgColor: string;
severity: "high" | "medium" | "low";
description: string;
symptoms: string[];
treatment: string;
season: string;
}
export const VINE_DISEASES: Disease[] = [
{
id: "mildiou",
name: "diseases.mildiou.name",
type: "fungal",
icon: "water-outline",
iconColor: "#BA7517",
bgColor: "#FAEEDA",
severity: "high",
description: "diseases.mildiou.description",
symptoms: [
"diseases.mildiou.symptom1",
"diseases.mildiou.symptom2",
"diseases.mildiou.symptom3",
],
treatment: "diseases.mildiou.treatment",
season: "diseases.mildiou.season",
},
{
id: "oidium",
name: "diseases.oidium.name",
type: "fungal",
icon: "snow-outline",
iconColor: "#534AB7",
bgColor: "#EEEDFE",
severity: "high",
description: "diseases.oidium.description",
symptoms: [
"diseases.oidium.symptom1",
"diseases.oidium.symptom2",
],
treatment: "diseases.oidium.treatment",
season: "diseases.oidium.season",
},
{
id: "black_rot",
name: "diseases.blackRot.name",
type: "fungal",
icon: "ellipse",
iconColor: "#5F5E5A",
bgColor: "#F1EFE8",
severity: "high",
description: "diseases.blackRot.description",
symptoms: [
"diseases.blackRot.symptom1",
"diseases.blackRot.symptom2",
],
treatment: "diseases.blackRot.treatment",
season: "diseases.blackRot.season",
},
{
id: "esca",
name: "diseases.esca.name",
type: "fungal",
icon: "leaf-outline",
iconColor: "#993C1D",
bgColor: "#FAECE7",
severity: "medium",
description: "diseases.esca.description",
symptoms: [
"diseases.esca.symptom1",
"diseases.esca.symptom2",
],
treatment: "diseases.esca.treatment",
season: "diseases.esca.season",
},
{
id: "botrytis",
name: "diseases.botrytis.name",
type: "fungal",
icon: "cloud-outline",
iconColor: "#185FA5",
bgColor: "#E6F1FB",
severity: "medium",
description: "diseases.botrytis.description",
symptoms: [
"diseases.botrytis.symptom1",
"diseases.botrytis.symptom2",
],
treatment: "diseases.botrytis.treatment",
season: "diseases.botrytis.season",
},
{
id: "flavescence_doree",
name: "diseases.flavescence.name",
type: "bacterial",
icon: "warning-outline",
iconColor: "#A32D2D",
bgColor: "#FCEBEB",
severity: "high",
description: "diseases.flavescence.description",
symptoms: [
"diseases.flavescence.symptom1",
"diseases.flavescence.symptom2",
],
treatment: "diseases.flavescence.treatment",
season: "diseases.flavescence.season",
},
{
id: "chlorose",
name: "diseases.chlorose.name",
type: "abiotic",
icon: "sunny-outline",
iconColor: "#639922",
bgColor: "#EAF3DE",
severity: "low",
description: "diseases.chlorose.description",
symptoms: [
"diseases.chlorose.symptom1",
"diseases.chlorose.symptom2",
],
treatment: "diseases.chlorose.treatment",
season: "diseases.chlorose.season",
},
];

35
VinEye/src/data/guides.ts Normal file
View file

@ -0,0 +1,35 @@
export interface Guide {
id: string;
title: string;
subtitle: string;
icon: string;
iconColor: string;
bgColor: string;
}
export const PRACTICAL_GUIDES: Guide[] = [
{
id: "healthy_leaf",
title: "guides.healthyLeaf.title",
subtitle: "guides.healthyLeaf.subtitle",
icon: "happy-outline",
iconColor: "#1D9E75",
bgColor: "#E1F5EE",
},
{
id: "treatment_calendar",
title: "guides.treatmentCalendar.title",
subtitle: "guides.treatmentCalendar.subtitle",
icon: "book-outline",
iconColor: "#185FA5",
bgColor: "#E6F1FB",
},
{
id: "grape_varieties",
title: "guides.grapeVarieties.title",
subtitle: "guides.grapeVarieties.subtitle",
icon: "wine-outline",
iconColor: "#534AB7",
bgColor: "#EEEDFE",
},
];

View file

@ -0,0 +1,33 @@
import { useState, useCallback } from 'react';
import { runInference } from '@/services/tflite/model';
import type { Detection } from '@/types/detection';
// TODO: Remplacer par le vrai hook TFLite avec react-native-fast-tflite
export function useDetection() {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [lastDetection, setLastDetection] = useState<Detection | null>(null);
const [error, setError] = useState<string | null>(null);
const analyze = useCallback(async (imageUri?: string): Promise<Detection | null> => {
setIsAnalyzing(true);
setError(null);
try {
const detection = await runInference(imageUri);
setLastDetection(detection);
return detection;
} catch (err) {
setError('Erreur lors de l\'analyse. Veuillez réessayer.');
return null;
} finally {
setIsAnalyzing(false);
}
}, []);
const reset = useCallback(() => {
setLastDetection(null);
setError(null);
}, []);
return { analyze, isAnalyzing, lastDetection, error, reset };
}

View file

@ -0,0 +1,151 @@
import { useState, useEffect, useCallback } from 'react';
import { storage } from '@/services/storage';
import type { GameProgress, BadgeId } from '@/types/gamification';
import type { Detection } from '@/types/detection';
import {
createInitialBadges,
checkNewBadges,
getLevelNumber,
XP_REWARDS,
} from '@/utils/achievements';
const INITIAL_PROGRESS: GameProgress = {
xp: 0,
level: 1,
badges: createInitialBadges(),
streak: 0,
lastScanDate: null,
totalScans: 0,
uniqueGrapes: [],
bestStreak: 0,
highConfidenceScans: 0,
};
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function isYesterday(date: Date, reference: Date): boolean {
const yesterday = new Date(reference);
yesterday.setDate(yesterday.getDate() - 1);
return isSameDay(date, yesterday);
}
export function useGameProgress() {
const [progress, setProgress] = useState<GameProgress>(INITIAL_PROGRESS);
const [newlyUnlockedBadges, setNewlyUnlockedBadges] = useState<BadgeId[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadProgress();
}, []);
async function loadProgress() {
setIsLoading(true);
const saved = await storage.get<GameProgress>(storage.KEYS.GAME_PROGRESS);
setProgress(saved ?? INITIAL_PROGRESS);
setIsLoading(false);
}
const processDetection = useCallback(async (detection: Detection): Promise<number> => {
let xpEarned = 0;
setProgress((prev) => {
const now = new Date();
const lastDate = prev.lastScanDate ? new Date(prev.lastScanDate) : null;
// Update streak
let newStreak = prev.streak;
if (detection.result === 'vine') {
if (!lastDate || isYesterday(lastDate, now)) {
newStreak = prev.streak + 1;
} else if (!lastDate || !isSameDay(lastDate, now)) {
newStreak = 1;
}
// Same day — no streak change
}
// Calculate XP
if (detection.result === 'vine') {
xpEarned += XP_REWARDS.SCAN_SUCCESS;
if (detection.cepageId && !prev.uniqueGrapes.includes(detection.cepageId)) {
xpEarned += XP_REWARDS.NEW_CEEPAGE;
}
if (newStreak > prev.streak) {
xpEarned += XP_REWARDS.DAILY_STREAK_BONUS;
}
if (detection.confidence > 90) {
xpEarned += XP_REWARDS.HIGH_CONFIDENCE_BONUS;
}
}
const newXP = prev.xp + xpEarned;
const newLevel = getLevelNumber(newXP);
const updatedUniqueGrapes =
detection.cepageId && !prev.uniqueGrapes.includes(detection.cepageId)
? [...prev.uniqueGrapes, detection.cepageId]
: prev.uniqueGrapes;
const updatedTotalScans = prev.totalScans + 1;
const updatedHighConf =
detection.result === 'vine' && detection.confidence > 95
? prev.highConfidenceScans + 1
: prev.highConfidenceScans;
const nextProgress: GameProgress = {
...prev,
xp: newXP,
level: newLevel,
streak: newStreak,
bestStreak: Math.max(prev.bestStreak, newStreak),
lastScanDate: new Date().toISOString(),
totalScans: updatedTotalScans,
uniqueGrapes: updatedUniqueGrapes,
highConfidenceScans: updatedHighConf,
badges: prev.badges,
};
// Check badges
const { badges, newlyUnlocked } = checkNewBadges(nextProgress, prev.badges);
nextProgress.badges = badges;
if (newlyUnlocked.length > 0) {
setNewlyUnlockedBadges(newlyUnlocked);
}
// Persist
storage.set(storage.KEYS.GAME_PROGRESS, nextProgress);
return nextProgress;
});
return xpEarned;
}, []);
const clearNewlyUnlocked = useCallback(() => {
setNewlyUnlockedBadges([]);
}, []);
const resetProgress = useCallback(async () => {
await storage.remove(storage.KEYS.GAME_PROGRESS);
setProgress(INITIAL_PROGRESS);
}, []);
return {
progress,
isLoading,
processDetection,
newlyUnlockedBadges,
clearNewlyUnlocked,
resetProgress,
reload: loadProgress,
};
}

View file

@ -0,0 +1,42 @@
import { useState, useEffect, useCallback } from 'react';
import { storage } from '@/services/storage';
import type { ScanRecord } from '@/types/detection';
export function useHistory() {
const [history, setHistory] = useState<ScanRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadHistory();
}, []);
async function loadHistory() {
setIsLoading(true);
const saved = await storage.get<ScanRecord[]>(storage.KEYS.SCAN_HISTORY);
setHistory(saved ?? []);
setIsLoading(false);
}
const addScan = useCallback(async (record: ScanRecord) => {
setHistory((prev) => {
const updated = [record, ...prev];
storage.set(storage.KEYS.SCAN_HISTORY, updated);
return updated;
});
}, []);
const deleteScan = useCallback(async (id: string) => {
setHistory((prev) => {
const updated = prev.filter((r) => r.id !== id);
storage.set(storage.KEYS.SCAN_HISTORY, updated);
return updated;
});
}, []);
const clearHistory = useCallback(async () => {
await storage.remove(storage.KEYS.SCAN_HISTORY);
setHistory([]);
}, []);
return { history, isLoading, addScan, deleteScan, clearHistory, reload: loadHistory };
}

268
VinEye/src/i18n/en.json Normal file
View file

@ -0,0 +1,268 @@
{
"common": {
"scan": "Scan",
"history": "History",
"profile": "Profile",
"home": "Home",
"viewAll": "View all",
"cancel": "Cancel",
"confirm": "Confirm",
"loading": "Loading...",
"error": "Error",
"retry": "Retry",
"map": "Map",
"notifications": "Notifications",
"settings": "Settings",
"details": "Details"
},
"home": {
"greeting": "Hello, Winemaker!",
"scanButton": "Scan a vine",
"searchPlaceholder": "Search a disease, a grape variety...",
"totalScans": "Total scans",
"uniqueGrapes": "Grapes found",
"currentStreak": "Current streak",
"progression": "Progression",
"statistics": "Statistics",
"bannerTitle": "Start your collection",
"bannerSubtitle": "Discover grape varieties and grow your wine knowledge",
"bannerButton": "Get started",
"lastScan": "Last scan",
"noScansYet": "No scans yet",
"startScanning": "Start scanning!",
"tapToStart": "Tap to scan",
"frequentDiseases": "Frequent diseases",
"seasonAlert": {
"title": "High downy mildew risk",
"message": "Rain and heat expected this week. Keep an eye on your leaves."
},
"practicalGuides": "Practical guides"
},
"diseases": {
"types": {
"fungal": "Fungal",
"bacterial": "Bacterial",
"pest": "Pest",
"abiotic": "Deficiency"
},
"mildiou": {
"name": "Downy mildew",
"description": "Downy mildew is caused by the fungus Plasmopara viticola. It attacks all green parts of the vine, mainly the leaves.",
"symptom1": "Oily yellow spots on the upper surface of leaves",
"symptom2": "White cottony down on the underside",
"symptom3": "Drying and premature leaf drop",
"treatment": "Preventive copper-based treatment (Bordeaux mixture). Apply before rain, renew every 10-14 days.",
"season": "May to August — favored by heat and humidity"
},
"oidium": {
"name": "Powdery mildew",
"description": "Powdery mildew is caused by Erysiphe necator. It develops in warm, dry weather, unlike downy mildew.",
"symptom1": "White-grey powder on leaves and clusters",
"symptom2": "Berries that crack or dry out",
"treatment": "Sulfur dusting or spraying. Preventive treatments from bud break.",
"season": "April to September — favored by warm, dry weather"
},
"blackRot": {
"name": "Black rot",
"description": "Black rot is caused by Guignardia bidwellii. It causes significant damage to berries.",
"symptom1": "Circular brown spots bordered with black on leaves",
"symptom2": "Mummified, black and wrinkled berries",
"treatment": "Remove mummified berries. Preventive fungicide treatments in spring.",
"season": "May to July — favored by spring rains"
},
"esca": {
"name": "Esca",
"description": "Esca is a complex of wood diseases caused by several fungi. A chronic disease that can kill the vine.",
"symptom1": "Discoloration between leaf veins (striped appearance)",
"symptom2": "Sudden drying of foliage (apoplexy)",
"treatment": "No curative treatment. Cutting back affected vine. Protect pruning wounds.",
"season": "Symptoms visible in summer — June to September"
},
"botrytis": {
"name": "Botrytis",
"description": "Grey rot is caused by Botrytis cinerea. It attacks clusters at maturity.",
"symptom1": "Soft grey rot on berries",
"symptom2": "Characteristic grey felt on clusters",
"treatment": "Promote cluster aeration. Leaf removal. Anti-botrytis treatments before cluster closure.",
"season": "August to harvest — favored by humidity"
},
"flavescence": {
"name": "Flavescence dorée",
"description": "Phytoplasma disease transmitted by the leafhopper Scaphoideus titanus. Regulated disease, mandatory reporting.",
"symptom1": "Leaf rolling with yellow or red coloration depending on variety",
"symptom2": "Non-lignification of shoots (remain rubbery)",
"treatment": "Mandatory uprooting of contaminated vines. Insecticide treatment against the vector leafhopper.",
"season": "Symptoms visible from July"
},
"chlorose": {
"name": "Iron chlorosis",
"description": "Leaf yellowing due to iron deficiency, often linked to overly calcareous soil.",
"symptom1": "Yellowing between veins, veins remaining green",
"symptom2": "General weakening of the vine",
"treatment": "Iron chelate application. Choose rootstock adapted to calcareous soils.",
"season": "Spring — especially on calcareous soils after heavy rain"
}
},
"notifications": {
"markAllRead": "All read",
"empty": {
"title": "Nothing new",
"body": "Your notifications will appear here. Scan a vine to get started!"
},
"mock": {
"mildewAlert": {
"title": "Mildew Alert",
"body": "Favorable conditions for downy mildew detected in your area. Watch for yellow spots on leaves."
},
"sulfurTip": {
"title": "Tip: Sulfur Treatment",
"body": "Now is a good time for preventive sulfur dusting against powdery mildew."
},
"scanReminder": {
"title": "Scan Reminder",
"body": "You haven't scanned in 3 days. Keep your streak alive!"
},
"botrytisAlert": {
"title": "Botrytis Risk",
"body": "High humidity favors grey rot. Consider aerating your clusters."
},
"pruningTip": {
"title": "Tip: Spring Pruning",
"body": "Protect pruning wounds with a sealing paste to prevent esca."
},
"updateAvailable": {
"title": "Update Available",
"body": "VinEye v2.1 is available with detection for 3 new diseases."
}
}
},
"library": {
"title": "My Library",
"plants": "plants",
"empty": {
"title": "No scanned plants",
"body": "Scan your first vine to start your collection!"
}
},
"guides": {
"screenTitle": "Guides & Tips",
"tabDiseases": "Diseases",
"tabGuides": "Practical Guides",
"severity": {
"critical": "Critical",
"moderate": "Moderate",
"low": "Low"
},
"healthyLeaf": {
"title": "Recognizing a healthy leaf",
"subtitle": "Basics for beginners"
},
"treatmentCalendar": {
"title": "Treatment calendar",
"subtitle": "When and how to treat"
},
"grapeVarieties": {
"title": "Bordeaux grape varieties",
"subtitle": "Merlot, Cabernet, Sauvignon..."
}
},
"scanner": {
"scanning": "Analyzing...",
"pointCamera": "Point the camera at a vine",
"confidence": "Confidence",
"capture": "Capture",
"analyzing": "Analyzing...",
"permissionRequired": "Camera permission required",
"permissionMessage": "VinEye needs camera access to detect grapevines.",
"grantPermission": "Grant camera access",
"identify": "Identify the plant"
},
"result": {
"vineDetected": "Vine detected!",
"notVine": "This is not a vine",
"uncertain": "Uncertain result",
"grape": "Probable variety",
"origin": "Origin",
"characteristics": "Characteristics",
"regions": "Typical regions",
"xpEarned": "XP earned",
"scanAgain": "Scan again",
"viewHistory": "View history",
"color": "Color",
"red": "Red",
"white": "White",
"rose": "Rosé"
},
"history": {
"title": "History",
"empty": "No scans in history",
"filter": {
"all": "All",
"vine": "Vine",
"notVine": "Not vine"
},
"search": "Search a variety...",
"sortBy": "Sort by",
"date": "Date",
"confidence": "Confidence",
"deleteConfirm": "Delete this scan?"
},
"profile": {
"title": "Profile",
"stats": "Statistics",
"totalScans": "Total scans",
"successRate": "Success rate",
"bestStreak": "Best streak",
"uniqueGrapes": "Unique grapes",
"badges": "Badges",
"nextLevel": "Next level",
"language": "Language",
"resetData": "Reset data",
"resetConfirm": "Are you sure you want to reset all data?",
"days": "days",
"xpTotal": "Total XP"
},
"settings": {
"general": "General",
"app": "Application",
"editProfile": "Edit profile",
"privacy": "Privacy",
"premiumStatus": "Premium Status",
"inactive": "Inactive",
"appearance": "Appearance",
"helpCenter": "Help Center",
"terms": "Terms of Use",
"referTitle": "Refer a friend",
"referBody": "Share VinEye and earn bonus XP for every friend you invite."
},
"achievements": {
"firstScan": "First Scan",
"firstScanDesc": "First scan completed",
"connoisseur": "Connoisseur",
"connoisseurDesc": "10 different grapes identified",
"onFire": "On Fire",
"onFireDesc": "7-day consecutive streak",
"sharpEye": "Sharp Eye",
"sharpEyeDesc": "5 scans with confidence > 95%",
"explorer": "Explorer",
"explorerDesc": "Scans in 3 different regions",
"perfectionist": "Perfectionist",
"perfectionistDesc": "50 successful scans",
"master": "Master Ampelographer",
"masterDesc": "All badges unlocked",
"unlocked": "Badge unlocked!",
"locked": "Locked",
"xpEarned": "+{{xp}} XP"
},
"levels": {
"bud": "Bud",
"leaf": "Leaf",
"shoot": "Shoot",
"cluster": "Cluster",
"harvester": "Harvester",
"winemaker": "Winemaker",
"cellarMaster": "Cellar Master",
"level": "Level {{level}}",
"xpToNext": "{{xp}} XP to next level"
}
}

268
VinEye/src/i18n/fr.json Normal file
View file

@ -0,0 +1,268 @@
{
"common": {
"scan": "Scanner",
"history": "Historique",
"profile": "Profil",
"home": "Accueil",
"viewAll": "Voir tout",
"cancel": "Annuler",
"confirm": "Confirmer",
"loading": "Chargement...",
"error": "Erreur",
"retry": "Réessayer",
"map": "Carte",
"notifications": "Notifications",
"settings": "Paramètres",
"details": "Détails"
},
"home": {
"greeting": "Bonjour, Vigneron !",
"scanButton": "Scanner une vigne",
"searchPlaceholder": "Rechercher une maladie, un cépage...",
"totalScans": "Scans totaux",
"uniqueGrapes": "Cépages trouvés",
"currentStreak": "Streak actuel",
"progression": "Progression",
"statistics": "Statistiques",
"bannerTitle": "Commencez votre collection",
"bannerSubtitle": "Découvrez les cépages et enrichissez vos connaissances viticoles",
"bannerButton": "Commencer",
"lastScan": "Dernier scan",
"noScansYet": "Aucun scan pour l'instant",
"startScanning": "Commencez à scanner !",
"tapToStart": "Appuyez pour scanner",
"frequentDiseases": "Maladies fréquentes",
"seasonAlert": {
"title": "Risque mildiou élevé",
"message": "Pluie et chaleur prévues cette semaine. Surveillez vos feuilles."
},
"practicalGuides": "Guides pratiques"
},
"diseases": {
"types": {
"fungal": "Fongique",
"bacterial": "Bactérien",
"pest": "Ravageur",
"abiotic": "Carence"
},
"mildiou": {
"name": "Mildiou",
"description": "Le mildiou est causé par le champignon Plasmopara viticola. Il attaque toutes les parties vertes de la vigne, principalement les feuilles.",
"symptom1": "Taches jaunes huileuses sur la face supérieure des feuilles",
"symptom2": "Duvet blanc cotonneux sur la face inférieure",
"symptom3": "Dessèchement et chute prématurée des feuilles",
"treatment": "Traitement préventif à base de cuivre (bouillie bordelaise). Appliquer avant les pluies, renouveler tous les 10-14 jours.",
"season": "Mai à août — favorisé par la chaleur et l'humidité"
},
"oidium": {
"name": "Oïdium",
"description": "L'oïdium est causé par Erysiphe necator. Il se développe par temps chaud et sec, contrairement au mildiou.",
"symptom1": "Poudre blanche-grisâtre sur feuilles et grappes",
"symptom2": "Baies qui éclatent ou se dessèchent",
"treatment": "Soufre en poudrage ou pulvérisation. Traitements préventifs dès le débourrement.",
"season": "Avril à septembre — favorisé par temps chaud et sec"
},
"blackRot": {
"name": "Black rot",
"description": "Le black rot est causé par Guignardia bidwellii. Il provoque des dégâts importants sur les baies.",
"symptom1": "Taches brunes circulaires bordées de noir sur les feuilles",
"symptom2": "Baies momifiées, noires et ridées",
"treatment": "Éliminer les baies momifiées. Traitements fongicides préventifs au printemps.",
"season": "Mai à juillet — favorisé par les pluies printanières"
},
"esca": {
"name": "Esca",
"description": "L'esca est un complexe de maladies du bois causé par plusieurs champignons. Maladie chronique qui peut tuer le cep.",
"symptom1": "Décolorations entre les nervures des feuilles (aspect tigré)",
"symptom2": "Dessèchement brutal du feuillage (apoplexie)",
"treatment": "Aucun traitement curatif. Recépage du cep atteint. Protéger les plaies de taille.",
"season": "Symptômes visibles en été — juin à septembre"
},
"botrytis": {
"name": "Botrytis",
"description": "La pourriture grise est causée par Botrytis cinerea. Elle attaque les grappes à maturité.",
"symptom1": "Pourriture molle grise sur les baies",
"symptom2": "Feutrage gris caractéristique sur les grappes",
"treatment": "Favoriser l'aération des grappes. Effeuillage. Traitements anti-botrytis avant fermeture de la grappe.",
"season": "Août à vendanges — favorisé par l'humidité"
},
"flavescence": {
"name": "Flavescence dorée",
"description": "Maladie à phytoplasme transmise par la cicadelle Scaphoideus titanus. Maladie réglementée, déclaration obligatoire.",
"symptom1": "Enroulement des feuilles avec coloration jaune ou rouge selon le cépage",
"symptom2": "Non-aoûtement des rameaux (restent caoutchouteux)",
"treatment": "Arrachage obligatoire des ceps contaminés. Traitement insecticide contre la cicadelle vectrice.",
"season": "Symptômes visibles à partir de juillet"
},
"chlorose": {
"name": "Chlorose ferrique",
"description": "Jaunissement des feuilles dû à une carence en fer, souvent lié à un sol trop calcaire.",
"symptom1": "Jaunissement entre les nervures, nervures restant vertes",
"symptom2": "Affaiblissement général de la vigne",
"treatment": "Apport de chélates de fer. Choix d'un porte-greffe adapté aux sols calcaires.",
"season": "Printemps — surtout sur sols calcaires après de fortes pluies"
}
},
"notifications": {
"markAllRead": "Tout lu",
"empty": {
"title": "Rien de nouveau",
"body": "Vos notifications apparaîtront ici. Scannez une vigne pour commencer !"
},
"mock": {
"mildewAlert": {
"title": "Alerte Mildiou",
"body": "Conditions favorables au mildiou détectées dans votre zone. Surveillez les taches jaunes sur les feuilles."
},
"sulfurTip": {
"title": "Conseil : Traitement soufre",
"body": "C'est le bon moment pour un poudrage de soufre préventif contre l'oïdium."
},
"scanReminder": {
"title": "Rappel de scan",
"body": "Vous n'avez pas scanné depuis 3 jours. Gardez votre streak en vie !"
},
"botrytisAlert": {
"title": "Risque Botrytis",
"body": "L'humidité élevée favorise la pourriture grise. Pensez à aérer vos grappes."
},
"pruningTip": {
"title": "Conseil : Taille de printemps",
"body": "Protégez vos plaies de taille avec un mastic cicatrisant pour prévenir l'esca."
},
"updateAvailable": {
"title": "Mise à jour disponible",
"body": "VinEye v2.1 est disponible avec la détection de 3 nouvelles maladies."
}
}
},
"library": {
"title": "Ma bibliothèque",
"plants": "plantes",
"empty": {
"title": "Aucune plante scannée",
"body": "Scannez votre première vigne pour commencer votre collection !"
}
},
"guides": {
"screenTitle": "Guides & Conseils",
"tabDiseases": "Maladies",
"tabGuides": "Guides Pratiques",
"severity": {
"critical": "Critique",
"moderate": "Modéré",
"low": "Faible"
},
"healthyLeaf": {
"title": "Reconnaître une feuille saine",
"subtitle": "Les bases pour débutants"
},
"treatmentCalendar": {
"title": "Calendrier de traitement",
"subtitle": "Quand et comment traiter"
},
"grapeVarieties": {
"title": "Les cépages bordelais",
"subtitle": "Merlot, Cabernet, Sauvignon..."
}
},
"scanner": {
"scanning": "Analyse en cours...",
"pointCamera": "Pointez la caméra vers une vigne",
"confidence": "Confiance",
"capture": "Capturer",
"analyzing": "Analyse...",
"permissionRequired": "Permission caméra requise",
"permissionMessage": "VinEye nécessite l'accès à votre caméra pour détecter les vignes.",
"grantPermission": "Autoriser la caméra",
"identify": "Identifier la plante"
},
"result": {
"vineDetected": "Vigne détectée !",
"notVine": "Ce n'est pas une vigne",
"uncertain": "Résultat incertain",
"grape": "Cépage probable",
"origin": "Origine",
"characteristics": "Caractéristiques",
"regions": "Régions typiques",
"xpEarned": "XP gagnés",
"scanAgain": "Scanner encore",
"viewHistory": "Voir l'historique",
"color": "Couleur",
"red": "Rouge",
"white": "Blanc",
"rose": "Rosé"
},
"history": {
"title": "Historique",
"empty": "Aucun scan dans l'historique",
"filter": {
"all": "Tous",
"vine": "Vigne",
"notVine": "Pas vigne"
},
"search": "Rechercher un cépage...",
"sortBy": "Trier par",
"date": "Date",
"confidence": "Confiance",
"deleteConfirm": "Supprimer ce scan ?"
},
"profile": {
"title": "Profil",
"stats": "Statistiques",
"totalScans": "Total scans",
"successRate": "Taux de réussite",
"bestStreak": "Meilleur streak",
"uniqueGrapes": "Cépages uniques",
"badges": "Badges",
"nextLevel": "Prochain niveau",
"language": "Langue",
"resetData": "Réinitialiser les données",
"resetConfirm": "Êtes-vous sûr de vouloir réinitialiser toutes les données ?",
"days": "jours",
"xpTotal": "XP total"
},
"settings": {
"general": "Général",
"app": "Application",
"editProfile": "Modifier le profil",
"privacy": "Confidentialité",
"premiumStatus": "Statut Premium",
"inactive": "Inactif",
"appearance": "Apparence",
"helpCenter": "Centre d'aide",
"terms": "Conditions d'utilisation",
"referTitle": "Inviter un ami",
"referBody": "Partagez VinEye et gagnez des XP bonus pour chaque ami invité."
},
"achievements": {
"firstScan": "Premier Scan",
"firstScanDesc": "Premier scan effectué",
"connoisseur": "Connaisseur",
"connoisseurDesc": "10 cépages différents identifiés",
"onFire": "En Feu",
"onFireDesc": "Streak de 7 jours consécutifs",
"sharpEye": "Œil de Lynx",
"sharpEyeDesc": "5 scans avec confiance > 95%",
"explorer": "Explorateur",
"explorerDesc": "Scans dans 3 régions différentes",
"perfectionist": "Perfectionniste",
"perfectionistDesc": "50 scans réussis",
"master": "Maître Ampélographe",
"masterDesc": "Tous les badges débloqués",
"unlocked": "Badge débloqué !",
"locked": "Verrouillé",
"xpEarned": "+{{xp}} XP"
},
"levels": {
"bud": "Bourgeon",
"leaf": "Feuille",
"shoot": "Sarment",
"cluster": "Grappe",
"harvester": "Vendangeur",
"winemaker": "Vigneron",
"cellarMaster": "Maître de Chai",
"level": "Niveau {{level}}",
"xpToNext": "{{xp}} XP pour le prochain niveau"
}
}

25
VinEye/src/i18n/index.ts Normal file
View file

@ -0,0 +1,25 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLocales } from 'expo-localization';
import fr from './fr.json';
import en from './en.json';
const deviceLocale = getLocales()[0]?.languageCode ?? 'fr';
i18n
.use(initReactI18next)
.init({
resources: {
fr: { translation: fr },
en: { translation: en },
},
lng: deviceLocale === 'fr' ? 'fr' : 'en',
fallbackLng: 'fr',
interpolation: {
escapeValue: false,
},
compatibilityJSON: 'v4',
});
export default i18n;

81
VinEye/src/lib/theme.ts Normal file
View file

@ -0,0 +1,81 @@
import { DarkTheme, DefaultTheme, type Theme } from '@react-navigation/native';
export const THEME = {
light: {
background: 'hsl(0 0% 98%)',
foreground: 'hsl(0 0% 10.6%)',
card: 'hsl(0 0% 100%)',
cardForeground: 'hsl(0 0% 10.6%)',
popover: 'hsl(0 0% 100%)',
popoverForeground: 'hsl(0 0% 10.6%)',
primary: 'hsl(153 42% 30%)',
primaryForeground: 'hsl(0 0% 100%)',
secondary: 'hsl(147 46% 89%)',
secondaryForeground: 'hsl(153 42% 30%)',
muted: 'hsl(0 0% 96.1%)',
mutedForeground: 'hsl(0 0% 42%)',
accent: 'hsl(147 46% 89%)',
accentForeground: 'hsl(153 42% 30%)',
destructive: 'hsl(14 100% 63%)',
border: 'hsl(0 0% 87.8%)',
input: 'hsl(0 0% 87.8%)',
ring: 'hsl(153 42% 30%)',
radius: '0.625rem',
chart1: 'hsl(153 42% 30%)',
chart2: 'hsl(147 46% 89%)',
chart3: 'hsl(282 100% 22%)',
chart4: 'hsl(37 100% 65%)',
chart5: 'hsl(14 100% 63%)',
},
dark: {
background: 'hsl(0 0% 6.7%)',
foreground: 'hsl(0 0% 98%)',
card: 'hsl(0 0% 10.6%)',
cardForeground: 'hsl(0 0% 98%)',
popover: 'hsl(0 0% 10.6%)',
popoverForeground: 'hsl(0 0% 98%)',
primary: 'hsl(147 46% 89%)',
primaryForeground: 'hsl(153 42% 30%)',
secondary: 'hsl(153 42% 30%)',
secondaryForeground: 'hsl(147 46% 89%)',
muted: 'hsl(0 0% 17.6%)',
mutedForeground: 'hsl(0 0% 61%)',
accent: 'hsl(153 42% 30%)',
accentForeground: 'hsl(147 46% 89%)',
destructive: 'hsl(14 100% 63%)',
border: 'hsl(0 0% 17.6%)',
input: 'hsl(0 0% 17.6%)',
ring: 'hsl(147 46% 89%)',
radius: '0.625rem',
chart1: 'hsl(147 46% 89%)',
chart2: 'hsl(153 42% 30%)',
chart3: 'hsl(282 100% 50%)',
chart4: 'hsl(37 100% 65%)',
chart5: 'hsl(14 100% 63%)',
},
};
export const NAV_THEME: Record<'light' | 'dark', Theme> = {
light: {
...DefaultTheme,
colors: {
background: THEME.light.background,
border: THEME.light.border,
card: THEME.light.card,
notification: THEME.light.destructive,
primary: THEME.light.primary,
text: THEME.light.foreground,
},
},
dark: {
...DarkTheme,
colors: {
background: THEME.dark.background,
border: THEME.dark.border,
card: THEME.dark.card,
notification: THEME.dark.destructive,
primary: THEME.dark.primary,
text: THEME.dark.foreground,
},
},
};

6
VinEye/src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,178 @@
import React from "react";
import { View, Text, TouchableOpacity, Platform } from "react-native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import * as Haptics from "expo-haptics";
import { House, ScanLine, Map, BookOpen, Leaf } from "lucide-react-native";
import HomeScreen from "@/screens/HomeScreen";
import ScannerScreen from "@/screens/ScannerScreen";
import MapScreen from "@/screens/MapScreen";
import GuidesScreen from "@/screens/GuidesScreen";
import LibraryScreen from "@/screens/LibraryScreen";
import { colors } from "@/theme/colors";
const Tab = createBottomTabNavigator();
const TAB_ICONS: Record<string, any> = {
Home: House,
Guides: BookOpen,
Library: Leaf,
Map: Map,
};
function MyCustomTabBar({ state, descriptors, navigation }: any) {
const insets = useSafeAreaInsets();
return (
<View
style={{
flexDirection: "row",
backgroundColor: colors.surface,
borderTopWidth: 1,
borderTopColor: colors.neutral[300],
paddingBottom: insets.bottom,
paddingTop: 8,
alignItems: "flex-end",
}}
>
{state.routes.map((route: any, index: number) => {
const { options } = descriptors[route.key];
const isFocused = state.index === index;
const label = options.tabBarLabel || route.name;
const isScanner = route.name === "Scanner";
const onPress = () => {
if (Platform.OS !== "web") {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
const event = navigation.emit({
type: "tabPress",
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
// FAB central pour Scanner
if (isScanner) {
return (
<TouchableOpacity
key={route.key}
onPress={onPress}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel={label}
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary[800],
alignItems: "center",
justifyContent: "center",
marginTop: -25,
shadowColor: colors.primary[900],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
}}
>
<ScanLine size={26} color="#FFFFFF" />
</View>
</TouchableOpacity>
);
}
// Onglets classiques (Home, Map)
const Icon = TAB_ICONS[route.name];
const tintColor = isFocused ? colors.primary[700] : colors.neutral[400];
return (
<TouchableOpacity
key={route.key}
onPress={onPress}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={label}
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: 6,
}}
>
{Icon && (
<Icon
size={22}
color={tintColor}
strokeWidth={isFocused ? 2.5 : 1.8}
/>
)}
<Text
numberOfLines={1}
style={{
fontSize: 11,
marginTop: 4,
color: tintColor,
fontWeight: isFocused ? "600" : "400",
}}
>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
);
}
export default function BottomTabNavigator() {
const { t } = useTranslation();
return (
<Tab.Navigator
tabBar={(props) => <MyCustomTabBar {...props} />}
screenOptions={{ headerShown: false }}
>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{ tabBarLabel: t("common.home") }}
/>
<Tab.Screen
name="Guides"
component={GuidesScreen}
options={{ tabBarLabel: t("guides.screenTitle") }}
/>
<Tab.Screen
name="Scanner"
component={ScannerScreen}
options={{ tabBarLabel: t("common.scan") }}
/>
<Tab.Screen
name="Library"
component={LibraryScreen}
options={{ tabBarLabel: t("library.title") }}
/>
<Tab.Screen
name="Map"
component={MapScreen}
options={{ tabBarLabel: t("common.map") }}
/>
</Tab.Navigator>
);
}

View file

@ -0,0 +1,59 @@
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { NavigationContainer } from '@react-navigation/native';
import SplashScreen from '@/screens/SplashScreen';
import ResultScreen from '@/screens/ResultScreen';
import NotificationsScreen from '@/screens/NotificationsScreen';
import ProfileScreen from '@/screens/ProfileScreen';
import SettingsScreen from '@/screens/SettingsScreen';
import GuidesScreen from '@/screens/GuidesScreen';
import LibraryScreen from '@/screens/LibraryScreen';
import BottomTabNavigator from './BottomTabNavigator';
import linking from './linking';
import type { RootStackParamList } from '@/types/navigation';
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function RootNavigator() {
return (
<NavigationContainer linking={linking}>
<Stack.Navigator
initialRouteName="Splash"
screenOptions={{ headerShown: false, animation: 'fade' }}
>
<Stack.Screen name="Splash" component={SplashScreen} />
<Stack.Screen name="Main" component={BottomTabNavigator} />
<Stack.Screen
name="Result"
component={ResultScreen}
options={{ animation: 'slide_from_bottom', presentation: 'modal' }}
/>
<Stack.Screen
name="Notifications"
component={NotificationsScreen}
options={{ animation: 'slide_from_right' }}
/>
<Stack.Screen
name="Profile"
component={ProfileScreen}
options={{ animation: 'slide_from_right' }}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{ animation: 'slide_from_right' }}
/>
<Stack.Screen
name="Guides"
component={GuidesScreen}
options={{ animation: 'slide_from_right' }}
/>
<Stack.Screen
name="Library"
component={LibraryScreen}
options={{ animation: 'slide_from_right' }}
/>
</Stack.Navigator>
</NavigationContainer>
);
}

View file

@ -0,0 +1,26 @@
import type { LinkingOptions } from '@react-navigation/native';
import type { RootStackParamList } from '@/types/navigation';
const linking: LinkingOptions<RootStackParamList> = {
prefixes: ['vineye://'],
config: {
screens: {
Splash: 'splash',
Main: {
screens: {
Home: 'home',
Scanner: 'scan',
Map: 'map',
},
},
Result: 'result',
Notifications: 'notifications',
Profile: 'profile',
Settings: 'settings',
Guides: 'guides',
Library: 'library',
},
},
};
export default linking;

View file

@ -0,0 +1,373 @@
import { useState } from "react";
import {
View,
FlatList,
TouchableOpacity,
StyleSheet,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import { VINE_DISEASES } from "@/data/diseases";
import { PRACTICAL_GUIDES } from "@/data/guides";
import type { Disease } from "@/data/diseases";
import type { Guide } from "@/data/guides";
type Tab = "diseases" | "guides";
const DISEASE_TYPE_KEYS: Record<Disease["type"], string> = {
fungal: "diseases.types.fungal",
bacterial: "diseases.types.bacterial",
pest: "diseases.types.pest",
abiotic: "diseases.types.abiotic",
};
const SEVERITY_CONFIG: Record<
Disease["severity"],
{ label: string; color: string; bg: string }
> = {
high: { label: "guides.severity.critical", color: "#DC2626", bg: "#FEF2F2" },
medium: { label: "guides.severity.moderate", color: "#F59E0B", bg: "#FFFBEB" },
low: { label: "guides.severity.low", color: "#10B981", bg: "#ECFDF5" },
};
function DiseaseCard({ item }: { item: Disease }) {
const { t } = useTranslation();
const severity = SEVERITY_CONFIG[item.severity];
return (
<TouchableOpacity activeOpacity={0.7} style={styles.diseaseCard}>
{/* Image placeholder */}
<View style={[styles.diseaseBanner, { backgroundColor: item.bgColor }]}>
<Ionicons name={item.icon as any} size={36} color={item.iconColor} />
{/* Severity badge */}
<View style={[styles.severityBadge, { backgroundColor: severity.bg }]}>
<View style={[styles.severityDot, { backgroundColor: severity.color }]} />
<Text style={[styles.severityText, { color: severity.color }]}>
{t(severity.label)}
</Text>
</View>
</View>
{/* Content */}
<View style={styles.diseaseContent}>
<View style={[styles.typePill, { backgroundColor: `${item.iconColor}12` }]}>
<Text style={[styles.typeText, { color: item.iconColor }]}>
{t(DISEASE_TYPE_KEYS[item.type])}
</Text>
</View>
<Text style={styles.diseaseName} numberOfLines={1}>
{t(item.name)}
</Text>
<Text style={styles.diseaseSeason} numberOfLines={1}>
{t(item.season)}
</Text>
</View>
</TouchableOpacity>
);
}
function GuideCard({ item }: { item: Guide }) {
const { t } = useTranslation();
return (
<TouchableOpacity activeOpacity={0.7} style={styles.guideCard}>
<View style={[styles.guideIcon, { backgroundColor: `${item.iconColor}12` }]}>
<Ionicons name={item.icon as any} size={24} color={item.iconColor} />
</View>
<View style={styles.guideText}>
<Text style={styles.guideTitle} numberOfLines={1}>
{t(item.title)}
</Text>
<Text style={styles.guideSubtitle} numberOfLines={1}>
{t(item.subtitle)}
</Text>
</View>
<View style={styles.chevronWrap}>
<Ionicons name="chevron-forward" size={14} color="#D1D1D6" />
</View>
</TouchableOpacity>
);
}
export default function GuidesScreen() {
const { t } = useTranslation();
const navigation = useNavigation();
const [activeTab, setActiveTab] = useState<Tab>("diseases");
function handleBack() {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
(navigation as any).navigate("Main");
}
}
return (
<SafeAreaView style={styles.safe} edges={["top"]}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color="#1A1A1A" />
</TouchableOpacity>
<Text style={styles.headerTitle}>{t("guides.screenTitle")}</Text>
<View style={{ width: 44 }} />
</View>
{/* Segmented Control */}
<View style={styles.tabContainer}>
<View style={styles.tabBar}>
<TouchableOpacity
style={[styles.tab, activeTab === "diseases" && styles.tabActive]}
onPress={() => setActiveTab("diseases")}
activeOpacity={0.7}
>
<Text
style={[
styles.tabText,
activeTab === "diseases" && styles.tabTextActive,
]}
>
{t("guides.tabDiseases")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === "guides" && styles.tabActive]}
onPress={() => setActiveTab("guides")}
activeOpacity={0.7}
>
<Text
style={[
styles.tabText,
activeTab === "guides" && styles.tabTextActive,
]}
>
{t("guides.tabGuides")}
</Text>
</TouchableOpacity>
</View>
</View>
{/* Content */}
{activeTab === "diseases" ? (
<FlatList
data={VINE_DISEASES}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <DiseaseCard item={item} />}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
/>
) : (
<FlatList
data={PRACTICAL_GUIDES}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <GuideCard item={item} />}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
/>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
backgroundColor: "#F8F9FB",
},
// Header
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "transparent",
},
backBtn: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
borderRadius: 14,
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#F0F0F0",
},
headerTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1A1A1A",
letterSpacing: -0.4,
},
// Tabs
tabContainer: {
paddingHorizontal: 20,
paddingBottom: 12,
},
tabBar: {
flexDirection: "row",
backgroundColor: "#EEEFF1",
borderRadius: 14,
padding: 4,
},
tab: {
flex: 1,
paddingVertical: 10,
alignItems: "center",
borderRadius: 11,
},
tabActive: {
backgroundColor: "#FFFFFF",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 4,
},
android: { elevation: 2 },
}),
},
tabText: {
fontSize: 14,
fontWeight: "500",
color: "#8E8E93",
},
tabTextActive: {
color: "#1A1A1A",
fontWeight: "600",
},
// List
listContent: {
padding: 20,
paddingBottom: 40,
},
// Disease card
diseaseCard: {
backgroundColor: "#FFFFFF",
borderRadius: 24,
overflow: "hidden",
borderWidth: 1,
borderColor: "#F0F0F0",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 10,
},
android: { elevation: 2 },
}),
},
diseaseBanner: {
height: 120,
alignItems: "center",
justifyContent: "center",
position: "relative",
},
severityBadge: {
position: "absolute",
top: 12,
right: 12,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 20,
gap: 5,
},
severityDot: {
width: 6,
height: 6,
borderRadius: 3,
},
severityText: {
fontSize: 11,
fontWeight: "600",
},
diseaseContent: {
padding: 16,
},
typePill: {
alignSelf: "flex-start",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
marginBottom: 8,
},
typeText: {
fontSize: 11,
fontWeight: "600",
},
diseaseName: {
fontSize: 16,
fontWeight: "500",
color: "#1A1A1A",
marginBottom: 4,
},
diseaseSeason: {
fontSize: 12,
fontWeight: "400",
color: "#8E8E93",
},
// Guide card
guideCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF",
borderRadius: 24,
padding: 14,
borderWidth: 1,
borderColor: "#F0F0F0",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 8,
},
android: { elevation: 2 },
}),
},
guideIcon: {
width: 52,
height: 52,
borderRadius: 18,
alignItems: "center",
justifyContent: "center",
},
guideText: {
flex: 1,
marginLeft: 14,
},
guideTitle: {
fontSize: 15,
fontWeight: "500",
color: "#1A1A1A",
marginBottom: 2,
},
guideSubtitle: {
fontSize: 13,
fontWeight: "400",
color: "#8E8E93",
},
chevronWrap: {
marginLeft: 8,
backgroundColor: "#F8F9FA",
padding: 6,
borderRadius: 12,
},
});

View file

@ -0,0 +1,217 @@
import { useState, useMemo } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Ionicons } from '@expo/vector-icons';
import { ScanList } from "@/components/history/ScanList";
import { useHistory } from "@/hooks/useHistory";
import { getCepageById } from "@/utils/cepages";
import { colors } from "@/theme/colors";
import { typography } from "@/theme/typography";
import { spacing } from "@/theme/spacing";
import type { DetectionResult, ScanRecord } from "@/types/detection";
type Filter = "all" | "vine" | "not_vine";
type SortBy = "date" | "confidence";
export default function HistoryScreen() {
const { t } = useTranslation();
const { history, deleteScan } = useHistory();
const [filter, setFilter] = useState<Filter>("all");
const [sortBy, setSortBy] = useState<SortBy>("date");
const [search, setSearch] = useState("");
const filteredHistory = useMemo(() => {
let result: ScanRecord[] = [...history];
// Filter by result type
if (filter === "vine") {
result = result.filter((r) => r.detection.result === "vine");
} else if (filter === "not_vine") {
result = result.filter((r) => r.detection.result !== "vine");
}
// Filter by ceepage search
if (search.trim()) {
const q = search.toLowerCase().trim();
result = result.filter((r) => {
if (!r.detection.cepageId) return false;
const c = getCepageById(r.detection.cepageId);
return (
c?.name.fr.toLowerCase().includes(q) ||
c?.name.en.toLowerCase().includes(q)
);
});
}
// Sort
result.sort((a, b) => {
if (sortBy === "date") {
return (
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
return b.detection.confidence - a.detection.confidence;
});
return result;
}, [history, filter, sortBy, search]);
function handleDelete(id: string) {
Alert.alert(t("history.deleteConfirm"), undefined, [
{ text: t("common.cancel"), style: "cancel" },
{
text: "Supprimer",
style: "destructive",
onPress: () => deleteScan(id),
},
]);
}
return (
<SafeAreaView style={styles.safe} edges={["top"]}>
<View style={styles.header}>
<Text style={styles.title}>{t("history.title")}</Text>
<View style={styles.header}>
<Ionicons name="search-outline" size={22} color={colors.neutral[800]} />
<TextInput
style={styles.search}
placeholder={t("history.search")}
placeholderTextColor={colors.neutral[500]}
value={search}
onChangeText={setSearch}
/>
</View>
{/* Search */}
{/* <TextInput
style={styles.search}
placeholder={t("history.search")}
placeholderTextColor={colors.neutral[500]}
value={search}
onChangeText={setSearch}
/> */}
{/* Filters */}
<View style={styles.filters}>
{(["all", "vine", "not_vine"] as Filter[]).map((f) => (
<TouchableOpacity
key={f}
style={[styles.filterBtn, filter === f && styles.filterBtnActive]}
onPress={() => setFilter(f)}
>
<Text
style={[
styles.filterText,
filter === f && styles.filterTextActive,
]}
>
{t(`history.filter.${f === "not_vine" ? "notVine" : f}`)}
</Text>
</TouchableOpacity>
))}
</View>
{/* Sort */}
<View style={styles.sortRow}>
<Text style={styles.sortLabel}>{t("history.sortBy")} :</Text>
{(["date", "confidence"] as SortBy[]).map((s) => (
<TouchableOpacity
key={s}
onPress={() => setSortBy(s)}
style={[styles.sortBtn, sortBy === s && styles.sortBtnActive]}
>
<Text
style={[styles.sortText, sortBy === s && styles.sortTextActive]}
>
{t(`history.${s}`)}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<ScanList records={filteredHistory} onDelete={handleDelete} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.background },
header: {
paddingHorizontal: spacing.base,
paddingTop: spacing.base,
paddingBottom: spacing.sm,
gap: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.neutral[300],
backgroundColor: colors.surface,
},
title: {
fontSize: typography.fontSizes["2xl"],
fontWeight: typography.fontWeights.bold,
color: colors.neutral[900],
},
search: {
backgroundColor: colors.neutral[200],
borderRadius: 10,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: typography.fontSizes.sm,
color: colors.neutral[900],
},
filters: {
flexDirection: "row",
gap: spacing.sm,
},
filterBtn: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 20,
backgroundColor: colors.neutral[200],
},
filterBtnActive: {
backgroundColor: colors.primary[700],
},
filterText: {
fontSize: typography.fontSizes.sm,
color: colors.neutral[700],
fontWeight: typography.fontWeights.medium,
},
filterTextActive: {
color: colors.surface,
},
sortRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
sortLabel: {
fontSize: typography.fontSizes.xs,
color: colors.neutral[600],
},
sortBtn: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: 8,
},
sortBtnActive: {
backgroundColor: colors.primary[200],
},
sortText: {
fontSize: typography.fontSizes.xs,
color: colors.neutral[600],
},
sortTextActive: {
color: colors.primary[800],
fontWeight: typography.fontWeights.semibold,
},
});

View file

@ -0,0 +1,62 @@
import { View, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import type { RootStackParamList } from "@/types/navigation";
import SearchHeader from "@/components/home/SearchHeader";
import SearchSection from "@/components/home/SearchSection";
import SectionHeader from "@/components/home/components/homeheader";
import FrequentDiseases from "@/components/home/FrequentDiseases";
import SeasonAlert from "@/components/home/SeasonAlert";
import PracticalGuides from "@/components/home/PracticalGuides";
import HeroScanner from "@/components/home/HomeCta";
type Nav = NativeStackNavigationProp<RootStackParamList>;
export default function HomeScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
<ScrollView
className="flex-1"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 24 }}
>
<SearchHeader />
<SearchSection />
<HeroScanner />
{/* Frequent diseases carousel */}
<View className="mb-6 gap-3">
<View className="px-5">
<SectionHeader
title={t("home.frequentDiseases")}
onViewAll={() => navigation.navigate("Guides")}
/>
</View>
<FrequentDiseases />
</View>
{/* Season alert */}
<SeasonAlert />
{/* Practical guides */}
<View className="mx-5 mb-6 gap-3">
<SectionHeader
title={t("home.practicalGuides")}
onViewAll={() => navigation.navigate("Guides")}
/>
<PracticalGuides />
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
);
}

View file

@ -0,0 +1,296 @@
import { useState } from "react";
import {
View,
FlatList,
TouchableOpacity,
StyleSheet,
Platform,
Dimensions,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import SearchHeader from "@/components/home/SearchHeader";
import SearchSection from "@/components/home/SearchSection";
// ─── Types ───────────────────────────────────────────────
interface ScannedPlant {
id: string;
name: string;
date: string;
color: string;
iconColor: string;
favorite: boolean;
}
// ─── Mock Data ───────────────────────────────────────────
const INITIAL_PLANTS: ScannedPlant[] = [
{
id: "1",
name: "Merlot",
date: "2026-04-01",
color: "#E9F5EC",
iconColor: colors.primary[700],
favorite: true,
},
{
id: "2",
name: "Cabernet Sauvignon",
date: "2026-03-28",
color: "#EEEDFE",
iconColor: "#534AB7",
favorite: false,
},
{
id: "3",
name: "Chardonnay",
date: "2026-03-25",
color: "#FAEEDA",
iconColor: "#BA7517",
favorite: true,
},
{
id: "4",
name: "Pinot Noir",
date: "2026-03-20",
color: "#FAECE7",
iconColor: "#993C1D",
favorite: false,
},
{
id: "5",
name: "Sauvignon Blanc",
date: "2026-03-15",
color: "#E6F1FB",
iconColor: "#185FA5",
favorite: false,
},
{
id: "6",
name: "Grenache",
date: "2026-03-10",
color: "#FCEBEB",
iconColor: "#A32D2D",
favorite: true,
},
];
const { width } = Dimensions.get("window");
const CARD_WIDTH = (width - 56) / 2;
// ─── Component ───────────────────────────────────────────
export default function LibraryScreen() {
const { t } = useTranslation();
const [plants, setPlants] = useState(INITIAL_PLANTS);
function toggleFavorite(id: string) {
setPlants((prev) =>
prev.map((p) => (p.id === id ? { ...p, favorite: !p.favorite } : p)),
);
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
}
const renderItem = ({ item }: { item: ScannedPlant }) => (
<View style={styles.card}>
{/* Image placeholder */}
<View style={[styles.imagePlaceholder, { backgroundColor: item.color }]}>
<Ionicons name="leaf" size={32} color={item.iconColor} />
<TouchableOpacity
onPress={() => toggleFavorite(item.id)}
style={styles.heartBtn}
activeOpacity={0.7}
>
<Ionicons
name={item.favorite ? "heart" : "heart-outline"}
size={18}
color={item.favorite ? "#EF4444" : "#C7C7CC"}
/>
</TouchableOpacity>
</View>
{/* Info */}
<View style={styles.cardInfo}>
<Text style={styles.plantName} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.plantDate}>{formatDate(item.date)}</Text>
</View>
</View>
);
const renderEmpty = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyIcon}>
<Ionicons name="leaf-outline" size={48} color={colors.neutral[300]} />
</View>
<Text style={styles.emptyTitle}>{t("library.empty.title")}</Text>
<Text style={styles.emptyBody}>{t("library.empty.body")}</Text>
</View>
);
return (
<SafeAreaView style={styles.safe} edges={["top"]}>
<FlatList
data={plants}
keyExtractor={(item) => item.id}
numColumns={2}
columnWrapperStyle={styles.row}
renderItem={renderItem}
ListEmptyComponent={renderEmpty}
ListHeaderComponent={
<>
<SearchHeader />
<SearchSection />
<View style={styles.titleRow}>
<Text style={styles.sectionTitle}>{t("library.title")}</Text>
<Text style={styles.countText}>
{plants.length} {t("library.plants")}
</Text>
</View>
</>
}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
</SafeAreaView>
);
}
// ─── Styles ──────────────────────────────────────────────
const styles = StyleSheet.create({
safe: {
flex: 1,
backgroundColor: "#F8F9FB",
},
listContent: {
paddingBottom: 40,
},
titleRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
marginBottom: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1A1A1A",
letterSpacing: -0.4,
},
countText: {
fontSize: 13,
fontWeight: "400",
color: "#8E8E93",
},
row: {
paddingHorizontal: 20,
gap: 16,
marginBottom: 16,
},
// Card
card: {
width: CARD_WIDTH,
backgroundColor: "#FFFFFF",
borderRadius: 24,
overflow: "hidden",
borderWidth: 1,
borderColor: "#F0F0F0",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 10,
},
android: { elevation: 2 },
}),
},
imagePlaceholder: {
width: "100%",
aspectRatio: 1,
alignItems: "center",
justifyContent: "center",
position: "relative",
},
heartBtn: {
position: "absolute",
top: 10,
right: 10,
width: 34,
height: 34,
borderRadius: 12,
backgroundColor: "#FFFFFF",
alignItems: "center",
justifyContent: "center",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
},
android: { elevation: 3 },
}),
},
cardInfo: {
padding: 14,
},
plantName: {
fontSize: 14,
fontWeight: "500",
color: "#1A1A1A",
marginBottom: 2,
},
plantDate: {
fontSize: 12,
fontWeight: "400",
color: "#8E8E93",
},
// Empty state
emptyContainer: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 80,
paddingHorizontal: 40,
},
emptyIcon: {
width: 96,
height: 96,
borderRadius: 32,
backgroundColor: "#F0F0F0",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
},
emptyTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1A1A1A",
marginBottom: 8,
},
emptyBody: {
fontSize: 14,
fontWeight: "400",
color: "#8E8E93",
textAlign: "center",
lineHeight: 20,
},
});

View file

@ -0,0 +1,30 @@
import { View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
export default function MapScreen() {
const { t } = useTranslation();
return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
<View className="flex-1 items-center justify-center px-8">
<View
className="mb-6 h-20 w-20 items-center justify-center rounded-full"
style={{ backgroundColor: colors.primary[100] }}
>
<Ionicons name="map-outline" size={36} color={colors.primary[700]} />
</View>
<Text className="mb-2 text-xl font-semibold" style={{ color: colors.neutral[900] }}>
{t("common.map")}
</Text>
<Text className="text-center text-sm" style={{ color: colors.neutral[500] }}>
Coming soon
</Text>
</View>
</SafeAreaView>
);
}

View file

@ -0,0 +1,424 @@
import { useState, useCallback } from "react";
import {
View,
FlatList,
TouchableOpacity,
StyleSheet,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
// ─── Types ───────────────────────────────────────────────
type NotificationType = "health_alert" | "tip" | "system";
interface Notification {
id: string;
type: NotificationType;
title: string;
body: string;
timestamp: string;
read: boolean;
}
// ─── Mock Data ───────────────────────────────────────────
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: "1",
type: "health_alert",
title: "notifications.mock.mildewAlert.title",
body: "notifications.mock.mildewAlert.body",
timestamp: "2026-04-02T14:30:00Z",
read: false,
},
{
id: "2",
type: "tip",
title: "notifications.mock.sulfurTip.title",
body: "notifications.mock.sulfurTip.body",
timestamp: "2026-04-01T09:15:00Z",
read: false,
},
{
id: "3",
type: "system",
title: "notifications.mock.scanReminder.title",
body: "notifications.mock.scanReminder.body",
timestamp: "2026-03-31T18:00:00Z",
read: true,
},
{
id: "4",
type: "health_alert",
title: "notifications.mock.botrytisAlert.title",
body: "notifications.mock.botrytisAlert.body",
timestamp: "2026-03-30T11:45:00Z",
read: true,
},
{
id: "5",
type: "tip",
title: "notifications.mock.pruningTip.title",
body: "notifications.mock.pruningTip.body",
timestamp: "2026-03-29T07:00:00Z",
read: true,
},
{
id: "6",
type: "system",
title: "notifications.mock.updateAvailable.title",
body: "notifications.mock.updateAvailable.body",
timestamp: "2026-03-28T15:30:00Z",
read: true,
},
];
// ─── Style tokens per type ───────────────────────────────
const TYPE_STYLES: Record<
NotificationType,
{ icon: string; iconColor: string; bgColor: string; dotColor: string }
> = {
health_alert: {
icon: "alert-circle",
iconColor: "#DC2626",
bgColor: "#FEE2E2",
dotColor: "#EF4444",
},
tip: {
icon: "bulb",
iconColor: "#0D9488",
bgColor: "#CCFBF1",
dotColor: "#14B8A6",
},
system: {
icon: "notifications",
iconColor: "#6366F1",
bgColor: "#E0E7FF",
dotColor: "#818CF8",
},
};
// ─── Helpers ─────────────────────────────────────────────
function timeAgo(timestamp: string, t: (key: string) => string): string {
const diff = Date.now() - new Date(timestamp).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
// ─── Component ───────────────────────────────────────────
export default function NotificationsScreen() {
const { t } = useTranslation();
const navigation = useNavigation();
const [notifications, setNotifications] =
useState<Notification[]>(MOCK_NOTIFICATIONS);
const unreadCount = notifications.filter((n) => !n.read).length;
const markAllRead = useCallback(() => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, []);
const markRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
);
}, []);
const renderItem = ({ item }: { item: Notification }) => {
const style = TYPE_STYLES[item.type];
return (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => markRead(item.id)}
style={[styles.card, !item.read && styles.cardUnread]}
>
{/* Unread dot */}
{!item.read && (
<View style={[styles.unreadDot, { backgroundColor: style.dotColor }]} />
)}
{/* Icon */}
<View style={[styles.iconContainer, { backgroundColor: style.bgColor }]}>
<Ionicons name={style.icon as any} size={22} color={style.iconColor} />
</View>
{/* Content */}
<View style={styles.content}>
<View style={styles.titleRow}>
<Text
numberOfLines={1}
style={[styles.title, !item.read && styles.titleUnread]}
>
{t(item.title)}
</Text>
<Text style={styles.time}>{timeAgo(item.timestamp, t)}</Text>
</View>
<Text numberOfLines={2} style={styles.body}>
{t(item.body)}
</Text>
</View>
</TouchableOpacity>
);
};
const renderEmpty = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyIcon}>
<Ionicons name="notifications-off-outline" size={48} color={colors.neutral[300]} />
</View>
<Text style={styles.emptyTitle}>{t("notifications.empty.title")}</Text>
<Text style={styles.emptyBody}>{t("notifications.empty.body")}</Text>
</View>
);
return (
<SafeAreaView style={styles.safe} edges={["top"]}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
activeOpacity={0.7}
>
<Ionicons name="arrow-back" size={22} color={colors.neutral[900]} />
</TouchableOpacity>
<View style={styles.headerCenter}>
<Text style={styles.headerTitle}>
{t("common.notifications")}
</Text>
{unreadCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unreadCount}</Text>
</View>
)}
</View>
<TouchableOpacity
onPress={markAllRead}
style={styles.markAllButton}
activeOpacity={0.7}
disabled={unreadCount === 0}
>
<Text
style={[
styles.markAllText,
unreadCount === 0 && styles.markAllDisabled,
]}
>
{t("notifications.markAllRead")}
</Text>
</TouchableOpacity>
</View>
{/* List */}
<FlatList
data={notifications}
keyExtractor={(item) => item.id}
renderItem={renderItem}
ListEmptyComponent={renderEmpty}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
/>
</SafeAreaView>
);
}
// ─── Styles ──────────────────────────────────────────────
const styles = StyleSheet.create({
safe: {
flex: 1,
backgroundColor: "#FAFAFA",
},
// Header
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 14,
backgroundColor: "#FFFFFF",
borderBottomWidth: 1,
borderBottomColor: "#F0F0F0",
},
backButton: {
width: 40,
height: 40,
borderRadius: 14,
backgroundColor: "#F5F7F9",
alignItems: "center",
justifyContent: "center",
},
headerCenter: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
headerTitle: {
fontSize: 20,
fontWeight: "900",
color: colors.neutral[900],
letterSpacing: -0.5,
},
badge: {
backgroundColor: "#EF4444",
borderRadius: 10,
minWidth: 22,
height: 22,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 6,
},
badgeText: {
fontSize: 11,
fontWeight: "800",
color: "#FFFFFF",
},
markAllButton: {
paddingVertical: 6,
paddingHorizontal: 10,
},
markAllText: {
fontSize: 13,
fontWeight: "700",
color: colors.primary[700],
},
markAllDisabled: {
color: colors.neutral[300],
},
// List
list: {
padding: 20,
paddingBottom: 40,
},
// Card
card: {
flexDirection: "row",
alignItems: "flex-start",
backgroundColor: "#FFFFFF",
borderRadius: 24,
padding: 16,
borderWidth: 1,
borderColor: "#F0F0F0",
position: "relative",
...Platform.select({
ios: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 10,
},
android: {
elevation: 2,
},
}),
},
cardUnread: {
backgroundColor: "#FAFCFF",
borderColor: "#E8EEFF",
},
// Unread dot
unreadDot: {
position: "absolute",
top: 18,
left: 10,
width: 8,
height: 8,
borderRadius: 4,
},
// Icon
iconContainer: {
width: 48,
height: 48,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
marginRight: 14,
},
// Content
content: {
flex: 1,
},
titleRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 4,
},
title: {
fontSize: 14,
fontWeight: "600",
color: colors.neutral[700],
flex: 1,
marginRight: 8,
},
titleUnread: {
fontWeight: "800",
color: colors.neutral[900],
},
time: {
fontSize: 11,
fontWeight: "600",
color: colors.neutral[400],
},
body: {
fontSize: 13,
fontWeight: "500",
color: colors.neutral[500],
lineHeight: 18,
},
// Empty state
emptyContainer: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 80,
paddingHorizontal: 40,
},
emptyIcon: {
width: 96,
height: 96,
borderRadius: 32,
backgroundColor: "#F5F7F9",
alignItems: "center",
justifyContent: "center",
marginBottom: 24,
},
emptyTitle: {
fontSize: 18,
fontWeight: "800",
color: colors.neutral[900],
marginBottom: 8,
letterSpacing: -0.3,
},
emptyBody: {
fontSize: 14,
fontWeight: "500",
color: colors.neutral[400],
textAlign: "center",
lineHeight: 20,
},
});

View file

@ -0,0 +1,253 @@
import { View, ScrollView, StyleSheet, Platform, Dimensions, TouchableOpacity } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import { useGameProgress } from "@/hooks/useGameProgress";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
const { width } = Dimensions.get("window");
const STAT_CARD_SIZE = (width - 56) / 2; // Ajusté pour le gap de 16
const BENTO_STATS = [
{ key: "scans", icon: "scan-outline", iconColor: "#F59E0B", label: "profile.totalScans" },
{ key: "grapes", icon: "leaf-outline", iconColor: "#10B981", label: "profile.uniqueGrapes" },
{ key: "streak", icon: "flame-outline", iconColor: "#EF4444", label: "profile.bestStreak" },
{ key: "xp", icon: "star-outline", iconColor: "#6366F1", label: "profile.xpTotal" },
];
export default function ProfileScreen() {
const { t } = useTranslation();
const navigation = useNavigation<Nav>();
const { progress } = useGameProgress();
function handleBack() {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
navigation.navigate("Main" as any);
}
}
return (
<View style={styles.root}>
{/* Hero Header - Style Courbé */}
<View style={styles.heroBlock}>
<SafeAreaView edges={["top"]} style={styles.heroSafeArea}>
<View style={styles.heroTopRow}>
<TouchableOpacity onPress={handleBack} style={styles.heroBackBtn}>
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate("Settings")} style={styles.heroSettingsBtn}>
<Ionicons name="settings-outline" size={22} color={colors.primary[800]} />
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* Avatar avec bague de séparation */}
<View style={styles.avatarContainer}>
<View style={styles.avatarRing}>
<View style={styles.avatar}>
<Text style={styles.avatarEmoji}>🧑🌾</Text>
</View>
</View>
</View>
{/* User Info - Focus sur la clarté */}
<View style={styles.infoCard}>
<Text style={styles.userName}>Yanis Cyrius</Text>
<Text style={styles.userEmail}>yanis@vineye.app</Text>
<View style={styles.actionRow}>
<TouchableOpacity style={styles.friendBtn} activeOpacity={0.8}>
<Text style={styles.friendBtnText}>+ Friends</Text>
</TouchableOpacity>
<View style={styles.xpBadge}>
<Text style={styles.xpBadgeText}>{progress.xp} XP</Text>
</View>
</View>
</View>
{/* Stats Grid - Bento Style Pur */}
<View style={styles.statsGrid}>
{BENTO_STATS.map((stat) => (
<View key={stat.key} style={styles.statCard}>
<View style={[styles.statIconWrap, { backgroundColor: `${stat.iconColor}15` }]}>
<Ionicons name={stat.icon as any} size={22} color={stat.iconColor} />
</View>
<Text style={styles.statValue}>
{stat.key === "grapes" ? (progress.uniqueGrapes?.length ?? 0) : progress[stat.key as keyof typeof progress] || 0}
</Text>
<Text style={styles.statLabel}>{t(stat.label)}</Text>
</View>
))}
</View>
<View style={{ height: 60 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#F8F9FB", // Gris très clair bleuté
},
heroBlock: {
height: 200,
backgroundColor: colors.primary[700],
borderBottomLeftRadius: 48,
borderBottomRightRadius: 48,
},
heroSafeArea: {
flex: 1,
},
heroTopRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingTop: 10,
},
heroBackBtn: {
width: 44,
height: 44,
borderRadius: 16,
backgroundColor: "rgba(255,255,255,0.15)",
alignItems: "center",
justifyContent: "center",
},
heroSettingsBtn: {
width: 44,
height: 44,
borderRadius: 16,
backgroundColor: "#FFFFFF",
alignItems: "center",
justifyContent: "center",
},
scrollView: {
flex: 1,
marginTop: -70,
},
scrollContent: {
paddingHorizontal: 20,
},
avatarContainer: {
alignItems: "center",
marginBottom: 20,
},
avatarRing: {
width: 110,
height: 110,
borderRadius: 55,
backgroundColor: "#FFFFFF",
alignItems: "center",
justifyContent: "center",
...Platform.select({
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.1, shadowRadius: 12 },
android: { elevation: 8 },
}),
},
avatar: {
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: colors.primary[50],
alignItems: "center",
justifyContent: "center",
},
avatarEmoji: {
fontSize: 48,
},
infoCard: {
backgroundColor: "#FFFFFF",
borderRadius: 32,
padding: 24,
alignItems: "center",
marginBottom: 20,
borderWidth: 1,
borderColor: "#F0F0F0",
},
userName: {
fontSize: 24,
fontWeight: "800",
color: "#1A1A1A",
letterSpacing: -0.5,
},
userEmail: {
fontSize: 14,
color: "#A0A0A0",
marginTop: 2,
marginBottom: 20,
},
actionRow: {
flexDirection: "row",
gap: 12,
},
friendBtn: {
backgroundColor: "#FFFFFF",
borderWidth: 1.5,
borderColor: "#F97316",
borderRadius: 100,
paddingHorizontal: 20,
paddingVertical: 10,
},
friendBtnText: {
fontSize: 14,
fontWeight: "600",
color: "#F97316",
},
xpBadge: {
backgroundColor: colors.primary[600],
borderRadius: 100,
paddingHorizontal: 20,
paddingVertical: 10,
justifyContent: "center",
},
xpBadgeText: {
fontSize: 14,
fontWeight: "600",
color: "#FFFFFF",
},
statsGrid: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
},
statCard: {
width: STAT_CARD_SIZE,
backgroundColor: "#FFFFFF",
borderRadius: 28,
padding: 20,
marginBottom: 16,
borderWidth: 1,
borderColor: "#F2F2F2",
},
statIconWrap: {
width: 44,
height: 44,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
},
statValue: {
fontSize: 22,
fontWeight: "500", // Medium au lieu de Bold pour le look premium
color: "#1A1A1A",
},
statLabel: {
fontSize: 13,
color: "#9A9A9A",
marginTop: 4,
},
});

View file

@ -0,0 +1,190 @@
import { useEffect } from 'react';
import { View, ScrollView, TouchableOpacity } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
} from 'react-native-reanimated';
import { ProgressCircle } from '@/components/ui/ProgressCircle';
import { Button } from '@/components/ui/Button';
import { Text } from '@/components/ui/text';
import { Badge } from '@/components/ui/Badge';
import { getCepageById } from '@/utils/cepages';
import { colors } from '@/theme/colors';
import type { RootStackParamList } from '@/types/navigation';
import type { DetectionResult } from '@/types/detection';
type ResultNav = NativeStackNavigationProp<RootStackParamList, 'Result'>;
type ResultRoute = RouteProp<RootStackParamList, 'Result'>;
function getResultColor(result: DetectionResult): string {
if (result === 'vine') return colors.success;
if (result === 'uncertain') return colors.warning;
return colors.danger;
}
function InfoCard({ icon, iconColor, label, value }: {
icon: keyof typeof Ionicons.glyphMap;
iconColor: string;
label: string;
value: string;
}) {
return (
<View className="w-[48%] gap-1 rounded-[14px] bg-white p-[14px] shadow-sm" style={{ elevation: 1 }}>
<View
className="h-8 w-8 items-center justify-center rounded-lg"
style={{ backgroundColor: iconColor + '18' }}
>
<Ionicons name={icon} size={20} color={iconColor} />
</View>
<Text className="text-[11px] text-neutral-500">{label}</Text>
<Text className="text-[15px] font-semibold text-neutral-900">{value}</Text>
</View>
);
}
export default function ResultScreen() {
const { t } = useTranslation();
const navigation = useNavigation<ResultNav>();
const route = useRoute<ResultRoute>();
const { detection } = route.params;
const cepage = detection.cepageId ? getCepageById(detection.cepageId) : undefined;
const resultColor = getResultColor(detection.result);
const headerOpacity = useSharedValue(0);
const headerScale = useSharedValue(0.8);
const cardTranslateY = useSharedValue(30);
const cardOpacity = useSharedValue(0);
useEffect(() => {
headerOpacity.value = withTiming(1, { duration: 400 });
headerScale.value = withTiming(1, { duration: 500 });
cardTranslateY.value = withDelay(250, withTiming(0, { duration: 400 }));
cardOpacity.value = withDelay(250, withTiming(1, { duration: 400 }));
}, []);
const headerStyle = useAnimatedStyle(() => ({
opacity: headerOpacity.value,
transform: [{ scale: headerScale.value }],
}));
const cardStyle = useAnimatedStyle(() => ({
opacity: cardOpacity.value,
transform: [{ translateY: cardTranslateY.value }],
}));
const resultLabel =
detection.result === 'vine'
? t('result.vineDetected')
: detection.result === 'uncertain'
? t('result.uncertain')
: t('result.notVine');
return (
<SafeAreaView className="flex-1 bg-[#FAFAFA]">
<ScrollView showsVerticalScrollIndicator={false} contentContainerClassName="gap-5 p-4 pb-12">
{/* Close button */}
<TouchableOpacity
className="h-8 w-8 items-center justify-center self-end rounded-full bg-neutral-200"
onPress={() => navigation.goBack()}
>
<Ionicons name="close" size={20} color={colors.neutral[700]} />
</TouchableOpacity>
{/* Confidence circle */}
<Animated.View className="items-center gap-3 py-4" style={headerStyle}>
<ProgressCircle
size={100}
strokeWidth={8}
progress={detection.confidence / 100}
color={resultColor}
trackColor={resultColor + '25'}
>
<Text className="text-[20px] font-extrabold" style={{ color: resultColor }}>
{detection.confidence}%
</Text>
</ProgressCircle>
{/* Success message with checkmark */}
<View className="flex-row items-center gap-1.5">
<Ionicons
name={detection.result === 'vine' ? 'checkmark-circle' : detection.result === 'uncertain' ? 'help-circle' : 'close-circle'}
size={20}
color={resultColor}
/>
<Text className="text-[13px] font-medium" style={{ color: resultColor }}>
{resultLabel}
</Text>
</View>
</Animated.View>
{/* Plant name + tags + description + info grid */}
{cepage && detection.result === 'vine' && (
<Animated.View style={cardStyle}>
<Text className="mb-1 text-[24px] font-bold text-neutral-900">{cepage.name.fr}</Text>
{/* Tags */}
<View className="mb-5 flex-row flex-wrap gap-2">
<Badge
label={cepage.color === 'rouge' ? '🍷 Rouge' : cepage.color === 'blanc' ? '🥂 Blanc' : '🌸 Rosé'}
color="neutral"
size="sm"
/>
{cepage.regions.slice(0, 2).map((r) => (
<Badge key={r} label={r} color="neutral" size="sm" />
))}
</View>
{/* Description */}
<View className="mb-4 gap-1">
<Text className="text-[17px] font-semibold text-neutral-900">
{t('result.characteristics')}
</Text>
<Text className="text-[13px] leading-[22px] text-neutral-600">
{cepage.characteristics.fr}
</Text>
</View>
{/* 2x2 info grid */}
<View className="flex-row flex-wrap gap-[10px]">
<InfoCard icon="leaf" iconColor={colors.primary[700]} label={t('result.origin')} value={cepage.origin.fr} />
<InfoCard icon="water" iconColor="#2196F3" label={t('scanner.confidence')} value={`${detection.confidence}%`} />
<InfoCard icon="sunny" iconColor="#FF9800" label={t('result.regions')} value={cepage.regions[0] ?? '—'} />
<InfoCard icon="wine" iconColor="#E91E63" label="Type" value={cepage.color === 'rouge' ? 'Rouge' : cepage.color === 'blanc' ? 'Blanc' : 'Rosé'} />
</View>
</Animated.View>
)}
{/* Action buttons */}
<Animated.View className="mt-2 gap-2" style={cardStyle}>
<Button
variant="default"
size="lg"
className="w-full rounded-[14px]"
onPress={() => navigation.goBack()}
>
<Ionicons name="scan" size={18} color={colors.surface} />
<Text className="text-white">{t('result.scanAgain')}</Text>
</Button>
<Button
variant="ghost"
size="lg"
className="w-full rounded-[14px]"
onPress={() => navigation.goBack()}
>
<Text style={{ color: colors.primary[700] }}>{t('result.viewHistory')}</Text>
</Button>
</Animated.View>
</ScrollView>
</SafeAreaView>
);
}

View file

@ -0,0 +1,177 @@
import { useState } from 'react';
import { View, TouchableOpacity } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSequence,
withTiming,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { CameraOverlay } from '@/components/scanner/CameraOverlay';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/Button';
import { useDetection } from '@/hooks/useDetection';
import { useGameProgress } from '@/hooks/useGameProgress';
import { useHistory } from '@/hooks/useHistory';
import { hapticSuccess, hapticLight } from '@/services/haptics';
import { colors } from '@/theme/colors';
import type { RootStackParamList } from '@/types/navigation';
import type { ScanRecord } from '@/types/detection';
type ScannerNav = NativeStackNavigationProp<RootStackParamList>;
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export default function ScannerScreen() {
const { t } = useTranslation();
const navigation = useNavigation<ScannerNav>();
const [permission, requestPermission] = useCameraPermissions();
const { analyze, isAnalyzing } = useDetection();
const { processDetection } = useGameProgress();
const { addScan } = useHistory();
const [liveConfidence, setLiveConfidence] = useState(0);
const shutterScale = useSharedValue(1);
const shutterStyle = useAnimatedStyle(() => ({
transform: [{ scale: shutterScale.value }],
}));
async function handleCapture() {
if (isAnalyzing) return;
await hapticLight();
shutterScale.value = withSequence(
withTiming(0.88, { duration: 100 }),
withTiming(1, { duration: 150 })
);
const interval = setInterval(() => {
setLiveConfidence((prev) => Math.min(prev + Math.floor(Math.random() * 12), 85));
}, 150);
const detection = await analyze();
clearInterval(interval);
if (!detection) return;
setLiveConfidence(detection.confidence);
if (detection.result === 'vine') {
await hapticSuccess();
}
const xpEarned = await processDetection(detection);
const record: ScanRecord = {
id: generateId(),
detection,
xpEarned: typeof xpEarned === 'number' ? xpEarned : 10,
createdAt: new Date().toISOString(),
};
await addScan(record);
navigation.navigate('Result', { detection });
setTimeout(() => setLiveConfidence(0), 500);
}
if (!permission) {
return (
<View className="flex-1 items-center justify-center">
<Text>Chargement...</Text>
</View>
);
}
if (!permission.granted) {
return (
<View className="flex-1 items-center justify-center gap-4 bg-[#FAFAFA] p-8">
<Text className="text-[64px]">📷</Text>
<Text className="text-center text-[20px] font-bold text-neutral-900">
{t('scanner.permissionRequired')}
</Text>
<Text className="mb-4 text-center text-[15px] leading-6 text-neutral-600">
{t('scanner.permissionMessage')}
</Text>
<Button onPress={requestPermission} size="lg" variant="default" className="w-full">
<Text className="text-white">{t('scanner.grantPermission')}</Text>
</Button>
</View>
);
}
return (
<View className="flex-1 bg-neutral-900">
<CameraView className="flex-1" facing="back">
{/* Header overlay */}
<SafeAreaView edges={['top']} className="absolute top-0 left-0 right-0 z-10">
<View className="flex-row items-center justify-between px-5 py-2">
<View className="flex-row items-center gap-2">
<Ionicons name="leaf" size={18} color={colors.surface} />
<Text className="text-[15px] font-semibold text-white">
{t('scanner.identify') ?? 'Identify the plant'}
</Text>
</View>
<TouchableOpacity
className="h-8 w-8 items-center justify-center rounded-full"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
onPress={() => navigation.goBack()}
>
<Ionicons name="close" size={22} color={colors.surface} />
</TouchableOpacity>
</View>
</SafeAreaView>
<CameraOverlay isScanning={isAnalyzing} confidence={liveConfidence} />
{/* Bottom toolbar */}
<View className="absolute bottom-0 left-0 right-0 flex-row items-center justify-between px-8 pb-12 pt-5">
{/* Thumbnail */}
<View
className="h-11 w-11 items-center justify-center rounded-lg"
style={{ backgroundColor: 'rgba(255,255,255,0.15)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' }}
>
<Ionicons name="image-outline" size={20} color="rgba(255,255,255,0.5)" />
</View>
{/* Shutter */}
<Animated.View
className="h-[72px] w-[72px] items-center justify-center rounded-full border-[3px] border-white"
style={shutterStyle}
>
<TouchableOpacity
className="h-[60px] w-[60px] items-center justify-center rounded-full bg-white"
onPress={handleCapture}
disabled={isAnalyzing}
activeOpacity={0.8}
>
{isAnalyzing ? (
<Text className="text-center text-[11px] font-semibold" style={{ color: colors.primary[800] }}>
{t('scanner.analyzing')}
</Text>
) : (
<View className="h-[52px] w-[52px] rounded-full bg-white" />
)}
</TouchableOpacity>
</Animated.View>
{/* Flip camera */}
<TouchableOpacity
className="h-11 w-11 items-center justify-center rounded-full"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<Ionicons name="camera-reverse-outline" size={24} color={colors.surface} />
</TouchableOpacity>
</View>
</CameraView>
</View>
);
}

View file

@ -0,0 +1,320 @@
import {
View,
ScrollView,
StyleSheet,
Platform,
Alert,
TouchableOpacity,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import { useTranslation } from "react-i18next";
import { Ionicons } from "@expo/vector-icons";
import i18n from "@/i18n";
import { Text } from "@/components/ui/text";
import { colors } from "@/theme/colors";
import { useGameProgress } from "@/hooks/useGameProgress";
import { useHistory } from "@/hooks/useHistory";
interface MenuItem {
icon: string;
label: string;
rightText?: string;
rightColor?: string;
danger?: boolean;
onPress?: () => void;
}
export default function SettingsScreen() {
const { t } = useTranslation();
const navigation = useNavigation();
const { resetProgress } = useGameProgress();
const { clearHistory } = useHistory();
function handleLanguageToggle() {
const newLang = i18n.language === "fr" ? "en" : "fr";
i18n.changeLanguage(newLang);
}
function handleReset() {
Alert.alert(t("common.confirm"), t("profile.resetConfirm"), [
{ text: t("common.cancel"), style: "cancel" },
{
text: t("profile.resetData"),
style: "destructive",
onPress: async () => {
await resetProgress();
await clearHistory();
},
},
]);
}
const generalItems: MenuItem[] = [
{
icon: "person-outline",
label: t("settings.editProfile"),
},
{
icon: "globe-outline",
label: t("profile.language"),
rightText: i18n.language === "fr" ? "Français" : "English",
onPress: handleLanguageToggle,
},
{
icon: "notifications-outline",
label: t("common.notifications"),
},
{
icon: "shield-outline",
label: t("settings.privacy"),
},
];
const appItems: MenuItem[] = [
{
icon: "diamond-outline",
label: t("settings.premiumStatus"),
rightText: t("settings.inactive"),
rightColor: "#F97316",
},
{
icon: "color-palette-outline",
label: t("settings.appearance"),
},
{
icon: "help-circle-outline",
label: t("settings.helpCenter"),
},
{
icon: "document-text-outline",
label: t("settings.terms"),
},
];
const dangerItems: MenuItem[] = [
{
icon: "trash-outline",
label: t("profile.resetData"),
danger: true,
onPress: handleReset,
},
];
const renderMenuGroup = (items: MenuItem[]) => (
<View style={styles.menuCard}>
{items.map((item, index) => (
<View key={item.label}>
{index > 0 && <View style={styles.divider} />}
<TouchableOpacity
style={styles.menuRow}
activeOpacity={0.5}
onPress={item.onPress}
>
<View
style={[
styles.iconBox,
{ backgroundColor: item.danger ? "#FEF2F2" : "#F8F9FA" },
]}
>
<Ionicons
name={item.icon as any}
size={20}
color={item.danger ? "#EF4444" : "#636E72"}
/>
</View>
<Text
style={[styles.menuLabel, item.danger && styles.menuLabelDanger]}
>
{item.label}
</Text>
<View style={styles.menuRight}>
{item.rightText && (
<Text
style={[
styles.menuRightText,
item.rightColor && { color: item.rightColor },
]}
>
{item.rightText}
</Text>
)}
<Ionicons name="chevron-forward" size={14} color="#D1D1D6" />
</View>
</TouchableOpacity>
</View>
))}
</View>
);
return (
<SafeAreaView style={styles.safe} edges={["top"]}>
{/* Header épuré style Bumble/Apple */}
<View style={styles.header}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backBtn}
>
<Ionicons name="chevron-back" size={24} color="#1A1A1A" />
</TouchableOpacity>
<Text style={styles.headerTitle}>{t("common.settings")}</Text>
<View style={{ width: 44 }} />
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<Text style={styles.sectionLabel}>{t("settings.general")}</Text>
{renderMenuGroup(generalItems)}
<Text style={styles.sectionLabel}>{t("settings.app")}</Text>
{renderMenuGroup(appItems)}
{/* Banner Referral plus "Flat" et moderne */}
<TouchableOpacity style={styles.referCard} activeOpacity={0.9}>
<View style={styles.referContent}>
<Text style={styles.referTitle}>Refer a friend</Text>
<Text style={styles.referBody}>
Get $50 per successful referral
</Text>
</View>
<View style={styles.referIconWrap}>
<Ionicons name="gift" size={28} color="#FFFFFF" />
</View>
</TouchableOpacity>
{renderMenuGroup(dangerItems)}
<Text style={styles.versionText}>VinEye Version 1.0.0</Text>
<View style={{ height: 40 }} />
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
backgroundColor: "#F8F9FB", // Gris encore plus clair/bleuté
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "transparent", // Pas de démarcation brutale
},
backBtn: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
borderRadius: 14,
backgroundColor: "#FFFFFF",
borderWidth: 1,
borderColor: "#F0F0F0",
},
headerTitle: {
fontSize: 18,
fontWeight: "600", // Pas de Bold 900 ici, juste Medium/SemiBold
color: "#1A1A1A",
letterSpacing: -0.4,
},
scrollContent: {
paddingHorizontal: 20,
paddingTop: 10,
},
sectionLabel: {
fontSize: 12,
fontWeight: "500",
color: "#A0A0A0",
marginBottom: 12,
marginLeft: 4,
textTransform: "uppercase",
letterSpacing: 1,
},
menuCard: {
backgroundColor: "#FFFFFF",
borderRadius: 24,
marginBottom: 20,
borderWidth: 1,
borderColor: "#F2F2F2",
},
menuRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
},
iconBox: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
marginRight: 12,
},
menuLabel: {
flex: 1,
fontSize: 15,
fontWeight: "400", // On reste sur du Regular
color: "#2D3436",
},
menuLabelDanger: {
color: "#EF4444",
},
menuRight: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
menuRightText: {
fontSize: 14,
color: "#B2B2B2",
},
divider: {
height: 1,
backgroundColor: "#F8F9FA",
marginLeft: 60, // Aligné avec le texte, pas l'icône
},
referCard: {
backgroundColor: "#F97316",
borderRadius: 24,
padding: 20,
marginBottom: 20,
flexDirection: "row",
alignItems: "center",
},
referContent: {
flex: 1,
},
referTitle: {
fontSize: 17,
fontWeight: "700",
color: "#FFFFFF",
},
referBody: {
fontSize: 13,
color: "rgba(255,255,255,0.7)",
marginTop: 2,
},
referIconWrap: {
width: 50,
height: 50,
borderRadius: 15,
backgroundColor: "rgba(255,255,255,0.2)",
alignItems: "center",
justifyContent: "center",
},
versionText: {
textAlign: "center",
fontSize: 12,
color: "#D1D1D6",
marginTop: 10,
},
});

View file

@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { View, Text, Image, StyleSheet } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
import type { RootStackParamList } from '@/types/navigation';
type SplashNav = NativeStackNavigationProp<RootStackParamList, 'Splash'>;
export default function SplashScreen() {
const navigation = useNavigation<SplashNav>();
useEffect(() => {
const timer = setTimeout(() => {
navigation.replace('Main');
}, 2800);
return () => clearTimeout(timer);
}, []);
return (
<View style={styles.container}>
<Image
source={require('@/assets/images/icon.png')}
style={styles.logoImage}
resizeMode="contain"
/>
{/* <Text style={styles.logo}>VinEye</Text>
<Text style={styles.subtitle}>Détection de vignes par IA</Text> */}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
},
logoImage: {
width: 198,
height: 198,
},
logo: {
fontSize: typography.fontSizes['4xl'],
fontWeight: typography.fontWeights.extrabold,
color: colors.primary[200],
letterSpacing: -1,
},
subtitle: {
fontSize: typography.fontSizes.base,
color: colors.primary[400],
fontWeight: typography.fontWeights.medium,
},
});

View file

@ -0,0 +1,25 @@
import * as Haptics from 'expo-haptics';
export async function hapticSuccess(): Promise<void> {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
export async function hapticWarning(): Promise<void> {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
export async function hapticError(): Promise<void> {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
export async function hapticLight(): Promise<void> {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
export async function hapticMedium(): Promise<void> {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
export async function hapticHeavy(): Promise<void> {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}

View file

@ -0,0 +1,43 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
const KEYS = {
GAME_PROGRESS: '@vineye:game_progress',
SCAN_HISTORY: '@vineye:scan_history',
LANGUAGE: '@vineye:language',
} as const;
async function get<T>(key: string): Promise<T | null> {
try {
const raw = await AsyncStorage.getItem(key);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function set<T>(key: string, value: T): Promise<void> {
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage errors are non-critical — fail silently
}
}
async function remove(key: string): Promise<void> {
try {
await AsyncStorage.removeItem(key);
} catch {
// Ignore removal errors
}
}
async function clearAll(): Promise<void> {
try {
await AsyncStorage.multiRemove(Object.values(KEYS));
} catch {
// Ignore errors
}
}
export const storage = { get, set, remove, clearAll, KEYS };

View file

@ -0,0 +1,9 @@
// Labels for the TFLite vine detection model
// TODO: Remplacer par les vrais labels du modèle TFLite MobileNetV2
export const VINE_LABELS = [
'vine', // 0 — vigne détectée
'uncertain', // 1 — incertain
'not_vine', // 2 — pas une vigne
] as const;
export type VineLabel = (typeof VINE_LABELS)[number];

View file

@ -0,0 +1,52 @@
// TODO: Remplacer par le vrai modèle TFLite (MobileNetV2 fine-tuné sur dataset vignes)
import type { Detection, DetectionResult } from '@/types/detection';
import { cepages } from '@/utils/cepages';
const WEIGHTED_RESULTS: { result: DetectionResult; weight: number }[] = [
{ result: 'vine', weight: 70 },
{ result: 'uncertain', weight: 20 },
{ result: 'not_vine', weight: 10 },
];
function weightedRandom(): DetectionResult {
const total = WEIGHTED_RESULTS.reduce((sum, r) => sum + r.weight, 0);
let rand = Math.random() * total;
for (const r of WEIGHTED_RESULTS) {
rand -= r.weight;
if (rand <= 0) return r.result;
}
return 'vine';
}
// TODO: Remplacer par le vrai modèle TFLite
export async function loadModel(): Promise<boolean> {
// Simule le chargement du modèle (1-2 secondes)
await new Promise((resolve) => setTimeout(resolve, 1200 + Math.random() * 800));
return true;
}
// TODO: Remplacer par le vrai modèle TFLite
export async function runInference(imageUri?: string): Promise<Detection> {
// Simule l'inférence (200-600ms)
await new Promise((resolve) => setTimeout(resolve, 200 + Math.random() * 400));
const result = weightedRandom();
const confidence = result === 'vine'
? Math.floor(70 + Math.random() * 30) // 70100%
: result === 'uncertain'
? Math.floor(40 + Math.random() * 30) // 4070%
: Math.floor(10 + Math.random() * 30); // 1040%
const cepageId =
result === 'vine'
? cepages[Math.floor(Math.random() * cepages.length)].id
: undefined;
return {
result,
confidence,
cepageId,
timestamp: Date.now(),
imageUri,
};
}

View file

@ -0,0 +1,46 @@
export const colors = {
// Primary — Vert Vigne
primary: {
900: '#1B4332',
800: '#2D6A4F',
700: '#40916C',
600: '#52B788',
500: '#74C69D',
400: '#95D5B2',
300: '#B7E4C7',
200: '#D8F3DC',
100: '#E9F5EC',
},
// Accent — Violet Raisin
accent: {
900: '#3E0047',
800: '#6A0572',
700: '#8E24AA',
600: '#AB47BC',
500: '#CE93D8',
400: '#E1BEE7',
300: '#F3E5F5',
200: '#F8EDF9',
100: '#FDF5FF',
},
// Neutrals
neutral: {
900: '#1B1B1B',
800: '#2D2D2D',
700: '#4A4A4A',
600: '#6B6B6B',
500: '#9E9E9E',
400: '#BDBDBD',
300: '#E0E0E0',
200: '#F5F5F5',
100: '#FAFAFA',
},
// Semantic — Résultats de détection
success: '#4CAF50',
warning: '#FFB74D',
danger: '#FF7043',
// Backgrounds
background: '#FAFAFA',
surface: '#FFFFFF',
card: '#FFFFFF',
} as const;

View file

@ -0,0 +1,3 @@
export { colors } from './colors';
export { typography } from './typography';
export { spacing } from './spacing';

View file

@ -0,0 +1,12 @@
export const spacing = {
xs: 4,
sm: 8,
md: 12,
base: 16,
lg: 20,
xl: 24,
'2xl': 32,
'3xl': 40,
'4xl': 48,
'5xl': 64,
} as const;

View file

@ -0,0 +1,25 @@
export const typography = {
fontSizes: {
xs: 11,
sm: 13,
base: 15,
md: 17,
lg: 20,
xl: 24,
'2xl': 28,
'3xl': 34,
'4xl': 40,
},
fontWeights: {
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
extrabold: '800' as const,
},
lineHeights: {
tight: 1.2,
normal: 1.5,
relaxed: 1.75,
},
};

View file

@ -0,0 +1,16 @@
export type DetectionResult = 'vine' | 'uncertain' | 'not_vine';
export interface Detection {
result: DetectionResult;
confidence: number; // 0100
cepageId?: string;
timestamp: number;
imageUri?: string;
}
export interface ScanRecord {
id: string;
detection: Detection;
xpEarned: number;
createdAt: string; // ISO date
}

View file

@ -0,0 +1,46 @@
export type BadgeId =
| 'first_scan'
| 'connoisseur'
| 'on_fire'
| 'sharp_eye'
| 'explorer'
| 'perfectionist'
| 'master';
export interface Badge {
id: BadgeId;
nameKey: string;
descKey: string;
icon: string;
unlocked: boolean;
unlockedAt?: string;
}
export type LevelId =
| 'bud'
| 'leaf'
| 'shoot'
| 'cluster'
| 'harvester'
| 'winemaker'
| 'cellar_master';
export interface Level {
id: LevelId;
labelKey: string;
minXP: number;
maxXP: number;
number: number;
}
export interface GameProgress {
xp: number;
level: number;
badges: Badge[];
streak: number;
lastScanDate: string | null;
totalScans: number;
uniqueGrapes: string[];
bestStreak: number;
highConfidenceScans: number;
}

View file

@ -0,0 +1,20 @@
import type { Detection } from './detection';
export type RootStackParamList = {
Splash: undefined;
Main: undefined;
Result: { detection: Detection };
Notifications: undefined;
Profile: undefined;
Settings: undefined;
Guides: undefined;
Library: undefined;
};
export type BottomTabParamList = {
Home: undefined;
Guides: undefined;
Scanner: undefined;
Library: undefined;
Map: undefined;
};

View file

@ -0,0 +1,112 @@
import type { Badge, BadgeId, Level, LevelId, GameProgress } from '@/types/gamification';
// XP rewards
export const XP_REWARDS = {
SCAN_SUCCESS: 10,
NEW_CEEPAGE: 25,
DAILY_STREAK_BONUS: 5,
HIGH_CONFIDENCE_BONUS: 5, // confidence > 90%
} as const;
// Level definitions
export const LEVELS: Level[] = [
{ id: 'bud', labelKey: 'levels.bud', minXP: 0, maxXP: 50, number: 1 },
{ id: 'leaf', labelKey: 'levels.leaf', minXP: 51, maxXP: 150, number: 2 },
{ id: 'shoot', labelKey: 'levels.shoot', minXP: 151, maxXP: 300, number: 3 },
{ id: 'cluster', labelKey: 'levels.cluster', minXP: 301, maxXP: 500, number: 4 },
{ id: 'harvester', labelKey: 'levels.harvester', minXP: 501, maxXP: 800, number: 5 },
{ id: 'winemaker', labelKey: 'levels.winemaker', minXP: 801, maxXP: 1200, number: 6 },
{ id: 'cellar_master', labelKey: 'levels.cellarMaster', minXP: 1201, maxXP: 9999, number: 7 },
];
export const BADGE_DEFINITIONS: Omit<Badge, 'unlocked' | 'unlockedAt'>[] = [
{ id: 'first_scan', nameKey: 'achievements.firstScan', descKey: 'achievements.firstScanDesc', icon: '🌱' },
{ id: 'connoisseur', nameKey: 'achievements.connoisseur', descKey: 'achievements.connoisseurDesc', icon: '🍇' },
{ id: 'on_fire', nameKey: 'achievements.onFire', descKey: 'achievements.onFireDesc', icon: '🔥' },
{ id: 'sharp_eye', nameKey: 'achievements.sharpEye', descKey: 'achievements.sharpEyeDesc', icon: '🎯' },
{ id: 'explorer', nameKey: 'achievements.explorer', descKey: 'achievements.explorerDesc', icon: '🌍' },
{ id: 'perfectionist', nameKey: 'achievements.perfectionist', descKey: 'achievements.perfectionistDesc', icon: '⭐' },
{ id: 'master', nameKey: 'achievements.master', descKey: 'achievements.masterDesc', icon: '🏆' },
];
export function createInitialBadges(): Badge[] {
return BADGE_DEFINITIONS.map((def) => ({
...def,
unlocked: false,
}));
}
export function getLevelForXP(xp: number): Level {
for (let i = LEVELS.length - 1; i >= 0; i--) {
if (xp >= LEVELS[i].minXP) return LEVELS[i];
}
return LEVELS[0];
}
export function getLevelNumber(xp: number): number {
return getLevelForXP(xp).number;
}
export function getNextLevel(xp: number): Level | null {
const current = getLevelForXP(xp);
const next = LEVELS.find((l) => l.number === current.number + 1);
return next ?? null;
}
export function getXPProgress(xp: number): { current: number; total: number; ratio: number } {
const current = getLevelForXP(xp);
const xpInLevel = xp - current.minXP;
const total = current.maxXP - current.minXP;
return {
current: xpInLevel,
total,
ratio: Math.min(xpInLevel / total, 1),
};
}
export function checkNewBadges(
progress: GameProgress,
newBadges: Badge[]
): { badges: Badge[]; newlyUnlocked: BadgeId[] } {
const newlyUnlocked: BadgeId[] = [];
const updatedBadges = newBadges.map((badge) => {
if (badge.unlocked) return badge;
let shouldUnlock = false;
switch (badge.id) {
case 'first_scan':
shouldUnlock = progress.totalScans >= 1;
break;
case 'connoisseur':
shouldUnlock = progress.uniqueGrapes.length >= 10;
break;
case 'on_fire':
shouldUnlock = progress.streak >= 7;
break;
case 'sharp_eye':
shouldUnlock = progress.highConfidenceScans >= 5;
break;
case 'explorer':
// Simplified: 10+ scans spread across regions (mock check)
shouldUnlock = progress.totalScans >= 10;
break;
case 'perfectionist':
shouldUnlock = progress.totalScans >= 50;
break;
case 'master':
// Check all others unlocked
shouldUnlock = newBadges.filter((b) => b.id !== 'master').every((b) => b.unlocked);
break;
}
if (shouldUnlock) {
newlyUnlocked.push(badge.id);
return { ...badge, unlocked: true, unlockedAt: new Date().toISOString() };
}
return badge;
});
return { badges: updatedBadges, newlyUnlocked };
}

185
VinEye/src/utils/cepages.ts Normal file
View file

@ -0,0 +1,185 @@
export interface Ceepage {
id: string;
name: { fr: string; en: string };
origin: { fr: string; en: string };
color: 'rouge' | 'blanc' | 'rosé';
characteristics: { fr: string; en: string };
regions: string[];
imageUrl?: string;
}
export const cepages: Ceepage[] = [
{
id: 'cabernet_sauvignon',
name: { fr: 'Cabernet Sauvignon', en: 'Cabernet Sauvignon' },
origin: { fr: 'Bordeaux, France', en: 'Bordeaux, France' },
color: 'rouge',
characteristics: {
fr: 'Tanins élevés, arômes de cassis, cèdre et poivron vert. Vieillissement excellent.',
en: 'High tannins, aromas of blackcurrant, cedar and green pepper. Excellent ageing potential.',
},
regions: ['Bordeaux', 'Napa Valley', 'Coonawarra', 'Maipo Valley'],
},
{
id: 'merlot',
name: { fr: 'Merlot', en: 'Merlot' },
origin: { fr: 'Bordeaux, France', en: 'Bordeaux, France' },
color: 'rouge',
characteristics: {
fr: 'Souple et fruité, arômes de prune, chocolat et épices douces. Tanins soyeux.',
en: 'Soft and fruity, aromas of plum, chocolate and soft spices. Silky tannins.',
},
regions: ['Saint-Émilion', 'Pomerol', 'California', 'Washington State'],
},
{
id: 'pinot_noir',
name: { fr: 'Pinot Noir', en: 'Pinot Noir' },
origin: { fr: 'Bourgogne, France', en: 'Burgundy, France' },
color: 'rouge',
characteristics: {
fr: 'Délicat et élégant, arômes de cerise, framboise et sous-bois. Tanins fins.',
en: 'Delicate and elegant, aromas of cherry, raspberry and forest floor. Fine tannins.',
},
regions: ['Bourgogne', 'Oregon', 'Nouvelle-Zélande', 'Alsace'],
},
{
id: 'syrah',
name: { fr: 'Syrah / Shiraz', en: 'Syrah / Shiraz' },
origin: { fr: 'Vallée du Rhône, France', en: 'Rhône Valley, France' },
color: 'rouge',
characteristics: {
fr: 'Puissant et épicé, arômes de mûre, poivre noir et olive. Couleur intense.',
en: 'Powerful and spicy, aromas of blackberry, black pepper and olive. Intense color.',
},
regions: ['Côtes du Rhône', 'Barossa Valley', 'McLaren Vale', 'Colchagua'],
},
{
id: 'chardonnay',
name: { fr: 'Chardonnay', en: 'Chardonnay' },
origin: { fr: 'Bourgogne, France', en: 'Burgundy, France' },
color: 'blanc',
characteristics: {
fr: "Polyvalent, arômes de pomme, vanille et beurre selon l'élevage. Très expressif.",
en: 'Versatile, aromas of apple, vanilla and butter depending on ageing. Very expressive.',
},
regions: ['Chablis', 'Meursault', 'Napa Valley', 'Mâconnais'],
},
{
id: 'sauvignon_blanc',
name: { fr: 'Sauvignon Blanc', en: 'Sauvignon Blanc' },
origin: { fr: 'Loire, France', en: 'Loire Valley, France' },
color: 'blanc',
characteristics: {
fr: 'Vif et aromatique, arômes de citron vert, groseille et herbe coupée. Acidité marquée.',
en: 'Crisp and aromatic, aromas of lime, gooseberry and cut grass. Marked acidity.',
},
regions: ['Sancerre', 'Pouilly-Fumé', 'Marlborough', 'Bordeaux'],
},
{
id: 'riesling',
name: { fr: 'Riesling', en: 'Riesling' },
origin: { fr: 'Alsace / Rhénanie, France-Allemagne', en: 'Alsace / Rhine, France-Germany' },
color: 'blanc',
characteristics: {
fr: 'Aromatique et minéral, arômes de pêche, citron et pétrole. Acidité élevée, vieillissement exceptionnel.',
en: 'Aromatic and mineral, aromas of peach, citrus and petrol. High acidity, exceptional ageing.',
},
regions: ['Alsace', 'Mosel', 'Clare Valley', 'Rheingau'],
},
{
id: 'grenache',
name: { fr: 'Grenache', en: 'Grenache' },
origin: { fr: 'Aragon, Espagne', en: 'Aragon, Spain' },
color: 'rouge',
characteristics: {
fr: 'Chaleureux et fruité, arômes de fraise, cerise et garrigue. Fort en alcool.',
en: 'Warm and fruity, aromas of strawberry, cherry and scrubland. High alcohol.',
},
regions: ['Châteauneuf-du-Pape', 'Priorat', 'Gigondas', 'Barossa Valley'],
},
{
id: 'malbec',
name: { fr: 'Malbec', en: 'Malbec' },
origin: { fr: 'Sud-Ouest, France', en: 'South-West France' },
color: 'rouge',
characteristics: {
fr: 'Intense et velouté, arômes de prune, violette et cacao. Tanins doux et ronds.',
en: 'Intense and velvety, aromas of plum, violet and cocoa. Soft, rounded tannins.',
},
regions: ['Mendoza', 'Cahors', 'Salta', 'Patagonie'],
},
{
id: 'tempranillo',
name: { fr: 'Tempranillo', en: 'Tempranillo' },
origin: { fr: 'Rioja, Espagne', en: 'Rioja, Spain' },
color: 'rouge',
characteristics: {
fr: 'Fruité et terreux, arômes de cerise, cuir et tabac. Vieillissement en barrique apprécié.',
en: 'Fruity and earthy, aromas of cherry, leather and tobacco. Barrel ageing appreciated.',
},
regions: ['Rioja', 'Ribera del Duero', 'Toro', 'Penedès'],
},
{
id: 'sangiovese',
name: { fr: 'Sangiovese', en: 'Sangiovese' },
origin: { fr: 'Toscane, Italie', en: 'Tuscany, Italy' },
color: 'rouge',
characteristics: {
fr: 'Acide et tannique, arômes de cerise acidulée, herbes et terre. Grande acidité.',
en: 'Acidic and tannic, aromas of tart cherry, herbs and earth. High acidity.',
},
regions: ['Chianti', 'Brunello di Montalcino', 'Montepulciano', 'Morellino'],
},
{
id: 'nebbiolo',
name: { fr: 'Nebbiolo', en: 'Nebbiolo' },
origin: { fr: 'Piémont, Italie', en: 'Piedmont, Italy' },
color: 'rouge',
characteristics: {
fr: 'Très tannique et austère jeune, arômes de rose, goudron et cerise séchée. Vieillissement impératif.',
en: 'Very tannic and austere when young, aromas of rose, tar and dried cherry. Ageing essential.',
},
regions: ['Barolo', 'Barbaresco', 'Gattinara', 'Carema'],
},
{
id: 'gamay',
name: { fr: 'Gamay', en: 'Gamay' },
origin: { fr: 'Beaujolais, France', en: 'Beaujolais, France' },
color: 'rouge',
characteristics: {
fr: 'Léger et fruité, arômes de framboise, banane et bonbon. Peu tannique, à boire jeune.',
en: 'Light and fruity, aromas of raspberry, banana and candy. Low tannins, best drunk young.',
},
regions: ['Beaujolais', 'Moulin-à-Vent', 'Morgon', 'Touraine'],
},
{
id: 'chenin_blanc',
name: { fr: 'Chenin Blanc', en: 'Chenin Blanc' },
origin: { fr: 'Loire, France', en: 'Loire Valley, France' },
color: 'blanc',
characteristics: {
fr: 'Polyvalent, arômes de coing, miel et fleurs blanches. Acidité élevée. Sec à liquoreux.',
en: 'Versatile, aromas of quince, honey and white flowers. High acidity. Dry to sweet.',
},
regions: ['Vouvray', 'Anjou', 'Savennières', 'Stellenbosch'],
},
{
id: 'semillon',
name: { fr: 'Sémillon', en: 'Sémillon' },
origin: { fr: 'Bordeaux, France', en: 'Bordeaux, France' },
color: 'blanc',
characteristics: {
fr: "Riche et onctueux, arômes de citron, cire d'abeille et miel. Vieillissement remarquable.",
en: 'Rich and unctuous, aromas of lemon, beeswax and honey. Remarkable ageing.',
},
regions: ['Sauternes', 'Barsac', 'Hunter Valley', 'White Bordeaux'],
},
];
export function getCepageById(id: string): Ceepage | undefined {
return cepages.find((c) => c.id === id);
}
export function getRandomCeepage(): Ceepage {
return cepages[Math.floor(Math.random() * cepages.length)];
}

Some files were not shown because too many files have changed in this diff Show more