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