Initial Expo MVP
This commit is contained in:
71
src/components/LevelIndicator.tsx
Normal file
71
src/components/LevelIndicator.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
type LevelIndicatorProps = {
|
||||
isLevel: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export function LevelIndicator({ isLevel, x, y }: LevelIndicatorProps) {
|
||||
const bubbleOffsetX = Math.max(-44, Math.min(44, x * 4));
|
||||
const bubbleOffsetY = Math.max(-44, Math.min(44, y * 4));
|
||||
|
||||
return (
|
||||
<View style={[styles.wrapper, isLevel ? styles.level : styles.notLevel]}>
|
||||
<View style={styles.crosshairHorizontal} />
|
||||
<View style={styles.crosshairVertical} />
|
||||
<View style={[styles.bubble, { transform: [{ translateX: bubbleOffsetX }, { translateY: bubbleOffsetY }] }]} />
|
||||
<Text style={styles.status}>{isLevel ? 'Gerade' : 'Nicht gerade'}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
alignItems: 'center',
|
||||
alignSelf: 'center',
|
||||
borderRadius: 999,
|
||||
borderWidth: 10,
|
||||
height: 230,
|
||||
justifyContent: 'center',
|
||||
marginVertical: 26,
|
||||
overflow: 'hidden',
|
||||
width: 230,
|
||||
},
|
||||
level: {
|
||||
backgroundColor: '#052e16',
|
||||
borderColor: '#22c55e',
|
||||
},
|
||||
notLevel: {
|
||||
backgroundColor: '#431407',
|
||||
borderColor: '#f97316',
|
||||
},
|
||||
crosshairHorizontal: {
|
||||
backgroundColor: 'rgba(248, 250, 252, 0.28)',
|
||||
height: 2,
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
},
|
||||
crosshairVertical: {
|
||||
backgroundColor: 'rgba(248, 250, 252, 0.28)',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
width: 2,
|
||||
},
|
||||
bubble: {
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: 999,
|
||||
height: 54,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
width: 54,
|
||||
},
|
||||
status: {
|
||||
bottom: 28,
|
||||
color: '#f8fafc',
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
48
src/components/PrimaryButton.tsx
Normal file
48
src/components/PrimaryButton.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Pressable, StyleSheet, Text } from 'react-native';
|
||||
|
||||
type PrimaryButtonProps = {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary';
|
||||
};
|
||||
|
||||
export function PrimaryButton({ label, onPress, variant = 'primary' }: PrimaryButtonProps) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.button, styles[variant], pressed && styles.pressed]}
|
||||
>
|
||||
<Text style={[styles.label, variant === 'secondary' && styles.secondaryLabel]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: '#f97316',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#1f2937',
|
||||
borderColor: '#334155',
|
||||
borderWidth: 1,
|
||||
},
|
||||
label: {
|
||||
color: '#111827',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
secondaryLabel: {
|
||||
color: '#f8fafc',
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.78,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
});
|
||||
47
src/components/ReadingCard.tsx
Normal file
47
src/components/ReadingCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
type ReadingCardProps = {
|
||||
label: string;
|
||||
value: number | string;
|
||||
suffix?: string;
|
||||
};
|
||||
|
||||
export function ReadingCard({ label, value, suffix }: ReadingCardProps) {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.value}>
|
||||
{value}
|
||||
{suffix ? <Text style={styles.suffix}>{suffix}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#111827',
|
||||
borderColor: '#243244',
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
padding: 18,
|
||||
},
|
||||
label: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
value: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 30,
|
||||
fontWeight: '900',
|
||||
},
|
||||
suffix: {
|
||||
color: '#cbd5e1',
|
||||
fontSize: 18,
|
||||
},
|
||||
});
|
||||
44
src/hooks/useLevelSensor.ts
Normal file
44
src/hooks/useLevelSensor.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { sensorService } from '../services/sensorService';
|
||||
import { CalibrationOffset, LevelReading } from '../types/level';
|
||||
import { calculateLevelReading, createCalibrationOffset } from '../utils/levelMath';
|
||||
|
||||
const INITIAL_OFFSET: CalibrationOffset = { x: 0, y: 0 };
|
||||
const INITIAL_READING: LevelReading = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
angle: 0,
|
||||
isLevel: true,
|
||||
recordedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export function useLevelSensor() {
|
||||
const [reading, setReading] = useState<LevelReading>(INITIAL_READING);
|
||||
const [offset, setOffset] = useState<CalibrationOffset>(INITIAL_OFFSET);
|
||||
const [isCalibrated, setIsCalibrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = sensorService.subscribe(({ x, y, z }) => {
|
||||
setReading(calculateLevelReading(x, y, z, offset));
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [offset]);
|
||||
|
||||
function calibrate() {
|
||||
setOffset(createCalibrationOffset(reading));
|
||||
setIsCalibrated(true);
|
||||
}
|
||||
|
||||
function resetCalibration() {
|
||||
setOffset(INITIAL_OFFSET);
|
||||
setIsCalibrated(false);
|
||||
}
|
||||
|
||||
return {
|
||||
reading,
|
||||
isCalibrated,
|
||||
calibrate,
|
||||
resetCalibration,
|
||||
};
|
||||
}
|
||||
65
src/screens/HomeScreen.tsx
Normal file
65
src/screens/HomeScreen.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { PrimaryButton } from '../components/PrimaryButton';
|
||||
import { RootStackParamList } from '../types/navigation';
|
||||
|
||||
type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
|
||||
|
||||
export function HomeScreen({ navigation }: HomeScreenProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.kicker}>Baustellenwerkzeug</Text>
|
||||
<Text style={styles.title}>Digitale Wasserwaage für schnelle Kontrollen.</Text>
|
||||
<Text style={styles.text}>
|
||||
Nutzt die iPhone-Sensoren, zeigt X/Y-Neigung und Winkel in Grad und speichert Messwerte als MVP-Mock.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<PrimaryButton label="Messung starten" onPress={() => navigation.navigate('Level')} />
|
||||
<PrimaryButton label="Einstellungen" variant="secondary" onPress={() => navigation.navigate('Settings')} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#0f172a',
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
padding: 24,
|
||||
},
|
||||
hero: {
|
||||
backgroundColor: '#111827',
|
||||
borderColor: '#243244',
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
marginTop: 32,
|
||||
padding: 24,
|
||||
},
|
||||
kicker: {
|
||||
color: '#f97316',
|
||||
fontSize: 13,
|
||||
fontWeight: '900',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 16,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
title: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 34,
|
||||
fontWeight: '900',
|
||||
lineHeight: 40,
|
||||
marginBottom: 16,
|
||||
},
|
||||
text: {
|
||||
color: '#cbd5e1',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
actions: {
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
98
src/screens/LevelScreen.tsx
Normal file
98
src/screens/LevelScreen.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { LevelIndicator } from '../components/LevelIndicator';
|
||||
import { PrimaryButton } from '../components/PrimaryButton';
|
||||
import { ReadingCard } from '../components/ReadingCard';
|
||||
import { useLevelSensor } from '../hooks/useLevelSensor';
|
||||
import { measurementStore } from '../services/measurementStore';
|
||||
import { LevelReading } from '../types/level';
|
||||
|
||||
export function LevelScreen() {
|
||||
const { reading, isCalibrated, calibrate, resetCalibration } = useLevelSensor();
|
||||
const [savedMeasurements, setSavedMeasurements] = useState<LevelReading[]>(measurementStore.list());
|
||||
|
||||
function saveMeasurement() {
|
||||
measurementStore.save(reading);
|
||||
setSavedMeasurements(measurementStore.list());
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<LevelIndicator isLevel={reading.isLevel} x={reading.x} y={reading.y} />
|
||||
|
||||
<View style={styles.grid}>
|
||||
<ReadingCard label="X-Neigung" value={reading.x} suffix="°" />
|
||||
<ReadingCard label="Y-Neigung" value={reading.y} suffix="°" />
|
||||
</View>
|
||||
<ReadingCard label="Gesamtwinkel" value={reading.angle} suffix="°" />
|
||||
|
||||
<View style={styles.actions}>
|
||||
<PrimaryButton label="Kalibrieren" onPress={calibrate} />
|
||||
<PrimaryButton label="Messwert speichern" variant="secondary" onPress={saveMeasurement} />
|
||||
{isCalibrated ? <PrimaryButton label="Kalibrierung zurücksetzen" variant="secondary" onPress={resetCalibration} /> : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelTitle}>Gespeicherte Messwerte</Text>
|
||||
{savedMeasurements.length === 0 ? (
|
||||
<Text style={styles.empty}>Noch keine Messwerte gespeichert.</Text>
|
||||
) : (
|
||||
savedMeasurements.map((item) => (
|
||||
<View key={item.recordedAt} style={styles.measurementRow}>
|
||||
<Text style={styles.measurementValue}>X {item.x}° / Y {item.y}°</Text>
|
||||
<Text style={styles.measurementStatus}>{item.isLevel ? 'Gerade' : 'Nicht gerade'}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#0f172a',
|
||||
gap: 14,
|
||||
padding: 20,
|
||||
paddingBottom: 36,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
gap: 14,
|
||||
},
|
||||
actions: {
|
||||
gap: 10,
|
||||
marginTop: 10,
|
||||
},
|
||||
panel: {
|
||||
backgroundColor: '#111827',
|
||||
borderColor: '#243244',
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
marginTop: 10,
|
||||
padding: 18,
|
||||
},
|
||||
panelTitle: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
marginBottom: 12,
|
||||
},
|
||||
empty: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
measurementRow: {
|
||||
borderTopColor: '#243244',
|
||||
borderTopWidth: 1,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
measurementValue: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
measurementStatus: {
|
||||
color: '#f97316',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
57
src/screens/SettingsScreen.tsx
Normal file
57
src/screens/SettingsScreen.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export function SettingsScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Einstellungen</Text>
|
||||
<Text style={styles.label}>Toleranz</Text>
|
||||
<Text style={styles.value}>± 1,5° für Gerade/Nicht gerade</Text>
|
||||
<Text style={styles.label}>Sensor</Text>
|
||||
<Text style={styles.value}>Accelerometer über expo-sensors</Text>
|
||||
<Text style={styles.note}>
|
||||
Spätere Erweiterungen: Gefälle-Messung, Protokoll, PDF-Export und Premium-Funktionen.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#0f172a',
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#111827',
|
||||
borderColor: '#243244',
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
padding: 22,
|
||||
},
|
||||
title: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
marginBottom: 18,
|
||||
},
|
||||
label: {
|
||||
color: '#f97316',
|
||||
fontSize: 13,
|
||||
fontWeight: '900',
|
||||
marginTop: 16,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
value: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 17,
|
||||
marginTop: 6,
|
||||
},
|
||||
note: {
|
||||
color: '#cbd5e1',
|
||||
fontSize: 15,
|
||||
lineHeight: 23,
|
||||
marginTop: 24,
|
||||
},
|
||||
});
|
||||
15
src/services/measurementStore.ts
Normal file
15
src/services/measurementStore.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { LevelReading } from '../types/level';
|
||||
|
||||
let measurements: LevelReading[] = [];
|
||||
|
||||
export const measurementStore = {
|
||||
save(reading: LevelReading) {
|
||||
const savedReading = { ...reading, recordedAt: new Date().toISOString() };
|
||||
measurements = [savedReading, ...measurements].slice(0, 10);
|
||||
return savedReading;
|
||||
},
|
||||
|
||||
list() {
|
||||
return measurements;
|
||||
},
|
||||
};
|
||||
15
src/services/sensorService.ts
Normal file
15
src/services/sensorService.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Accelerometer, AccelerometerMeasurement } from 'expo-sensors';
|
||||
|
||||
const SENSOR_INTERVAL_MS = 120;
|
||||
|
||||
export type SensorSubscription = {
|
||||
remove: () => void;
|
||||
};
|
||||
|
||||
export const sensorService = {
|
||||
subscribe(onMeasurement: (measurement: AccelerometerMeasurement) => void): SensorSubscription {
|
||||
// A short interval keeps the UI responsive without draining the battery too aggressively.
|
||||
Accelerometer.setUpdateInterval(SENSOR_INTERVAL_MS);
|
||||
return Accelerometer.addListener(onMeasurement);
|
||||
},
|
||||
};
|
||||
12
src/types/level.ts
Normal file
12
src/types/level.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type LevelReading = {
|
||||
x: number;
|
||||
y: number;
|
||||
angle: number;
|
||||
isLevel: boolean;
|
||||
recordedAt: string;
|
||||
};
|
||||
|
||||
export type CalibrationOffset = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
5
src/types/navigation.ts
Normal file
5
src/types/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type RootStackParamList = {
|
||||
Home: undefined;
|
||||
Level: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
33
src/utils/levelMath.ts
Normal file
33
src/utils/levelMath.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CalibrationOffset, LevelReading } from '../types/level';
|
||||
|
||||
const LEVEL_TOLERANCE_DEGREES = 1.5;
|
||||
|
||||
export function radiansToDegrees(value: number) {
|
||||
return value * (180 / Math.PI);
|
||||
}
|
||||
|
||||
export function roundDegree(value: number) {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function calculateLevelReading(x: number, y: number, z: number, offset: CalibrationOffset): LevelReading {
|
||||
// Roll and pitch are derived from all axes to reduce jumps while the phone is tilted.
|
||||
const roll = radiansToDegrees(Math.atan2(y, z)) - offset.y;
|
||||
const pitch = radiansToDegrees(Math.atan2(-x, Math.sqrt(y * y + z * z))) - offset.x;
|
||||
const absoluteAngle = Math.sqrt(pitch * pitch + roll * roll);
|
||||
|
||||
return {
|
||||
x: roundDegree(pitch),
|
||||
y: roundDegree(roll),
|
||||
angle: roundDegree(absoluteAngle),
|
||||
isLevel: Math.abs(pitch) <= LEVEL_TOLERANCE_DEGREES && Math.abs(roll) <= LEVEL_TOLERANCE_DEGREES,
|
||||
recordedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCalibrationOffset(reading: LevelReading): CalibrationOffset {
|
||||
return {
|
||||
x: reading.x,
|
||||
y: reading.y,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user