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

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
GITEA_URL=http://example.local:3000
GITEA_USER=username
GITEA_TOKEN=replace-me

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
.expo/
dist/
web-build/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.*
!.env.example

29
App.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { StatusBar } from 'expo-status-bar';
import { HomeScreen } from './src/screens/HomeScreen';
import { SettingsScreen } from './src/screens/SettingsScreen';
import { LevelScreen } from './src/screens/LevelScreen';
import { RootStackParamList } from './src/types/navigation';
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function App() {
return (
<NavigationContainer>
<StatusBar style="light" />
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: '#111827' },
headerTintColor: '#f8fafc',
headerTitleStyle: { fontWeight: '800' },
contentStyle: { backgroundColor: '#0f172a' },
}}
>
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'Handwerker Wasserwaage' }} />
<Stack.Screen name="Level" component={LevelScreen} options={{ title: 'Digitale Wasserwaage' }} />
<Stack.Screen name="Settings" component={SettingsScreen} options={{ title: 'Einstellungen' }} />
</Stack.Navigator>
</NavigationContainer>
);
}

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# Handwerker Wasserwaage
Mobile MVP-App für Handwerker als digitale Wasserwaage mit React Native, Expo und TypeScript.
Die App nutzt `expo-sensors` und den Accelerometer, um X/Y-Neigung, Gesamtwinkel und ein optisches Feedback für `gerade` oder `nicht gerade` anzuzeigen.
## Tech Stack
- React Native
- Expo
- TypeScript
- expo-sensors
- React Navigation
## MVP Features
- HomeScreen mit Einstieg in die Messung
- WasserwaageScreen mit Live-Sensordaten
- Anzeige von X/Y-Neigung
- Winkelanzeige in Grad
- Optisches Feedback für gerade / nicht gerade
- Kalibrierungs-Button
- Einfache Messwert-Speicherung als Mock im Speicher
- SettingsScreen mit aktueller Sensor- und Toleranzinfo
## Projektstruktur
```text
src/
components/ Wiederverwendbare UI-Komponenten
hooks/ Sensor-Hook useLevelSensor
screens/ App-Screens
services/ Sensor-Service und Mock-Speicher
types/ TypeScript-Typen
utils/ Mathematische Hilfsfunktionen
```
## Installation
```bash
npm install
npm run ios
```
Alternativ:
```bash
npm start
```
## Entwicklung
```bash
npm run typecheck
```
## Roadmap
- Gefälle-Messung
- Messprotokoll
- PDF-Export
- Premium-Version

17
app.json Normal file
View File

@@ -0,0 +1,17 @@
{
"expo": {
"name": "Handwerker Wasserwaage",
"slug": "handwerker-wasserwaage",
"version": "0.1.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "de.handwerker.wasserwaage"
},
"android": {
"package": "de.handwerker.wasserwaage"
}
}
}

7279
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "handwerker-wasserwaage",
"version": "0.1.0",
"private": true,
"main": "expo/AppEntry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@react-navigation/native": "latest",
"@react-navigation/native-stack": "latest",
"expo": "latest",
"expo-sensors": "latest",
"expo-status-bar": "^55.0.5",
"react": "latest",
"react-native": "latest",
"react-native-safe-area-context": "latest",
"react-native-screens": "latest"
},
"devDependencies": {
"@types/react": "latest",
"typescript": "latest"
}
}

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

8
tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noEmit": true
},
"include": ["App.tsx", "src/**/*.ts", "src/**/*.tsx"]
}