Building WellOS for oil field workers taught me that mobile-first isn't just about responsive design—it's about designing for hands wearing gloves, screens in direct sunlight, limited connectivity, and workers who don't have time for complexity.
Here's everything I learned about designing mobile apps for real-world field operations.
Understanding Field Workers' Constraints
Field workers face unique challenges:
- Environment: Bright sunlight, rain, dust, extreme temperatures
- Connectivity: Spotty or non-existent cellular coverage
- Time pressure: Quick data entry between tasks
- Safety gear: Gloves, hard hats limiting dexterity
- Distraction: Noisy, dangerous environments requiring focus
- Devices: Older phones, cracked screens, low battery
Design must accommodate these constraints.
Large Touch Targets
Minimum 44x44 points (Apple) or 48x48dp (Android). For field workers with gloves: 56x56 minimum.
// styles/touchable.ts
export const touchableStyles = StyleSheet.create({
// Standard
standard: {
minWidth: 44,
minHeight: 44,
padding: 12,
},
// Field-optimized (larger)
fieldOptimized: {
minWidth: 56,
minHeight: 56,
padding: 16,
},
// Critical actions (even larger)
critical: {
minWidth: 64,
minHeight: 64,
padding: 20,
},
});
// Component
const InspectionButton = ({ onPress }) => {
return (
<TouchableOpacity
style={[styles.button, touchableStyles.fieldOptimized]}
onPress={onPress}
activeOpacity={0.7}
>
<Text style={styles.buttonText}>Start Inspection</Text>
</TouchableOpacity>
);
};Spacing between targets: minimum 8px, preferably 16px.
High Contrast for Sunlight Readability
Screens are hard to read in direct sunlight. Use high contrast:
// theme/colors.ts
export const fieldTheme = {
// High contrast
text: '#000000',
background: '#FFFFFF',
// Status colors (bold, distinct)
success: '#047857', // Dark green
warning: '#DC2626', // Dark red
info: '#1E40AF', // Dark blue
// Touch states (clear feedback)
pressed: '#E5E7EB',
disabled: '#D1D5DB',
// Avoid light colors
// ❌ '#F3F4F6' - too light
// ❌ '#FEF3C7' - too light
};
// Component
const InspectionCard = ({ inspection }) => {
const getStatusColor = (status: InspectionStatus) => {
switch (status) {
case 'completed': return fieldTheme.success;
case 'pending': return fieldTheme.warning;
case 'in-progress': return fieldTheme.info;
}
};
return (
<View style={styles.card}>
<View
style={[
styles.statusBadge,
{ backgroundColor: getStatusColor(inspection.status) }
]}
>
<Text style={styles.statusText}>{inspection.status}</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: fieldTheme.background,
borderWidth: 2, // Bold border for visibility
borderColor: '#000',
},
statusBadge: {
padding: 8,
borderRadius: 4,
},
statusText: {
color: '#FFF',
fontWeight: 'bold',
fontSize: 16, // Large text
},
});Large, Bold Typography
Small text is unreadable in field conditions:
// theme/typography.ts
export const fieldTypography = {
// Minimum sizes for field use
heading1: {
fontSize: 32,
fontWeight: '700',
lineHeight: 40,
},
heading2: {
fontSize: 24,
fontWeight: '700',
lineHeight: 32,
},
body: {
fontSize: 18, // Larger than typical 16px
fontWeight: '400',
lineHeight: 26,
},
label: {
fontSize: 16,
fontWeight: '600',
lineHeight: 24,
},
button: {
fontSize: 18,
fontWeight: '700',
letterSpacing: 0.5,
},
};
// Component
const InspectionDetail = ({ inspection }) => {
return (
<ScrollView>
<Text style={fieldTypography.heading1}>
Well {inspection.wellId}
</Text>
<Text style={fieldTypography.body}>
{inspection.description}
</Text>
</ScrollView>
);
};Minimize Text Input
Text input is slow with gloves. Use selection, not typing:
// ❌ Bad: Requires typing
const InspectionForm = () => {
return (
<View>
<TextInput
placeholder="Enter pressure reading"
keyboardType="numeric"
/>
<TextInput
placeholder="Enter temperature"
keyboardType="numeric"
/>
<TextInput
placeholder="Enter notes"
multiline
/>
</View>
);
};
// ✅ Good: Buttons and selections
const InspectionForm = () => {
const [pressure, setPressure] = useState<number | null>(null);
const [temperature, setTemperature] = useState<string>('normal');
return (
<View>
<Text style={fieldTypography.label}>Pressure (PSI)</Text>
<View style={styles.buttonGrid}>
{[50, 100, 150, 200, 250].map(value => (
<TouchableOpacity
key={value}
style={[
styles.valueButton,
pressure === value && styles.valueButtonSelected
]}
onPress={() => setPressure(value)}
>
<Text style={styles.valueButtonText}>{value}</Text>
</TouchableOpacity>
))}
</View>
<Text style={fieldTypography.label}>Temperature</Text>
<View style={styles.buttonRow}>
{['cold', 'normal', 'hot'].map(value => (
<TouchableOpacity
key={value}
style={[
styles.optionButton,
temperature === value && styles.optionButtonSelected
]}
onPress={() => setTemperature(value)}
>
<Text style={styles.optionButtonText}>
{value.toUpperCase()}
</Text>
</TouchableOpacity>
))}
</View>
{/* Voice input for notes */}
<VoiceNoteRecorder />
</View>
);
};Voice Input for Notes
Voice is faster than typing:
import Voice from '@react-native-voice/voice';
export const VoiceNoteRecorder = ({ onRecordingComplete }) => {
const [isRecording, setIsRecording] = useState(false);
const [transcript, setTranscript] = useState('');
useEffect(() => {
Voice.onSpeechResults = (e) => {
setTranscript(e.value[0]);
};
return () => {
Voice.destroy().then(Voice.removeAllListeners);
};
}, []);
const startRecording = async () => {
try {
await Voice.start('en-US');
setIsRecording(true);
} catch (error) {
console.error(error);
}
};
const stopRecording = async () => {
try {
await Voice.stop();
setIsRecording(false);
onRecordingComplete(transcript);
} catch (error) {
console.error(error);
}
};
return (
<View>
<TouchableOpacity
style={[
styles.voiceButton,
isRecording && styles.voiceButtonRecording
]}
onPress={isRecording ? stopRecording : startRecording}
>
<MicrophoneIcon size={32} color={isRecording ? '#DC2626' : '#000'} />
<Text style={styles.voiceButtonText}>
{isRecording ? 'Stop Recording' : 'Record Note'}
</Text>
</TouchableOpacity>
{transcript && (
<Text style={styles.transcript}>{transcript}</Text>
)}
</View>
);
};Offline-First Architecture
Field workers often have no connectivity. App must work offline:
export const InspectionList = () => {
const [inspections, setInspections] = useState<Inspection[]>([]);
const [syncStatus, setSyncStatus] = useState<'synced' | 'pending' | 'error'>('synced');
useEffect(() => {
// Load from local SQLite
const loadInspections = async () => {
const local = await db.getAll('SELECT * FROM inspections');
setInspections(local);
};
loadInspections();
}, []);
useEffect(() => {
// Sync when online
const syncInterval = setInterval(async () => {
if (await NetInfo.fetch().then(state => state.isConnected)) {
try {
await syncInspections();
setSyncStatus('synced');
} catch (error) {
setSyncStatus('error');
}
}
}, 30000); // Every 30 seconds
return () => clearInterval(syncInterval);
}, []);
return (
<View>
<SyncStatusBanner status={syncStatus} />
<FlatList
data={inspections}
renderItem={({ item }) => <InspectionCard inspection={item} />}
/>
</View>
);
};Clear Sync Status
Users need to know what's synced:
const SyncStatusBanner = ({ status }) => {
return (
<View style={[styles.banner, styles[`banner_${status}`]]}>
{status === 'synced' && (
<>
<CheckIcon size={20} color="#047857" />
<Text style={styles.bannerText}>All data synced</Text>
</>
)}
{status === 'pending' && (
<>
<ClockIcon size={20} color="#DC6B19" />
<Text style={styles.bannerText}>Syncing when online...</Text>
</>
)}
{status === 'error' && (
<>
<AlertIcon size={20} color="#DC2626" />
<Text style={styles.bannerText}>Sync failed. Will retry.</Text>
</>
)}
</View>
);
};Photo Capture Optimized
Camera is essential for field documentation:
import { Camera, useCameraDevices } from 'react-native-vision-camera';
export const InspectionPhotoCapture = ({ onPhotoTaken }) => {
const camera = useRef<Camera>(null);
const devices = useCameraDevices();
const device = devices.back;
const takePhoto = async () => {
if (camera.current) {
const photo = await camera.current.takePhoto({
qualityPrioritization: 'balanced',
flash: 'auto',
enableAutoStabilization: true,
});
// Compress for storage
const compressed = await ImageResizer.createResizedImage(
photo.path,
1920, // Max width
1080, // Max height
'JPEG',
80 // Quality
);
// Save to local database
await savePhotoLocally(compressed.uri);
onPhotoTaken(compressed.uri);
}
};
return (
<View style={styles.container}>
<Camera
ref={camera}
style={StyleSheet.absoluteFill}
device={device}
isActive={true}
photo={true}
/>
<View style={styles.controls}>
{/* Large capture button */}
<TouchableOpacity
style={styles.captureButton}
onPress={takePhoto}
>
<View style={styles.captureButtonInner} />
</TouchableOpacity>
</View>
</View>
);
};
const styles = StyleSheet.create({
captureButton: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#FFF',
padding: 4,
alignSelf: 'center',
},
captureButtonInner: {
flex: 1,
borderRadius: 36,
backgroundColor: '#DC2626',
},
});GPS Tagging
Automatically tag location:
import Geolocation from '@react-native-community/geolocation';
export const useLocationTagging = () => {
const [location, setLocation] = useState<{
latitude: number;
longitude: number;
} | null>(null);
useEffect(() => {
Geolocation.getCurrentPosition(
(position) => {
setLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
},
(error) => {
console.error('Location error:', error);
},
{
enableHighAccuracy: true,
timeout: 20000,
maximumAge: 1000,
}
);
}, []);
return location;
};
// Usage
const CreateInspection = () => {
const location = useLocationTagging();
const handleSubmit = async (data) => {
await db.insert('inspections').values({
...data,
latitude: location?.latitude,
longitude: location?.longitude,
timestamp: new Date(),
});
};
};Battery Optimization
Field workers can't charge frequently. Optimize battery:
// Reduce GPS polling
Geolocation.getCurrentPosition(
callback,
errorCallback,
{
enableHighAccuracy: false, // Use network location (less battery)
timeout: 20000,
maximumAge: 300000, // Cache for 5 minutes
}
);
// Throttle API calls
const throttledSync = useCallback(
throttle(async () => {
await syncData();
}, 60000), // Max once per minute
[]
);
// Batch operations
const batchSaveInspections = async (inspections: Inspection[]) => {
// Single transaction instead of multiple
await db.transaction(async (tx) => {
for (const inspection of inspections) {
await tx.insert('inspections').values(inspection);
}
});
};
// Reduce animations
const fieldAnimationConfig = {
useNativeDriver: true, // Faster, less battery
duration: 150, // Shorter = less CPU
};Progressive Disclosure
Don't overwhelm with complexity. Show basics first:
const InspectionDetail = ({ inspection }) => {
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<ScrollView>
{/* Essential info always visible */}
<View>
<Text style={fieldTypography.heading1}>{inspection.wellId}</Text>
<Text style={fieldTypography.body}>Status: {inspection.status}</Text>
<Text style={fieldTypography.body}>
Date: {format(inspection.date, 'MMM d, yyyy')}
</Text>
</View>
{/* Advanced info hidden by default */}
{showAdvanced && (
<View>
<Text>GPS: {inspection.latitude}, {inspection.longitude}</Text>
<Text>Inspector: {inspection.inspectorName}</Text>
<Text>Equipment: {inspection.equipmentId}</Text>
{/* More details... */}
</View>
)}
<TouchableOpacity onPress={() => setShowAdvanced(!showAdvanced)}>
<Text style={styles.toggleText}>
{showAdvanced ? 'Show Less' : 'Show More'}
</Text>
</TouchableOpacity>
</ScrollView>
);
};Haptic Feedback
Confirm actions with haptic feedback (especially with gloves):
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
const InspectionButton = ({ onPress, label }) => {
const handlePress = () => {
ReactNativeHapticFeedback.trigger('impactMedium');
onPress();
};
return (
<TouchableOpacity
style={styles.button}
onPress={handlePress}
>
<Text>{label}</Text>
</TouchableOpacity>
);
};
// Different feedback for different actions
const saveInspection = () => {
ReactNativeHapticFeedback.trigger('notificationSuccess');
// Save logic
};
const deleteInspection = () => {
ReactNativeHapticFeedback.trigger('notificationWarning');
// Delete logic
};Error Handling
Clear, actionable errors:
const ErrorMessage = ({ error, onRetry }) => {
return (
<View style={styles.errorContainer}>
<AlertTriangleIcon size={48} color="#DC2626" />
<Text style={styles.errorTitle}>
{error.title || 'Something went wrong'}
</Text>
<Text style={styles.errorMessage}>
{error.message}
</Text>
{onRetry && (
<TouchableOpacity
style={styles.retryButton}
onPress={onRetry}
>
<Text style={styles.retryButtonText}>
Try Again
</Text>
</TouchableOpacity>
)}
</View>
);
};
// Usage
const InspectionsList = () => {
const [error, setError] = useState(null);
const loadInspections = async () => {
try {
const data = await api.getInspections();
setInspections(data);
} catch (err) {
setError({
title: 'Cannot load inspections',
message: 'Check your connection and try again.',
});
}
};
if (error) {
return <ErrorMessage error={error} onRetry={loadInspections} />;
}
// ...
};Results
After implementing these field-optimized designs in WellOS:
- Task completion time: 40% faster
- Error rate: 60% reduction
- User satisfaction: 4.2 → 4.8 stars
- Adoption rate: 95% of field workers using daily
Lessons Learned
- Test in real conditions: Office testing doesn't reveal sunlight issues
- Gloves change everything: Touch targets must be larger
- Offline is non-negotiable: Field workers have no connectivity
- Voice > typing: Much faster for notes
- Battery matters: Field workers can't charge mid-shift
- Simplicity wins: Complex features go unused
Conclusion
Designing for field operations requires rethinking standard mobile UX. Large touch targets, high contrast, minimal text input, offline-first architecture, and optimized battery usage are essential.
After 27 years of building software and two years building field operation apps, I've learned that the best mobile apps for workers are the ones that get out of their way—fast, simple, and reliable when it matters most.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.