Initial Expo MVP

This commit is contained in:
Ismail Ali
2026-04-28 21:37:11 +02:00
commit da31bd3c9a
20 changed files with 7946 additions and 0 deletions

View 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',
},
});

View 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 }],
},
});

View 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,
},
});

View 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,
};
}

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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;
},
};

View 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
View 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
View File

@@ -0,0 +1,5 @@
export type RootStackParamList = {
Home: undefined;
Level: undefined;
Settings: undefined;
};

33
src/utils/levelMath.ts Normal file
View 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,
};
}