add VinEye frontend app + fix hardcoded paths + gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
25
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# 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/
|
||||||
|
|
@ -10,7 +10,7 @@ The data used in this study came from [kaggle](kaggle.com/datasets/rm1000/grape-
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Model Structure
|
## Model Structure
|
||||||
|
|
||||||
Our model is a Convolutional Neural Network (CNN) built using Keras API with TensorFlow backend. It includes several convolutional layers followed by batch normalization, ReLU activation function and max pooling for downsampling.
|
Our model is a Convolutional Neural Network (CNN) built using Keras API with TensorFlow backend. It includes several convolutional layers followed by batch normalization, ReLU activation function and max pooling for downsampling.
|
||||||
Dropout layers are used for regularization to prevent overfitting. The architecture details and parameters are as follows:
|
Dropout layers are used for regularization to prevent overfitting. The architecture details and parameters are as follows:
|
||||||
|
|
|
||||||
23
VinEye/.claude/launch.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
VinEye/.claude/memory/MEMORY.md
Normal 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 -->
|
||||||
29
VinEye/.claude/notes/_features.md
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
VinEye/CLAUDE.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# VinEye
|
||||||
|
|
||||||
|
Application mobile React Native (Expo) de détection de cépages par IA.
|
||||||
|
Analyse la vigne en temps réel via la caméra, identifie le cépage, et gamifie la progression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Couche | Technologies |
|
||||||
|
|--------|-------------|
|
||||||
|
| Framework | React Native + Expo SDK 54 (bare workflow) |
|
||||||
|
| Navigation | React Navigation v7 (NativeStack + BottomTabs) — **PAS Expo Router** |
|
||||||
|
| Langage | TypeScript strict |
|
||||||
|
| UI | Composants custom (pas de shadcn — RN only) |
|
||||||
|
| Animations | React Native Reanimated v4 (`useEffect` vient de `react`, **pas** de reanimated) |
|
||||||
|
| IA | TFLite mock (weighted random : 70% vine / 20% uncertain / 10% not_vine) |
|
||||||
|
| Persistance | AsyncStorage (`@react-native-async-storage/async-storage`) |
|
||||||
|
| i18n | i18next + react-i18next (FR + EN) |
|
||||||
|
| Caméra | expo-camera |
|
||||||
|
| Haptics | expo-haptics |
|
||||||
|
| SVG | react-native-svg |
|
||||||
|
| Lottie | lottie-react-native |
|
||||||
|
| Package manager | **pnpm** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
VinEye/
|
||||||
|
├── App.tsx # Entry point (i18n init + RootNavigator)
|
||||||
|
├── src/
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ ├── images/ # logo.svg, icon.png, splash.png
|
||||||
|
│ │ └── lottie/ # confetti.json, scan-success.json, vine-leaf.json, level-up.json
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ # Button, Card, Badge, ProgressCircle, AnimatedCounter
|
||||||
|
│ │ ├── scanner/ # DetectionFrame, CameraOverlay, ConfidenceMeter
|
||||||
|
│ │ ├── gamification/ # XPBar, BadgeCard, LevelIndicator, StreakCounter
|
||||||
|
│ │ └── history/ # ScanCard, ScanList
|
||||||
|
│ ├── hooks/ # useDetection, useGameProgress, useHistory
|
||||||
|
│ ├── i18n/ # fr.json, en.json, index.ts
|
||||||
|
│ ├── navigation/ # RootNavigator, BottomTabNavigator, linking.ts
|
||||||
|
│ ├── screens/ # SplashScreen, HomeScreen, ScannerScreen, ResultScreen, HistoryScreen, ProfileScreen
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── tflite/model.ts # Mock TFLite inference
|
||||||
|
│ │ ├── storage.ts # AsyncStorage wrapper typé
|
||||||
|
│ │ └── haptics.ts # hapticSuccess/Warning/Error/Light/Medium/Heavy
|
||||||
|
│ ├── theme/ # colors.ts, typography.ts, spacing.ts, index.ts
|
||||||
|
│ ├── types/ # detection.ts, gamification.ts, navigation.ts
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── cepages.ts # 15 cépages (origine, couleur, caractéristiques, régions)
|
||||||
|
│ └── achievements.ts # XP_REWARDS, LEVELS, BADGE_DEFINITIONS, checkNewBadges, getLevelForXP
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
```
|
||||||
|
RootNavigator (Stack)
|
||||||
|
├── Splash → SplashScreen (auto-navigate vers Main après 2.8s)
|
||||||
|
├── Main → BottomTabNavigator
|
||||||
|
│ ├── Home → HomeScreen
|
||||||
|
│ ├── Scanner → ScannerScreen (bouton FAB central)
|
||||||
|
│ ├── History → HistoryScreen
|
||||||
|
│ └── Profile → ProfileScreen
|
||||||
|
└── Result (modal) → ResultScreen (slide_from_bottom)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Tokens (colors.ts)
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| `primary[700]` | `#2D6A4F` | Tab active, CTA principal |
|
||||||
|
| `primary[800]` | `#1B4332` | Scanner FAB |
|
||||||
|
| `primary[900]` | `#0A2318` | Ombres |
|
||||||
|
| `accent[500]` | `#7C3AED` | Badges, accents violet raisin |
|
||||||
|
| `surface` | `#FFFFFF` | Fond tab bar, cards |
|
||||||
|
| `background` | `#F8FBF9` | Fond écrans |
|
||||||
|
| `neutral[300]` | `#D1D5DB` | Bordures |
|
||||||
|
| `neutral[400]` | `#9CA3AF` | Tab inactive |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gamification
|
||||||
|
|
||||||
|
- **7 niveaux** : Bourgeon → Apprenti Vigneron → Vigneron → Expert Viticole → Sommelier → Grand Cru → Maître de Chai
|
||||||
|
- **XP** : +10 (vigne détectée), +5 (incertain), +15 (streak bonus)
|
||||||
|
- **7 badges** : premier_scan, amateur, expert, streaker_3, streaker_7, collectionneur, marathonien
|
||||||
|
- **Streak** : scan quotidien consécutif
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnalités clés
|
||||||
|
|
||||||
|
| Feature | Fichier principal | Statut |
|
||||||
|
|---------|-------------------|--------|
|
||||||
|
| Splash animée | `screens/SplashScreen.tsx` | ✅ |
|
||||||
|
| Scanner caméra | `screens/ScannerScreen.tsx` | ✅ |
|
||||||
|
| Résultat + cépage | `screens/ResultScreen.tsx` | ✅ |
|
||||||
|
| Historique + search | `screens/HistoryScreen.tsx` | ✅ |
|
||||||
|
| Profil + badges | `screens/ProfileScreen.tsx` | ✅ |
|
||||||
|
| Gamification XP | `hooks/useGameProgress.ts` | ✅ |
|
||||||
|
| Persistance | `services/storage.ts` | ✅ |
|
||||||
|
| Bilingue FR/EN | `i18n/` | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Package manager : **pnpm**
|
||||||
|
- Path alias : `@/*` → `src/*`
|
||||||
|
- `useEffect` toujours depuis `react` (jamais depuis `react-native-reanimated`)
|
||||||
|
- Navigation : React Navigation v7 uniquement, **jamais Expo Router** (`src/app/` est interdit — renommé en `src/screens/`)
|
||||||
|
- Max 300 lignes par fichier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start # Lance Metro bundler
|
||||||
|
pnpm android # Build Android
|
||||||
|
pnpm ios # Build iOS
|
||||||
|
```
|
||||||
41
VinEye/app.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
VinEye/assets/Screenshot_20260329_143114_Expo Go.jpg
Normal file
|
After Width: | Height: | Size: 572 KiB |
BIN
VinEye/assets/Screenshot_20260329_145021_Expo Go.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
VinEye/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
VinEye/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
VinEye/assets/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
VinEye/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
9
VinEye/babel.config.js
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="nativewind/types" />
|
||||||
57
VinEye/package.json
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"nativewind": "^4.2.3",
|
||||||
|
"react": "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-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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
7103
VinEye/pnpm-lock.yaml
Normal file
BIN
VinEye/src/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
VinEye/src/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
41
VinEye/src/assets/images/logo.svg
Normal 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 |
BIN
VinEye/src/assets/images/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
39
VinEye/src/assets/lottie/confetti.json
Normal 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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
VinEye/src/assets/lottie/level-up.json
Normal 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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
38
VinEye/src/assets/lottie/scan-success.json
Normal 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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
38
VinEye/src/assets/lottie/vine-leaf.json
Normal 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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# TFLite model placeholder - replace with vine_detector.tflite
|
||||||
76
VinEye/src/components/gamification/BadgeCard.tsx
Normal 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] },
|
||||||
|
});
|
||||||
54
VinEye/src/components/gamification/LevelIndicator.tsx
Normal 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],
|
||||||
|
},
|
||||||
|
});
|
||||||
74
VinEye/src/components/gamification/ProgressRing.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
VinEye/src/components/gamification/StreakCounter.tsx
Normal 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],
|
||||||
|
},
|
||||||
|
});
|
||||||
78
VinEye/src/components/gamification/XPBar.tsx
Normal 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' },
|
||||||
|
});
|
||||||
126
VinEye/src/components/history/ScanCard.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
68
VinEye/src/components/history/ScanList.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
56
VinEye/src/components/home/components/homeheader.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { View, ScrollView, TouchableOpacity, TextInput } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { ProgressRing } from "@/components/gamification/ProgressRing";
|
||||||
|
import { ScanCard } from "@/components/history/ScanCard";
|
||||||
|
import { useGameProgress } from "@/hooks/useGameProgress";
|
||||||
|
import { useHistory } from "@/hooks/useHistory";
|
||||||
|
import { colors } from "@/theme/colors";
|
||||||
|
import {
|
||||||
|
getLevelForXP,
|
||||||
|
getLevelNumber,
|
||||||
|
getXPProgress,
|
||||||
|
} from "@/utils/achievements";
|
||||||
|
import type { BottomTabParamList } from "@/types/navigation";
|
||||||
|
import StatCard from "@/components/home/gamificationstat";
|
||||||
|
import StatisticsSection from "@/components/home/statssection";
|
||||||
|
|
||||||
|
export default function SectionHeader({
|
||||||
|
title,
|
||||||
|
onViewAll,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onViewAll?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<Text className="text-[17px] font-semibold text-neutral-900">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{onViewAll && (
|
||||||
|
<TouchableOpacity onPress={onViewAll}>
|
||||||
|
<Text
|
||||||
|
className="text-[13px] font-medium"
|
||||||
|
style={{ color: colors.primary[700] }}
|
||||||
|
>
|
||||||
|
{t("common.viewAll") ?? "View all"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
VinEye/src/components/home/gamificationstat.tsx
Normal 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;
|
||||||
97
VinEye/src/components/home/statssection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
VinEye/src/components/scanner/CameraOverlay.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
58
VinEye/src/components/scanner/ConfidenceMeter.tsx
Normal 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; // 0–100
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
56
VinEye/src/components/scanner/DetectionFrame.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
45
VinEye/src/components/ui/AnimatedCounter.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
VinEye/src/components/ui/Badge.tsx
Normal 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] },
|
||||||
|
};
|
||||||
106
VinEye/src/components/ui/Button.tsx
Normal 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 };
|
||||||
48
VinEye/src/components/ui/Card.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
81
VinEye/src/components/ui/ProgressCircle.tsx
Normal 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; // 0–1
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
24
VinEye/src/components/ui/separator.tsx
Normal 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 };
|
||||||
88
VinEye/src/components/ui/text.tsx
Normal 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 };
|
||||||
33
VinEye/src/hooks/useDetection.ts
Normal 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 };
|
||||||
|
}
|
||||||
151
VinEye/src/hooks/useGameProgress.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
VinEye/src/hooks/useHistory.ts
Normal 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 };
|
||||||
|
}
|
||||||
114
VinEye/src/i18n/en.json
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"scan": "Scan",
|
||||||
|
"history": "History",
|
||||||
|
"profile": "Profile",
|
||||||
|
"home": "Home",
|
||||||
|
"viewAll": "View all",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"greeting": "Hello, Winemaker!",
|
||||||
|
"scanButton": "Scan a vine",
|
||||||
|
"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!"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
114
VinEye/src/i18n/fr.json
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"scan": "Scanner",
|
||||||
|
"history": "Historique",
|
||||||
|
"profile": "Profil",
|
||||||
|
"home": "Accueil",
|
||||||
|
"viewAll": "Voir tout",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Confirmer",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"error": "Erreur",
|
||||||
|
"retry": "Réessayer"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"greeting": "Bonjour, Vigneron !",
|
||||||
|
"scanButton": "Scanner une vigne",
|
||||||
|
"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 !"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
148
VinEye/src/navigation/BottomTabNavigator.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, Platform, LayoutAnimation, UIManager } from 'react-native';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
// Imports de tes écrans
|
||||||
|
import HomeScreen from '@/screens/HomeScreen';
|
||||||
|
import ScannerScreen from '@/screens/ScannerScreen';
|
||||||
|
import HistoryScreen from '@/screens/HistoryScreen';
|
||||||
|
import ProfileScreen from '@/screens/ProfileScreen';
|
||||||
|
|
||||||
|
// Activation de LayoutAnimation pour Android
|
||||||
|
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||||
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
function MyCustomTabBar({ state, descriptors, navigation }: any) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Gestion de la marge basse pour éviter la superposition avec la barre système
|
||||||
|
const safeBottom = Platform.OS === 'android' ? Math.max(insets.bottom, 24) : insets.bottom;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="absolute bg-white flex-row items-center justify-between px-2"
|
||||||
|
style={{
|
||||||
|
bottom: safeBottom + 10,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
height: 70,
|
||||||
|
borderRadius: 35,
|
||||||
|
elevation: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.routes.map((route: any, index: number) => {
|
||||||
|
const { options } = descriptors[route.key];
|
||||||
|
const isFocused = state.index === index;
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
// 1. Retour Haptique (Vibration légère "Impact")
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Animation de la transition (Pill expansion)
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
|
|
||||||
|
const event = navigation.emit({
|
||||||
|
type: 'tabPress',
|
||||||
|
target: route.key,
|
||||||
|
canPreventDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFocused && !event.defaultPrevented) {
|
||||||
|
navigation.navigate(route.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Choix de l'icône (Outline vs Solid)
|
||||||
|
const getIcon = (name: string, focused: boolean) => {
|
||||||
|
switch (name) {
|
||||||
|
case 'Home': return focused ? 'home' : 'home-outline';
|
||||||
|
case 'History': return focused ? 'receipt' : 'receipt-outline';
|
||||||
|
case 'Scanner': return focused ? 'scan' : 'scan-outline';
|
||||||
|
case 'Profile': return focused ? 'person' : 'person-outline';
|
||||||
|
default: return 'help-outline';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = options.tabBarLabel || route.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{ flex: isFocused ? 2 : 1 }}
|
||||||
|
className="items-center justify-center h-full"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`flex-row items-center justify-center py-2.5 ${
|
||||||
|
isFocused ? 'bg-gray-900 px-5' : 'bg-transparent px-0'
|
||||||
|
}`}
|
||||||
|
style={{ borderRadius: 999 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={getIcon(route.name, isFocused) as any}
|
||||||
|
size={22}
|
||||||
|
color={isFocused ? '#FFFFFF' : '#9CA3AF'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isFocused && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className="ml-2 text-white font-bold text-[13px]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</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="History"
|
||||||
|
component={HistoryScreen}
|
||||||
|
options={{ tabBarLabel: t('common.history') }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Scanner"
|
||||||
|
component={ScannerScreen}
|
||||||
|
options={{ tabBarLabel: 'Scan' }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Profile"
|
||||||
|
component={ProfileScreen}
|
||||||
|
options={{ tabBarLabel: t('common.profile') }}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
VinEye/src/navigation/RootNavigator.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
|
||||||
|
import SplashScreen from '@/screens/SplashScreen';
|
||||||
|
import ResultScreen from '@/screens/ResultScreen';
|
||||||
|
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.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
VinEye/src/navigation/linking.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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',
|
||||||
|
History: 'history',
|
||||||
|
Profile: 'profile',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Result: 'result',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default linking;
|
||||||
217
VinEye/src/screens/HistoryScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
217
VinEye/src/screens/HomeScreen.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { View, ScrollView, TouchableOpacity, TextInput } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { ProgressRing } from "@/components/gamification/ProgressRing";
|
||||||
|
import { ScanCard } from "@/components/history/ScanCard";
|
||||||
|
import { useGameProgress } from "@/hooks/useGameProgress";
|
||||||
|
import { useHistory } from "@/hooks/useHistory";
|
||||||
|
import { colors } from "@/theme/colors";
|
||||||
|
import {
|
||||||
|
getLevelForXP,
|
||||||
|
getLevelNumber,
|
||||||
|
getXPProgress,
|
||||||
|
} from "@/utils/achievements";
|
||||||
|
import type { BottomTabParamList } from "@/types/navigation";
|
||||||
|
import StatCard from "@/components/home/gamificationstat";
|
||||||
|
import StatisticsSection from "@/components/home/statssection";
|
||||||
|
import SectionHeader from "@/components/home/components/homeheader";
|
||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
|
||||||
|
type HomeNav = BottomTabNavigationProp<BottomTabParamList, "Home">;
|
||||||
|
|
||||||
|
interface GameProgress {
|
||||||
|
totalScans: number;
|
||||||
|
uniqueGrapes: string[];
|
||||||
|
streak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProgress = {
|
||||||
|
streak: 12, // La série de jours
|
||||||
|
xpTotal: 2450, // Le total de points XP
|
||||||
|
// ... tes autres données
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAT_CARDS: {
|
||||||
|
labelKey: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
bg: string;
|
||||||
|
accent: string;
|
||||||
|
dark: string;
|
||||||
|
getValue: (p: GameProgress) => number;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
labelKey: "home.totalScans",
|
||||||
|
icon: "scan-outline",
|
||||||
|
bg: "#E8F0EA",
|
||||||
|
accent: "#2D6A4F",
|
||||||
|
dark: "#1B4332",
|
||||||
|
getValue: (p) => p.totalScans,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "home.uniqueGrapes",
|
||||||
|
icon: "leaf-outline",
|
||||||
|
bg: "#EBE5F6",
|
||||||
|
accent: "#7B5EA7",
|
||||||
|
dark: "#3E0047",
|
||||||
|
getValue: (p) => p.uniqueGrapes.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelKey: "home.currentStreak",
|
||||||
|
icon: "flame-outline",
|
||||||
|
bg: "#F0EBE3",
|
||||||
|
accent: "#8B7355",
|
||||||
|
dark: "#4A3F30",
|
||||||
|
getValue: (p) => p.streak,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation<HomeNav>();
|
||||||
|
const { progress } = useGameProgress();
|
||||||
|
const { history } = useHistory();
|
||||||
|
const pulse = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pulse.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1.04, { duration: 1200 }),
|
||||||
|
withTiming(1, { duration: 1200 }),
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pulseStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: pulse.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lastScan = history[0];
|
||||||
|
|
||||||
|
const currentLevel = getLevelForXP(progress.xp);
|
||||||
|
const levelNumber = getLevelNumber(progress.xp);
|
||||||
|
const {
|
||||||
|
current: xpInLevel,
|
||||||
|
total: xpTotal,
|
||||||
|
ratio: xpRatio,
|
||||||
|
} = getXPProgress(progress.xp);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-[#FAFAFA]" edges={["top"]}>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 130, // La hauteur de ta barre (70) + le margin bas (~34) + de l'espace pour respirer (26)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header — title left, icons right */}
|
||||||
|
<View className="flex-row items-center justify-between px-5 pt-3 pb-4">
|
||||||
|
<View className="flex-1 flex-row items-center gap-2">
|
||||||
|
<View className="flex-1 flex-row items-center rounded-full bg-neutral-200 px-3 py-2">
|
||||||
|
<Ionicons
|
||||||
|
name="search-outline"
|
||||||
|
size={18}
|
||||||
|
color={colors.neutral[500]}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
className="ml-2 flex-1 text-[14px]"
|
||||||
|
placeholder={t("history.search")}
|
||||||
|
placeholderTextColor={colors.neutral[500]}
|
||||||
|
style={{ color: colors.neutral[900], paddingVertical: 0 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="h-9 w-9 items-center justify-center rounded-full bg-neutral-200"
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="add-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.neutral[800]}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<StatisticsSection progress={userProgress} />
|
||||||
|
|
||||||
|
{/* Scan banner */}
|
||||||
|
<View
|
||||||
|
className="mx-5 mb-6 rounded-2xl px-5 pt-5 pb-4 shadow-sm overflow-hidden relative border border-gray-50"
|
||||||
|
style={{ backgroundColor: colors.primary[100] }}
|
||||||
|
>
|
||||||
|
{/* Decorative leaf top-right */}
|
||||||
|
<View className="absolute -top-1 -right-1 opacity-30">
|
||||||
|
<Ionicons name="leaf" size={80} color={colors.primary[600]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
className="mb-1 text-[18px] font-bold"
|
||||||
|
style={{ color: colors.primary[900] }}
|
||||||
|
>
|
||||||
|
{t("home.bannerTitle")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="mb-5 max-w-[220px] text-[13px] leading-[18px]"
|
||||||
|
style={{ color: colors.primary[700] }}
|
||||||
|
>
|
||||||
|
{t("home.bannerSubtitle")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Scan icon centered */}
|
||||||
|
<View className="mb-5 items-center">
|
||||||
|
<Animated.View style={pulseStyle}>
|
||||||
|
<View
|
||||||
|
className="h-20 w-20 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: colors.primary[200] }}
|
||||||
|
>
|
||||||
|
<Ionicons name="scan" size={36} color={colors.primary[800]} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Full-width scan button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.8}
|
||||||
|
className="flex-row items-center justify-center gap-2 rounded-full py-3"
|
||||||
|
style={{ backgroundColor: colors.primary[800] }}
|
||||||
|
onPress={() => navigation.navigate("Scanner")}
|
||||||
|
>
|
||||||
|
<Text className="text-[15px] font-semibold text-white">
|
||||||
|
{t("home.scanButton")}
|
||||||
|
</Text>
|
||||||
|
<MaterialIcons name="arrow-forward-ios" size={16} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Last scan section */}
|
||||||
|
{lastScan && (
|
||||||
|
<View className="mx-5 mb-6 gap-2">
|
||||||
|
<SectionHeader
|
||||||
|
title={t("home.lastScan")}
|
||||||
|
onViewAll={() => navigation.navigate("History")}
|
||||||
|
/>
|
||||||
|
<ScanCard record={lastScan} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="h-8" />
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
VinEye/src/screens/ProfileScreen.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { XPBar } from '@/components/gamification/XPBar';
|
||||||
|
import { BadgeCard } from '@/components/gamification/BadgeCard';
|
||||||
|
import { useGameProgress } from '@/hooks/useGameProgress';
|
||||||
|
import { useHistory } from '@/hooks/useHistory';
|
||||||
|
import { colors } from '@/theme/colors';
|
||||||
|
import { typography } from '@/theme/typography';
|
||||||
|
import { spacing } from '@/theme/spacing';
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { progress, resetProgress } = useGameProgress();
|
||||||
|
const { clearHistory } = useHistory();
|
||||||
|
|
||||||
|
const successRate =
|
||||||
|
progress.totalScans > 0
|
||||||
|
? Math.round(
|
||||||
|
(progress.totalScans -
|
||||||
|
// we don't store not_vine count separately, so approximate
|
||||||
|
0) /
|
||||||
|
progress.totalScans *
|
||||||
|
100
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>🧑🌾</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.username}>Vigneron</Text>
|
||||||
|
<Text style={styles.xpTotal}>{progress.xp} XP</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* XP Bar */}
|
||||||
|
<Card style={styles.section} variant="elevated">
|
||||||
|
<XPBar xp={progress.xp} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<Card style={styles.section} variant="elevated">
|
||||||
|
<Text style={styles.sectionTitle}>{t('profile.stats')}</Text>
|
||||||
|
<View style={styles.statsGrid}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{progress.totalScans}</Text>
|
||||||
|
<Text style={styles.statLabel}>{t('profile.totalScans')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{progress.uniqueGrapes.length}</Text>
|
||||||
|
<Text style={styles.statLabel}>{t('profile.uniqueGrapes')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statValue, { color: colors.warning }]}>{progress.bestStreak}</Text>
|
||||||
|
<Text style={styles.statLabel}>{t('profile.bestStreak')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{progress.streak}</Text>
|
||||||
|
<Text style={styles.statLabel}>{t('home.currentStreak')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<Card style={styles.section} variant="elevated">
|
||||||
|
<Text style={styles.sectionTitle}>{t('profile.badges')}</Text>
|
||||||
|
<View style={styles.badgesGrid}>
|
||||||
|
{progress.badges.map((badge) => (
|
||||||
|
<BadgeCard key={badge.id} badge={badge} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<Card style={styles.section} variant="elevated">
|
||||||
|
<TouchableOpacity style={styles.settingRow} onPress={handleLanguageToggle}>
|
||||||
|
<Text style={styles.settingLabel}>{t('profile.language')}</Text>
|
||||||
|
<Text style={styles.settingValue}>
|
||||||
|
{i18n.language === 'fr' ? '🇫🇷 Français' : '🇬🇧 English'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.settingRow} onPress={handleReset}>
|
||||||
|
<Text style={[styles.settingLabel, { color: colors.danger }]}>
|
||||||
|
{t('profile.resetData')}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.settingValue}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<View style={{ height: spacing['2xl'] }} />
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safe: { flex: 1, backgroundColor: colors.background },
|
||||||
|
header: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing['2xl'],
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: colors.primary[200],
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
avatarText: { fontSize: 40 },
|
||||||
|
username: {
|
||||||
|
fontSize: typography.fontSizes.xl,
|
||||||
|
fontWeight: typography.fontWeights.bold,
|
||||||
|
color: colors.neutral[900],
|
||||||
|
},
|
||||||
|
xpTotal: {
|
||||||
|
fontSize: typography.fontSizes.sm,
|
||||||
|
color: colors.primary[700],
|
||||||
|
fontWeight: typography.fontWeights.semibold,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginHorizontal: spacing.base,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: typography.fontSizes.md,
|
||||||
|
fontWeight: typography.fontWeights.semibold,
|
||||||
|
color: colors.neutral[800],
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
statsGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
width: '47%',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.neutral[100],
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: typography.fontSizes.xl,
|
||||||
|
fontWeight: typography.fontWeights.bold,
|
||||||
|
color: colors.primary[800],
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: typography.fontSizes.xs,
|
||||||
|
color: colors.neutral[600],
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
badgesGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
settingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
settingLabel: {
|
||||||
|
fontSize: typography.fontSizes.base,
|
||||||
|
color: colors.neutral[800],
|
||||||
|
},
|
||||||
|
settingValue: {
|
||||||
|
fontSize: typography.fontSizes.sm,
|
||||||
|
color: colors.neutral[600],
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.neutral[200],
|
||||||
|
},
|
||||||
|
});
|
||||||
190
VinEye/src/screens/ResultScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
VinEye/src/screens/ScannerScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
VinEye/src/screens/SplashScreen.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { View, Text, 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}>
|
||||||
|
<Text style={styles.leafEmoji}>🍃</Text>
|
||||||
|
<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.primary[900],
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 24,
|
||||||
|
},
|
||||||
|
leafEmoji: { fontSize: 80 },
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
25
VinEye/src/services/haptics.ts
Normal 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);
|
||||||
|
}
|
||||||
43
VinEye/src/services/storage.ts
Normal 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 };
|
||||||
9
VinEye/src/services/tflite/labels.ts
Normal 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];
|
||||||
52
VinEye/src/services/tflite/model.ts
Normal 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) // 70–100%
|
||||||
|
: result === 'uncertain'
|
||||||
|
? Math.floor(40 + Math.random() * 30) // 40–70%
|
||||||
|
: Math.floor(10 + Math.random() * 30); // 10–40%
|
||||||
|
|
||||||
|
const cepageId =
|
||||||
|
result === 'vine'
|
||||||
|
? cepages[Math.floor(Math.random() * cepages.length)].id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
confidence,
|
||||||
|
cepageId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
imageUri,
|
||||||
|
};
|
||||||
|
}
|
||||||
46
VinEye/src/theme/colors.ts
Normal 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;
|
||||||
3
VinEye/src/theme/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { colors } from './colors';
|
||||||
|
export { typography } from './typography';
|
||||||
|
export { spacing } from './spacing';
|
||||||
12
VinEye/src/theme/spacing.ts
Normal 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;
|
||||||
25
VinEye/src/theme/typography.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
16
VinEye/src/types/detection.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export type DetectionResult = 'vine' | 'uncertain' | 'not_vine';
|
||||||
|
|
||||||
|
export interface Detection {
|
||||||
|
result: DetectionResult;
|
||||||
|
confidence: number; // 0–100
|
||||||
|
cepageId?: string;
|
||||||
|
timestamp: number;
|
||||||
|
imageUri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanRecord {
|
||||||
|
id: string;
|
||||||
|
detection: Detection;
|
||||||
|
xpEarned: number;
|
||||||
|
createdAt: string; // ISO date
|
||||||
|
}
|
||||||
46
VinEye/src/types/gamification.ts
Normal 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;
|
||||||
|
}
|
||||||
14
VinEye/src/types/navigation.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Detection } from './detection';
|
||||||
|
|
||||||
|
export type RootStackParamList = {
|
||||||
|
Splash: undefined;
|
||||||
|
Main: undefined;
|
||||||
|
Result: { detection: Detection };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BottomTabParamList = {
|
||||||
|
Home: undefined;
|
||||||
|
Scanner: undefined;
|
||||||
|
History: undefined;
|
||||||
|
Profile: undefined;
|
||||||
|
};
|
||||||
112
VinEye/src/utils/achievements.ts
Normal 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
|
|
@ -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)];
|
||||||
|
}
|
||||||
54
VinEye/tailwind.config.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./App.{js,jsx,ts,tsx}',
|
||||||
|
'./src/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./components/**/*.{js,jsx,ts,tsx}',
|
||||||
|
],
|
||||||
|
presets: [require('nativewind/preset')],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
};
|
||||||
11
VinEye/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts"]
|
||||||
|
}
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
epoch,accuracy,val_accuracy,loss,val_loss
|
|
||||||
1,0.7641927003860474,0.15000000596046448,0.6556282639503479,2.786958932876587
|
|
||||||
2,0.875781238079071,0.24583333730697632,0.3367018401622772,145.1642608642578
|
|
||||||
3,0.9261718988418579,0.24583333730697632,0.21222706139087677,542.9489135742188
|
|
||||||
4,0.9468749761581421,0.3499999940395355,0.15502126514911652,436.6821594238281
|
|
||||||
5,0.95703125,0.30000001192092896,0.12713894248008728,1734.4005126953125
|
|
||||||
6,0.9653645753860474,0.22083333134651184,0.10625138133764267,2078.35595703125
|
|
||||||
7,0.9670572876930237,0.22083333134651184,0.10060538351535797,4190.84716796875
|
|
||||||
8,0.9708333611488342,0.22083333134651184,0.08701884001493454,2175.69384765625
|
|
||||||
9,0.9759114384651184,0.22083333134651184,0.07224109768867493,1431.79736328125
|
|
||||||
10,0.9756510257720947,0.22499999403953552,0.0758061408996582,1257.38818359375
|
|
||||||
11,0.9799479246139526,0.3291666805744171,0.06562892347574234,679.346923828125
|
|
||||||
12,0.9819010496139526,0.22083333134651184,0.05864816904067993,1250.117431640625
|
|
||||||
13,0.9837239384651184,0.22083333134651184,0.05603867396712303,949.5216064453125
|
|
||||||
14,0.9856770634651184,0.22083333134651184,0.04392676800489426,2813.852783203125
|
|
||||||
15,0.9815104007720947,0.22083333134651184,0.05770495906472206,992.2079467773438
|
|
||||||
16,0.983593761920929,0.22083333134651184,0.04698636755347252,2555.2177734375
|
|
||||||
17,0.983203113079071,0.3166666626930237,0.05349167808890343,721.4380493164062
|
|
||||||
18,0.9864583611488342,0.22499999403953552,0.046624429523944855,1216.3863525390625
|
|
||||||
19,0.9897135496139526,0.22083333134651184,0.033429499715566635,2209.611572265625
|
|
||||||
20,0.9885416626930237,0.22083333134651184,0.037510477006435394,1644.256591796875
|
|
||||||
21,0.9889323115348816,0.2874999940395355,0.032061509788036346,599.4248657226562
|
|
||||||
22,0.9888020753860474,0.22083333134651184,0.03519482910633087,2405.77587890625
|
|
||||||
23,0.9846354126930237,0.4833333194255829,0.048758625984191895,374.9194030761719
|
|
||||||
24,0.9895833134651184,0.23333333432674408,0.031843990087509155,946.0235595703125
|
|
||||||
25,0.9877604246139526,0.24166665971279144,0.0385715514421463,1195.2344970703125
|
|
||||||
26,0.9901041388511658,0.22083333134651184,0.032194558531045914,806.0076904296875
|
|
||||||
27,0.9880208373069763,0.24583333730697632,0.03730996325612068,688.756103515625
|
|
||||||
28,0.9912760257720947,0.22083333134651184,0.03252696618437767,1622.8123779296875
|
|
||||||
29,0.9947916865348816,0.22083333134651184,0.01784966140985489,1913.7918701171875
|
|
||||||
30,0.9924479126930237,0.22083333134651184,0.026797903701663017,279.2188720703125
|
|
||||||
31,0.9907552003860474,0.22083333134651184,0.033388420939445496,1134.2767333984375
|
|
||||||
32,0.9908854365348816,0.3375000059604645,0.029145648702979088,95.03201293945312
|
|
||||||
33,0.9891927242279053,0.42500001192092896,0.03102271445095539,464.6019592285156
|
|
||||||
34,0.9932291507720947,0.22083333134651184,0.021412037312984467,986.3841552734375
|
|
||||||
35,0.9923177361488342,0.22083333134651184,0.02759469673037529,760.4578857421875
|
|
||||||
36,0.991406261920929,0.24583333730697632,0.028778191655874252,593.8187255859375
|
|
||||||
37,0.9945312738418579,0.32083332538604736,0.018624553456902504,663.8523559570312
|
|
||||||
38,0.9908854365348816,0.22499999403953552,0.02799573726952076,767.1515502929688
|
|
||||||
39,0.9962239861488342,0.4375,0.013362539932131767,313.8023986816406
|
|
||||||
40,0.9946614503860474,0.22083333134651184,0.01853085868060589,1301.3148193359375
|
|
||||||
41,0.986328125,0.32083332538604736,0.04215172678232193,640.0283813476562
|
|
||||||
42,0.9925781488418579,0.23333333432674408,0.02235046960413456,218.9736328125
|
|
||||||
43,0.9944010376930237,0.4208333194255829,0.017718089744448662,202.29286193847656
|
|
||||||
44,0.9915364384651184,0.22499999403953552,0.025970684364438057,1598.172119140625
|
|
||||||
45,0.99609375,0.44999998807907104,0.014352566562592983,487.48809814453125
|
|
||||||
46,0.9934895634651184,0.30000001192092896,0.01648867316544056,996.599365234375
|
|
||||||
47,0.9924479126930237,0.24583333730697632,0.02568558044731617,1811.96630859375
|
|
||||||
48,0.9936197996139526,0.22083333134651184,0.019128015264868736,896.1229858398438
|
|
||||||
49,0.9970052242279053,0.24583333730697632,0.009085672907531261,1030.3626708984375
|
|
||||||
50,0.9944010376930237,0.24583333730697632,0.017151959240436554,1934.4542236328125
|
|
||||||
51,0.9916666746139526,0.24583333730697632,0.028664644807577133,983.9193725585938
|
|
||||||
52,0.9962239861488342,0.3791666626930237,0.01063383650034666,484.26690673828125
|
|
||||||
53,0.995312511920929,0.42500001192092896,0.016774259507656097,1236.3868408203125
|
|
||||||
54,0.9947916865348816,0.4333333373069763,0.015777425840497017,207.80308532714844
|
|
||||||
55,0.9962239861488342,0.3333333432674408,0.012974864803254604,494.0450439453125
|
|
||||||
56,0.9957031011581421,0.32083332538604736,0.012591647915542126,430.7264709472656
|
|
||||||
57,0.9966145753860474,0.5083333253860474,0.012390038929879665,194.0229034423828
|
|
||||||
58,0.9868489503860474,0.2291666716337204,0.05284683033823967,711.9586181640625
|
|
||||||
59,0.996874988079071,0.3291666805744171,0.008380413986742496,296.1285705566406
|
|
||||||
60,0.9959635138511658,0.3541666567325592,0.013547107577323914,247.80809020996094
|
|
||||||
61,0.997265636920929,0.2541666626930237,0.008858543820679188,664.0957641601562
|
|
||||||
62,0.9977864623069763,0.4541666805744171,0.007046550512313843,381.4072265625
|
|
||||||
63,0.9976562261581421,0.24166665971279144,0.009287163615226746,667.2242431640625
|
|
||||||
64,0.9951822757720947,0.4166666567325592,0.015782717615365982,180.54684448242188
|
|
||||||
65,0.998046875,0.24583333730697632,0.005884839221835136,1132.2967529296875
|
|
||||||
66,0.996874988079071,0.24583333730697632,0.008365819230675697,423.9919128417969
|
|
||||||
67,0.9944010376930237,0.4375,0.0157401654869318,591.289794921875
|
|
||||||
68,0.996874988079071,0.42500001192092896,0.009277629666030407,368.8995666503906
|
|
||||||
69,0.996874988079071,0.25833332538604736,0.009817498736083508,268.4747619628906
|
|
||||||
70,0.9981771111488342,0.32083332538604736,0.006176070775836706,848.0325927734375
|
|
||||||
71,0.9954426884651184,0.32083332538604736,0.017365239560604095,921.9395751953125
|
|
||||||
72,0.9976562261581421,0.19166666269302368,0.006243720185011625,846.9988403320312
|
|
||||||
73,0.9979166388511658,0.32083332538604736,0.007621175609529018,770.1184692382812
|
|
||||||
74,0.998046875,0.3333333432674408,0.007831843569874763,1054.477783203125
|
|
||||||
75,0.9934895634651184,0.32499998807907104,0.02367531694471836,1973.513671875
|
|
||||||
76,0.994140625,0.32083332538604736,0.02045782469213009,1825.21875
|
|
||||||
77,0.9985677003860474,0.1458333283662796,0.006224052980542183,2563.483154296875
|
|
||||||
78,0.9990885257720947,0.2916666567325592,0.0029171621426939964,1612.9859619140625
|
|
||||||
79,0.9996093511581421,0.3083333373069763,0.0023937856312841177,1210.00048828125
|
|
||||||
80,0.9973958134651184,0.32083332538604736,0.00871509313583374,2131.439453125
|
|
||||||
81,0.996874988079071,0.32083332538604736,0.009170063771307468,1381.1668701171875
|
|
||||||
82,0.9984375238418579,0.3499999940395355,0.006568868178874254,1278.370361328125
|
|
||||||
83,0.9977864623069763,0.32083332538604736,0.006044364999979734,1442.549072265625
|
|
||||||
84,0.9977864623069763,0.36666667461395264,0.006215202622115612,1152.5875244140625
|
|
||||||
85,0.9954426884651184,0.3541666567325592,0.015190036036074162,1440.297607421875
|
|
||||||
86,0.9959635138511658,0.32083332538604736,0.013142507523298264,1981.55908203125
|
|
||||||
87,0.9973958134651184,0.32499998807907104,0.008220557123422623,753.7999267578125
|
|
||||||
88,0.998046875,0.3708333373069763,0.007407734636217356,1276.0592041015625
|
|
||||||
89,0.996874988079071,0.32083332538604736,0.011465544812381268,2005.687255859375
|
|
||||||
90,0.9979166388511658,0.3125,0.009250237606465816,1785.6741943359375
|
|
||||||
91,0.9977864623069763,0.3583333194255829,0.0066549344919621944,2299.27294921875
|
|
||||||
92,0.998046875,0.3291666805744171,0.007205411791801453,1235.2969970703125
|
|
||||||
93,0.9946614503860474,0.32083332538604736,0.017886042594909668,1514.164306640625
|
|
||||||
94,0.9993489384651184,0.3958333432674408,0.0025855659041553736,1014.2952880859375
|
|
||||||
95,0.9989583492279053,0.3375000059604645,0.004219961352646351,860.9890747070312
|
|
||||||
96,0.9975260496139526,0.375,0.009455768391489983,1195.864990234375
|
|
||||||
97,0.9984375238418579,0.32083332538604736,0.005296614952385426,1493.249267578125
|
|
||||||
98,0.9958333373069763,0.3708333373069763,0.01711571030318737,1151.3814697265625
|
|
||||||
99,0.9979166388511658,0.32083332538604736,0.007663541007786989,1383.2186279296875
|
|
||||||
100,0.9990885257720947,0.32083332538604736,0.0027740655932575464,1476.4110107421875
|
|
||||||
|
|
|
@ -1,101 +0,0 @@
|
||||||
epoch,accuracy,val_accuracy,loss,val_loss
|
|
||||||
1,0.7516441941261292,0.1502770036458969,0.6869990229606628,9.279614448547363
|
|
||||||
2,0.8496019244194031,0.27770084142684937,0.41523098945617676,111.58092498779297
|
|
||||||
3,0.8849082589149475,0.23268698155879974,0.30662405490875244,1214.7138671875
|
|
||||||
4,0.906022846698761,0.23268698155879974,0.25916892290115356,2999.732666015625
|
|
||||||
5,0.9245413541793823,0.23268698155879974,0.2080700546503067,5503.68408203125
|
|
||||||
6,0.9383869767189026,0.23268698155879974,0.1752740442752838,4690.720703125
|
|
||||||
7,0.9484250545501709,0.23268698155879974,0.1510019749403,5465.9267578125
|
|
||||||
8,0.9586362242698669,0.23268698155879974,0.12660910189151764,1849.1502685546875
|
|
||||||
9,0.961405336856842,0.23268698155879974,0.11609478294849396,3567.95849609375
|
|
||||||
10,0.9634822010993958,0.23268698155879974,0.11276258528232574,4574.11767578125
|
|
||||||
11,0.9655590057373047,0.23268698155879974,0.10540036112070084,3753.94873046875
|
|
||||||
12,0.9697126746177673,0.23268698155879974,0.0942334458231926,3380.722900390625
|
|
||||||
13,0.9714434146881104,0.23268698155879974,0.08314096182584763,6275.935546875
|
|
||||||
14,0.9762893915176392,0.23268698155879974,0.0717514380812645,2442.714599609375
|
|
||||||
15,0.9733471870422363,0.23268698155879974,0.08071456849575043,3416.07861328125
|
|
||||||
16,0.9787123799324036,0.23268698155879974,0.07309815287590027,3656.95751953125
|
|
||||||
17,0.9771547317504883,0.23268698155879974,0.07524916529655457,4760.67138671875
|
|
||||||
18,0.9790585041046143,0.23268698155879974,0.06330344825983047,2295.76513671875
|
|
||||||
19,0.9790585041046143,0.23268698155879974,0.06550607830286026,5322.1533203125
|
|
||||||
20,0.9783661961555481,0.23268698155879974,0.06409229338169098,2194.40283203125
|
|
||||||
21,0.9795777201652527,0.23268698155879974,0.05746037885546684,1612.3697509765625
|
|
||||||
22,0.9806161522865295,0.23268698155879974,0.060518983751535416,4674.85400390625
|
|
||||||
23,0.9833852648735046,0.27770084142684937,0.05250076577067375,1632.55615234375
|
|
||||||
24,0.987712025642395,0.27770084142684937,0.04003383219242096,1444.8175048828125
|
|
||||||
25,0.9828660488128662,0.27770084142684937,0.05212881788611412,3077.065185546875
|
|
||||||
26,0.9816545248031616,0.23268698155879974,0.05729270353913307,987.83740234375
|
|
||||||
27,0.9845967292785645,0.27770084142684937,0.05151180922985077,1980.85009765625
|
|
||||||
28,0.9870197176933289,0.2957063615322113,0.045257747173309326,1160.206298828125
|
|
||||||
29,0.9804430603981018,0.24376730620861053,0.06315220147371292,657.8983764648438
|
|
||||||
30,0.9890965819358826,0.4058171808719635,0.03781181946396828,797.4231567382812
|
|
||||||
31,0.9844236969947815,0.23268698155879974,0.05010179430246353,1343.955810546875
|
|
||||||
32,0.9851159453392029,0.27770084142684937,0.04631909728050232,1621.481689453125
|
|
||||||
33,0.9868466854095459,0.27770084142684937,0.04147016257047653,1539.1033935546875
|
|
||||||
34,0.987885057926178,0.3150969445705414,0.03842846676707268,557.3466796875
|
|
||||||
35,0.9903080463409424,0.23268698155879974,0.029467027634382248,2310.66357421875
|
|
||||||
36,0.9863274693489075,0.23268698155879974,0.044710543006658554,1721.2637939453125
|
|
||||||
37,0.9847698211669922,0.2583102583885193,0.04918225109577179,1349.9884033203125
|
|
||||||
38,0.9906542301177979,0.3067867159843445,0.03000747598707676,957.5072631835938
|
|
||||||
39,0.9923849105834961,0.29916897416114807,0.02561911940574646,1543.9759521484375
|
|
||||||
40,0.9865005016326904,0.23268698155879974,0.03470372408628464,2082.9208984375
|
|
||||||
41,0.9892696142196655,0.27770084142684937,0.03406791016459465,1992.923095703125
|
|
||||||
42,0.9937694668769836,0.23753462731838226,0.020795246586203575,1350.656982421875
|
|
||||||
43,0.9865005016326904,0.25692519545555115,0.050108153373003006,1347.6395263671875
|
|
||||||
44,0.9901350140571594,0.23268698155879974,0.028937410563230515,2191.127197265625
|
|
||||||
45,0.9941155910491943,0.23268698155879974,0.02078220620751381,1551.0986328125
|
|
||||||
46,0.9904811382293701,0.23268698155879974,0.028728974983096123,2349.158447265625
|
|
||||||
47,0.9903080463409424,0.23268698155879974,0.033932607620954514,1677.892822265625
|
|
||||||
48,0.9868466854095459,0.27770084142684937,0.03687359392642975,1879.01904296875
|
|
||||||
49,0.9942886829376221,0.27770084142684937,0.021784603595733643,1672.6260986328125
|
|
||||||
50,0.9911734461784363,0.23268698155879974,0.026831354945898056,2027.2027587890625
|
|
||||||
51,0.9894427061080933,0.27770084142684937,0.0279900673776865,1085.9053955078125
|
|
||||||
52,0.989615797996521,0.23337949812412262,0.03173811733722687,1212.1956787109375
|
|
||||||
53,0.993423342704773,0.23268698155879974,0.022051407024264336,1802.6983642578125
|
|
||||||
54,0.9955001473426819,0.23268698155879974,0.01723671332001686,1909.1605224609375
|
|
||||||
55,0.9904811382293701,0.23268698155879974,0.03664610907435417,1560.034912109375
|
|
||||||
56,0.9918656945228577,0.27770084142684937,0.029300954192876816,983.4076538085938
|
|
||||||
57,0.9948078989982605,0.2576177418231964,0.01762007363140583,908.1100463867188
|
|
||||||
58,0.9956732392311096,0.2340720295906067,0.013033601455390453,1055.4168701171875
|
|
||||||
59,0.993423342704773,0.23268698155879974,0.022611256688833237,2751.419921875
|
|
||||||
60,0.9892696142196655,0.27839335799217224,0.03379980847239494,1141.1070556640625
|
|
||||||
61,0.9944617748260498,0.41828253865242004,0.01806570030748844,992.7396850585938
|
|
||||||
62,0.9951540231704712,0.39058172702789307,0.015332863666117191,964.7886352539062
|
|
||||||
63,0.9958463311195374,0.4861495792865753,0.013660548254847527,1077.28125
|
|
||||||
64,0.9963655471801758,0.2340720295906067,0.010616755113005638,1765.6953125
|
|
||||||
65,0.9939425587654114,0.27770084142684937,0.019288523122668266,3227.864501953125
|
|
||||||
66,0.9913464784622192,0.23268698155879974,0.030606787651777267,3323.407958984375
|
|
||||||
67,0.9929041266441345,0.23268698155879974,0.022766467183828354,3642.7861328125
|
|
||||||
68,0.9955001473426819,0.23268698155879974,0.019210971891880035,1868.906982421875
|
|
||||||
69,0.996192455291748,0.23268698155879974,0.011477937921881676,2611.31640625
|
|
||||||
70,0.9949809908866882,0.23268698155879974,0.018107961863279343,3013.491943359375
|
|
||||||
71,0.9951540231704712,0.23268698155879974,0.015328730456531048,3909.685302734375
|
|
||||||
72,0.9927310347557068,0.23337949812412262,0.023373156785964966,2733.7919921875
|
|
||||||
73,0.9956732392311096,0.23268698155879974,0.013462813571095467,2812.021728515625
|
|
||||||
74,0.9955001473426819,0.23268698155879974,0.01717122085392475,1892.8349609375
|
|
||||||
75,0.9975770115852356,0.23268698155879974,0.008702440187335014,4670.3193359375
|
|
||||||
76,0.9972308874130249,0.23268698155879974,0.007852787151932716,4487.03515625
|
|
||||||
77,0.9965385794639587,0.23268698155879974,0.009604893624782562,6443.4267578125
|
|
||||||
78,0.9920387864112854,0.23268698155879974,0.020789368078112602,6024.58740234375
|
|
||||||
79,0.9935963749885559,0.23268698155879974,0.018593357875943184,2821.33935546875
|
|
||||||
80,0.9956732392311096,0.23268698155879974,0.015648460015654564,2576.421142578125
|
|
||||||
81,0.9949809908866882,0.23268698155879974,0.017194343730807304,3764.894775390625
|
|
||||||
82,0.9929041266441345,0.27770084142684937,0.021781075745821,2807.73291015625
|
|
||||||
83,0.9968847632408142,0.23268698155879974,0.012610912322998047,4471.623046875
|
|
||||||
84,0.9967116713523865,0.23268698155879974,0.01276914868503809,6788.912109375
|
|
||||||
85,0.9958463311195374,0.23268698155879974,0.0117557467892766,7592.3515625
|
|
||||||
86,0.9956732392311096,0.23268698155879974,0.010352320037782192,6271.54931640625
|
|
||||||
87,0.996192455291748,0.23268698155879974,0.009747961536049843,3658.080322265625
|
|
||||||
88,0.9974039196968079,0.23268698155879974,0.007929908111691475,8354.2626953125
|
|
||||||
89,0.9944617748260498,0.23268698155879974,0.01685083471238613,5968.7373046875
|
|
||||||
90,0.9968847632408142,0.23268698155879974,0.011445037089288235,3262.92578125
|
|
||||||
91,0.9949809908866882,0.23268698155879974,0.016830891370773315,4658.84912109375
|
|
||||||
92,0.9987885355949402,0.23268698155879974,0.006762914825230837,8752.833984375
|
|
||||||
93,0.9970577955245972,0.23268698155879974,0.01124420017004013,7593.404296875
|
|
||||||
94,0.9979231357574463,0.23268698155879974,0.009695205837488174,1501.1002197265625
|
|
||||||
95,0.9970577955245972,0.23268698155879974,0.010869201272726059,4020.974609375
|
|
||||||
96,0.9942886829376221,0.23268698155879974,0.01823830045759678,2655.122802734375
|
|
||||||
97,0.9982693195343018,0.23545706272125244,0.006866878364235163,886.0972900390625
|
|
||||||
98,0.9852890372276306,0.2527700960636139,0.06450172513723373,2126.42578125
|
|
||||||
99,0.9955001473426819,0.42659279704093933,0.015554066747426987,1247.246826171875
|
|
||||||
100,0.9975770115852356,0.33240997791290283,0.008071373216807842,1635.5611572265625
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ def select_model():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# all_model_dir = input("Model dir : ")
|
# all_model_dir = input("Model dir : ")
|
||||||
all_model_dir = "/home/jhodi/bit/Python/Grapevine_Pathology_Detection/venv/models"
|
all_model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'models')
|
||||||
model_found = 0
|
model_found = 0
|
||||||
for foldername, subfolders, filenames in os.walk(all_model_dir):
|
for foldername, subfolders, filenames in os.walk(all_model_dir):
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
|
|
|
||||||