Build RoomScan AI starter app

This commit is contained in:
Ismail Ali
2026-04-28 11:43:56 +02:00
parent 306c93fb47
commit bdc0c97431
16 changed files with 1071 additions and 18 deletions

View File

@@ -0,0 +1,54 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
type InstructionOverlayProps = {
title: string;
detail: string;
tone?: 'info' | 'warning' | 'danger';
};
export function InstructionOverlay({ title, detail, tone = 'info' }: InstructionOverlayProps) {
const toneColor = tone === 'warning' ? colors.warning : tone === 'danger' ? colors.danger : colors.primary;
return (
<View style={[styles.container, { borderColor: toneColor }]}>
<View style={[styles.indicator, { backgroundColor: toneColor }]} />
<View style={styles.textBlock}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.detail}>{detail}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
backgroundColor: 'rgba(8, 17, 31, 0.92)',
borderRadius: 20,
borderWidth: 1,
flexDirection: 'row',
gap: 14,
padding: 16,
},
indicator: {
borderRadius: 999,
height: 14,
width: 14,
},
textBlock: {
flex: 1,
},
title: {
color: colors.textPrimary,
fontSize: 16,
fontWeight: '800',
},
detail: {
color: colors.textSecondary,
fontSize: 13,
lineHeight: 19,
marginTop: 4,
},
});

View File

@@ -0,0 +1,51 @@
import { Pressable, StyleSheet, Text } from 'react-native';
import { colors } from '../theme/colors';
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,
variant === 'secondary' && styles.secondary,
pressed && styles.pressed,
]}
>
<Text style={[styles.label, variant === 'secondary' && styles.secondaryLabel]}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 18,
paddingHorizontal: 22,
paddingVertical: 16,
},
secondary: {
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderWidth: 1,
},
pressed: {
opacity: 0.82,
},
label: {
color: colors.background,
fontSize: 16,
fontWeight: '800',
},
secondaryLabel: {
color: colors.textPrimary,
},
});

View File

@@ -0,0 +1,62 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
type ScanProgressCardProps = {
progress: number;
};
export function ScanProgressCard({ progress }: ScanProgressCardProps) {
return (
<View style={styles.card}>
<View style={styles.row}>
<Text style={styles.label}>Scan-Fortschritt</Text>
<Text style={styles.value}>{progress}%</Text>
</View>
<View style={styles.track}>
<View style={[styles.fill, { width: `${progress}%` }]} />
</View>
<Text style={styles.hint}>Halte das iPhone ruhig und bewege dich entlang der Wandkontur.</Text>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 22,
borderWidth: 1,
padding: 18,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
},
label: {
color: colors.textSecondary,
fontSize: 14,
},
value: {
color: colors.textPrimary,
fontSize: 18,
fontWeight: '800',
},
track: {
backgroundColor: colors.surfaceRaised,
borderRadius: 999,
height: 10,
marginTop: 14,
overflow: 'hidden',
},
fill: {
backgroundColor: colors.accent,
height: '100%',
},
hint: {
color: colors.textSecondary,
fontSize: 13,
lineHeight: 19,
marginTop: 14,
},
});

View File

@@ -0,0 +1,29 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { HomeScreen } from '../screens/HomeScreen';
import { ResultScreen } from '../screens/ResultScreen';
import { ScanScreen } from '../screens/ScanScreen';
import { colors } from '../theme/colors';
import type { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function AppNavigator() {
return (
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.background },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontWeight: '700' },
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'RoomScan AI' }} />
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Raum scannen' }} />
<Stack.Screen name="Result" component={ResultScreen} options={{ title: '3D Ergebnis' }} />
</Stack.Navigator>
</NavigationContainer>
);
}

5
src/navigation/types.ts Normal file
View File

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

108
src/screens/HomeScreen.tsx Normal file
View File

@@ -0,0 +1,108 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { PrimaryButton } from '../components/PrimaryButton';
import type { RootStackParamList } from '../navigation/types';
import { colors } from '../theme/colors';
type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
const steps = ['Startpunkt wählen', 'Langsam entlang der Wand bewegen', 'Hinweise beachten', '3D Modell prüfen'];
export function HomeScreen({ navigation }: HomeScreenProps) {
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.hero}>
<Text style={styles.eyebrow}>ARKit + RoomPlan vorbereitet</Text>
<Text style={styles.title}>Geführte 3D Raumscans fuer iOS</Text>
<Text style={styles.subtitle}>
RoomScan AI fuehrt Nutzer Schritt fuer Schritt durch den Scan und bereitet die App auf eine
native RoomPlan-Integration per Expo Prebuild vor.
</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Scan Ablauf</Text>
{steps.map((step, index) => (
<View key={step} style={styles.stepRow}>
<Text style={styles.stepNumber}>{index + 1}</Text>
<Text style={styles.stepText}>{step}</Text>
</View>
))}
</View>
<PrimaryButton label="Mock Scan starten" onPress={() => navigation.navigate('Scan')} />
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
content: {
gap: 24,
padding: 24,
},
hero: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 28,
borderWidth: 1,
padding: 24,
},
eyebrow: {
color: colors.primary,
fontSize: 13,
fontWeight: '800',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
title: {
color: colors.textPrimary,
fontSize: 34,
fontWeight: '900',
lineHeight: 39,
marginTop: 12,
},
subtitle: {
color: colors.textSecondary,
fontSize: 16,
lineHeight: 24,
marginTop: 14,
},
card: {
backgroundColor: colors.surfaceRaised,
borderRadius: 24,
padding: 20,
},
cardTitle: {
color: colors.textPrimary,
fontSize: 18,
fontWeight: '800',
marginBottom: 14,
},
stepRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 12,
paddingVertical: 9,
},
stepNumber: {
backgroundColor: colors.primaryDark,
borderRadius: 999,
color: colors.textPrimary,
fontWeight: '800',
overflow: 'hidden',
paddingHorizontal: 10,
paddingVertical: 6,
},
stepText: {
color: colors.textSecondary,
flex: 1,
fontSize: 15,
},
});

View File

@@ -0,0 +1,130 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { PrimaryButton } from '../components/PrimaryButton';
import type { RootStackParamList } from '../navigation/types';
import { colors } from '../theme/colors';
type ResultScreenProps = NativeStackScreenProps<RootStackParamList, 'Result'>;
export function ResultScreen({ navigation }: ResultScreenProps) {
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
<View style={styles.container}>
<View style={styles.modelPreview}>
<View style={styles.floorPlan}>
<View style={styles.wallLong} />
<View style={styles.wallShort} />
<View style={styles.roomBlockLarge} />
<View style={styles.roomBlockSmall} />
</View>
<Text style={styles.previewLabel}>Mock 3D Modell</Text>
</View>
<View style={styles.summaryCard}>
<Text style={styles.title}>Scan bereit zur Auswertung</Text>
<Text style={styles.body}>
Diese Ansicht ist ein Platzhalter fuer das spaetere RoomPlan-Ergebnis. Nach der nativen iOS
Integration werden hier erkannte Waende, Tueren, Fenster und Moebel angezeigt.
</Text>
</View>
<View style={styles.actions}>
<PrimaryButton label="Neuen Scan starten" onPress={() => navigation.navigate('Scan')} />
<PrimaryButton label="Zur Startseite" variant="secondary" onPress={() => navigation.navigate('Home')} />
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
container: {
flex: 1,
gap: 20,
padding: 20,
},
modelPreview: {
alignItems: 'center',
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 30,
borderWidth: 1,
flex: 1,
justifyContent: 'center',
minHeight: 360,
padding: 24,
},
floorPlan: {
borderColor: colors.primary,
borderRadius: 24,
borderWidth: 3,
height: 220,
transform: [{ rotateX: '58deg' }, { rotateZ: '-18deg' }],
width: 220,
},
wallLong: {
backgroundColor: colors.primary,
borderRadius: 10,
height: 14,
left: 20,
position: 'absolute',
top: 72,
width: 150,
},
wallShort: {
backgroundColor: colors.accent,
borderRadius: 10,
height: 110,
position: 'absolute',
right: 48,
top: 54,
width: 14,
},
roomBlockLarge: {
backgroundColor: 'rgba(98, 214, 255, 0.24)',
borderRadius: 18,
bottom: 28,
height: 70,
left: 34,
position: 'absolute',
width: 88,
},
roomBlockSmall: {
backgroundColor: 'rgba(181, 240, 109, 0.28)',
borderRadius: 14,
height: 52,
position: 'absolute',
right: 32,
top: 24,
width: 58,
},
previewLabel: {
color: colors.textSecondary,
fontSize: 15,
marginTop: 32,
},
summaryCard: {
backgroundColor: colors.surfaceRaised,
borderRadius: 24,
padding: 20,
},
title: {
color: colors.textPrimary,
fontSize: 21,
fontWeight: '900',
},
body: {
color: colors.textSecondary,
fontSize: 15,
lineHeight: 23,
marginTop: 10,
},
actions: {
gap: 12,
},
});

135
src/screens/ScanScreen.tsx Normal file
View File

@@ -0,0 +1,135 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { InstructionOverlay } from '../components/InstructionOverlay';
import { PrimaryButton } from '../components/PrimaryButton';
import { ScanProgressCard } from '../components/ScanProgressCard';
import type { RootStackParamList } from '../navigation/types';
import { getGuidanceForProgress } from '../services/scanGuidance';
import { colors } from '../theme/colors';
type ScanScreenProps = NativeStackScreenProps<RootStackParamList, 'Scan'>;
export function ScanScreen({ navigation }: ScanScreenProps) {
const [progress, setProgress] = useState(10);
const guidance = getGuidanceForProgress(progress);
// Simulates RoomPlan scan updates until native iOS capture is connected.
useEffect(() => {
const intervalId = setInterval(() => {
setProgress((current) => Math.min(current + 5, 92));
}, 1200);
return () => clearInterval(intervalId);
}, []);
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']}>
<View style={styles.container}>
<View style={styles.cameraMock}>
<View style={styles.gridLineVertical} />
<View style={styles.gridLineHorizontal} />
<View style={styles.scanFrame}>
<Text style={styles.roomLabel}>Wohnzimmer</Text>
<View style={styles.cornerMarkerTop} />
<View style={styles.cornerMarkerBottom} />
</View>
<View style={styles.overlayPosition}>
<InstructionOverlay title={guidance.title} detail={guidance.detail} tone={guidance.tone} />
</View>
</View>
<ScanProgressCard progress={progress} />
<View style={styles.actions}>
<PrimaryButton label="Scan abschliessen" onPress={() => navigation.navigate('Result')} />
<PrimaryButton label="Neu starten" variant="secondary" onPress={() => setProgress(10)} />
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
container: {
flex: 1,
gap: 18,
padding: 20,
},
cameraMock: {
backgroundColor: '#0b1628',
borderColor: colors.border,
borderRadius: 30,
borderWidth: 1,
flex: 1,
minHeight: 380,
overflow: 'hidden',
},
gridLineVertical: {
backgroundColor: 'rgba(98, 214, 255, 0.16)',
height: '100%',
left: '50%',
position: 'absolute',
width: 1,
},
gridLineHorizontal: {
backgroundColor: 'rgba(98, 214, 255, 0.16)',
height: 1,
position: 'absolute',
top: '50%',
width: '100%',
},
scanFrame: {
borderColor: colors.primary,
borderRadius: 24,
borderWidth: 2,
bottom: 70,
left: 34,
position: 'absolute',
right: 34,
top: 70,
},
roomLabel: {
alignSelf: 'center',
backgroundColor: colors.primary,
borderRadius: 999,
color: colors.background,
fontWeight: '900',
marginTop: -16,
overflow: 'hidden',
paddingHorizontal: 16,
paddingVertical: 8,
},
cornerMarkerTop: {
backgroundColor: colors.accent,
borderRadius: 8,
height: 16,
position: 'absolute',
right: 20,
top: 42,
width: 16,
},
cornerMarkerBottom: {
backgroundColor: colors.warning,
borderRadius: 8,
bottom: 34,
height: 16,
left: 24,
position: 'absolute',
width: 16,
},
overlayPosition: {
bottom: 18,
left: 18,
position: 'absolute',
right: 18,
},
actions: {
gap: 12,
},
});

View File

@@ -0,0 +1,33 @@
export type ScanGuidance = {
title: string;
detail: string;
tone: 'info' | 'warning' | 'danger';
};
const guidanceSteps: ScanGuidance[] = [
{
title: 'Startpunkt setzen',
detail: 'Richte die Kamera auf eine freie Ecke und beginne dort mit dem Raumscan.',
tone: 'info',
},
{
title: 'Langsamer bewegen',
detail: 'Die Kamera verliert Details. Reduziere die Geschwindigkeit fuer stabilere Messpunkte.',
tone: 'warning',
},
{
title: 'Richtung ändern',
detail: 'Schwenke leicht nach rechts, damit die Wandkante vollstaendig erkannt wird.',
tone: 'info',
},
{
title: 'Bereich erneut erfassen',
detail: 'Ein Objekt wurde unklar erkannt. Gehe einen Schritt zurueck und scanne die Zone erneut.',
tone: 'danger',
},
];
export function getGuidanceForProgress(progress: number) {
const index = Math.min(Math.floor(progress / 25), guidanceSteps.length - 1);
return guidanceSteps[index];
}

13
src/theme/colors.ts Normal file
View File

@@ -0,0 +1,13 @@
export const colors = {
background: '#08111f',
surface: '#101d31',
surfaceRaised: '#17263d',
primary: '#62d6ff',
primaryDark: '#0f8fbf',
accent: '#b5f06d',
warning: '#ffcf5a',
danger: '#ff7a7a',
textPrimary: '#f4f8ff',
textSecondary: '#a7b5c9',
border: '#263854',
};