Initial Expo MVP
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
GITEA_URL=http://example.local:3000
|
||||||
|
GITEA_USER=username
|
||||||
|
GITEA_TOKEN=replace-me
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
29
App.tsx
Normal 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
62
README.md
Normal 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
17
app.json
Normal 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
7279
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["App.tsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user