← Back to Blog
Mobile-First Design for Field Operations

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

  1. Test in real conditions: Office testing doesn't reveal sunlight issues
  2. Gloves change everything: Touch targets must be larger
  3. Offline is non-negotiable: Field workers have no connectivity
  4. Voice > typing: Much faster for notes
  5. Battery matters: Field workers can't charge mid-shift
  6. 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.

Share this article

Help others discover this content


Jason Cochran

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.