feat: implement plant health check functionality with AI analysis, credit management, and UI integration
This commit is contained in:
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "GreenLens",
|
"name": "GreenLens",
|
||||||
"slug": "greenlens",
|
"slug": "greenlens",
|
||||||
"version": "2.1.6",
|
"version": "2.2.1",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -68,4 +68,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import * as ImageManipulator from 'expo-image-manipulator';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { usePostHog } from 'posthog-react-native';
|
import { usePostHog } from 'posthog-react-native';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
@@ -175,6 +176,20 @@ export default function ScannerScreen() {
|
|||||||
};
|
};
|
||||||
}, [isAnalyzing, scanLineProgress, scanPulse]);
|
}, [isAnalyzing, scanLineProgress, scanPulse]);
|
||||||
|
|
||||||
|
const resizeForAnalysis = async (uri: string): Promise<string> => {
|
||||||
|
if (uri.startsWith('data:')) return uri;
|
||||||
|
try {
|
||||||
|
const result = await ImageManipulator.manipulateAsync(
|
||||||
|
uri,
|
||||||
|
[{ resize: { width: 1024 } }],
|
||||||
|
{ compress: 0.6, format: ImageManipulator.SaveFormat.JPEG, base64: true },
|
||||||
|
);
|
||||||
|
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
|
||||||
|
} catch {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
|
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
|
||||||
if (isAnalyzing) return;
|
if (isAnalyzing) return;
|
||||||
|
|
||||||
@@ -342,7 +357,7 @@ export default function ScannerScreen() {
|
|||||||
const takePicture = async () => {
|
const takePicture = async () => {
|
||||||
if (!cameraRef.current || isAnalyzing) return;
|
if (!cameraRef.current || isAnalyzing) return;
|
||||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.7 });
|
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.5 });
|
||||||
if (photo) {
|
if (photo) {
|
||||||
const analysisUri = photo.base64
|
const analysisUri = photo.base64
|
||||||
? `data:image/jpeg;base64,${photo.base64}`
|
? `data:image/jpeg;base64,${photo.base64}`
|
||||||
@@ -358,17 +373,14 @@ export default function ScannerScreen() {
|
|||||||
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
mediaTypes: ['images'],
|
mediaTypes: ['images'],
|
||||||
quality: 0.7,
|
quality: 1,
|
||||||
base64: true,
|
base64: false,
|
||||||
});
|
});
|
||||||
if (!result.canceled && result.assets[0]) {
|
if (!result.canceled && result.assets[0]) {
|
||||||
const asset = result.assets[0];
|
const asset = result.assets[0];
|
||||||
const uri = asset.base64
|
const analysisUri = await resizeForAnalysis(asset.uri);
|
||||||
? `data:image/jpeg;base64,${asset.base64}`
|
setSelectedImage(asset.uri);
|
||||||
: asset.uri;
|
analyzeImage(analysisUri, asset.uri);
|
||||||
|
|
||||||
setSelectedImage(uri);
|
|
||||||
analyzeImage(uri, asset.uri || uri);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ export const ResultCard: React.FC<ResultCardProps> = ({
|
|||||||
|
|
||||||
<View style={styles.careGrid}>
|
<View style={styles.careGrid}>
|
||||||
{[
|
{[
|
||||||
{ icon: 'water' as const, label: t.water, value: result.careInfo.waterIntervalDays <= 7 ? t.waterModerate : t.waterLittle, color: colors.info, bg: colors.infoSoft },
|
{ icon: 'water' as const, label: t.water, value: t.waterEveryXDays.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.info, bg: colors.infoSoft },
|
||||||
{ icon: 'sunny' as const, label: t.light, value: result.careInfo.light, color: colors.warning, bg: colors.warningSoft },
|
{ icon: 'sunny' as const, label: t.light, value: result.careInfo.light || t.unknown, color: colors.warning, bg: colors.warningSoft },
|
||||||
{ icon: 'thermometer' as const, label: t.temp, value: result.careInfo.temp, color: colors.danger, bg: colors.dangerSoft },
|
{ icon: 'thermometer' as const, label: t.temp, value: result.careInfo.temp || t.unknown, color: colors.danger, bg: colors.dangerSoft },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<View key={item.label} style={[styles.careCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
<View key={item.label} style={[styles.careCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
<View style={[styles.careIcon, { backgroundColor: item.bg }]}>
|
<View style={[styles.careIcon, { backgroundColor: item.bg }]}>
|
||||||
@@ -118,8 +118,8 @@ export const ResultCard: React.FC<ResultCardProps> = ({
|
|||||||
<Text style={[styles.detailsTitle, { color: colors.textSecondary }]}>{t.detailedCare}</Text>
|
<Text style={[styles.detailsTitle, { color: colors.textSecondary }]}>{t.detailedCare}</Text>
|
||||||
{[
|
{[
|
||||||
{ text: t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.success },
|
{ text: t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString()), color: colors.success },
|
||||||
{ text: t.careTextLight.replace('{0}', result.careInfo.light), color: colors.warning },
|
{ text: t.careTextLight.replace('{0}', result.careInfo.light || t.unknown), color: colors.warning },
|
||||||
{ text: t.careTextTemp.replace('{0}', result.careInfo.temp), color: colors.danger },
|
{ text: t.careTextTemp.replace('{0}', result.careInfo.temp || t.unknown), color: colors.danger },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<View key={i} style={styles.detailRow}>
|
<View key={i} style={styles.detailRow}>
|
||||||
<View style={[styles.detailDot, { backgroundColor: item.color }]} />
|
<View style={[styles.detailDot, { backgroundColor: item.color }]} />
|
||||||
|
|||||||
@@ -107,10 +107,16 @@ const normalizeIdentifyResult = (raw, language) => {
|
|||||||
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
|
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
|
||||||
const light = getString(careInfoRaw.light);
|
const light = getString(careInfoRaw.light);
|
||||||
const temp = getString(careInfoRaw.temp);
|
const temp = getString(careInfoRaw.temp);
|
||||||
if (waterIntervalRaw == null || !light || !temp) {
|
if (waterIntervalRaw == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LIGHT_DEFAULTS = { de: 'Helles indirektes Licht', es: 'Luz indirecta brillante', en: 'Bright indirect light' };
|
||||||
|
const TEMP_DEFAULTS = { de: '15–25 °C', es: '15–25 °C', en: '59–77 °F (15–25 °C)' };
|
||||||
|
const lang = language || 'en';
|
||||||
|
const resolvedLight = (light && light !== 'Unknown') ? light : (LIGHT_DEFAULTS[lang] || LIGHT_DEFAULTS.en);
|
||||||
|
const resolvedTemp = (temp && temp !== 'Unknown') ? temp : (TEMP_DEFAULTS[lang] || TEMP_DEFAULTS.en);
|
||||||
|
|
||||||
const fallbackDescription = language === 'de'
|
const fallbackDescription = language === 'de'
|
||||||
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
||||||
: language === 'es'
|
: language === 'es'
|
||||||
@@ -124,8 +130,8 @@ const normalizeIdentifyResult = (raw, language) => {
|
|||||||
description: description || fallbackDescription,
|
description: description || fallbackDescription,
|
||||||
careInfo: {
|
careInfo: {
|
||||||
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
||||||
light,
|
light: resolvedLight,
|
||||||
temp,
|
temp: resolvedTemp,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -216,6 +222,10 @@ const buildIdentifyPrompt = (language, mode) => {
|
|||||||
'Rules:',
|
'Rules:',
|
||||||
nameLanguageInstruction,
|
nameLanguageInstruction,
|
||||||
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
||||||
|
`- "careInfo.light": short light requirement in ${getLanguageLabel(language)} (e.g. "bright indirect light", "full sun", "partial shade"). Must always be a real value, never "Unknown".`,
|
||||||
|
language === 'en'
|
||||||
|
? '- "careInfo.temp": temperature range in both Celsius and Fahrenheit (e.g. "18–24 °C (64–75 °F)"). Must always be a real plant-specific value, never "Unknown".'
|
||||||
|
: '- "careInfo.temp": temperature range in Celsius (e.g. "18–24 °C"). Must always be a real plant-specific value, never "Unknown".',
|
||||||
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
|
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
|
||||||
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
|
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
|
||||||
'- "confidence" must be between 0 and 1.',
|
'- "confidence" must be between 0 and 1.',
|
||||||
@@ -356,7 +366,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'fre
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
|
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
|
||||||
{ type: 'image_url', image_url: { url: imageUri } },
|
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -416,7 +426,7 @@ const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
||||||
{ type: 'image_url', image_url: { url: imageUri } },
|
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export const translations = {
|
|||||||
temp: "Temperatur",
|
temp: "Temperatur",
|
||||||
|
|
||||||
// Care Values (UI Helper)
|
// Care Values (UI Helper)
|
||||||
|
unknown: "Unbekannt",
|
||||||
waterModerate: "Mäßig",
|
waterModerate: "Mäßig",
|
||||||
waterLittle: "Wenig",
|
waterLittle: "Wenig",
|
||||||
waterEveryXDays: "Alle {0} Tage",
|
waterEveryXDays: "Alle {0} Tage",
|
||||||
@@ -367,6 +368,7 @@ registerToSave: "Sign up to save",
|
|||||||
light: "Light",
|
light: "Light",
|
||||||
temp: "Temperature",
|
temp: "Temperature",
|
||||||
|
|
||||||
|
unknown: "Unknown",
|
||||||
waterModerate: "Moderate",
|
waterModerate: "Moderate",
|
||||||
waterLittle: "Little",
|
waterLittle: "Little",
|
||||||
waterEveryXDays: "Every {0} days",
|
waterEveryXDays: "Every {0} days",
|
||||||
@@ -588,6 +590,7 @@ registerToSave: "Regístrate para guardar",
|
|||||||
light: "Luz",
|
light: "Luz",
|
||||||
temp: "Temperatura",
|
temp: "Temperatura",
|
||||||
|
|
||||||
|
unknown: "Desconocido",
|
||||||
waterModerate: "Moderado",
|
waterModerate: "Moderado",
|
||||||
waterLittle: "Poco",
|
waterLittle: "Poco",
|
||||||
waterEveryXDays: "Cada {0} días",
|
waterEveryXDays: "Cada {0} días",
|
||||||
|
|||||||
Reference in New Issue
Block a user