tahamajs's picture
|
download
raw
18.7 kB

The UI of the Air Mouse Android application is a modern, reactive, and highly modular system built entirely with Jetpack Compose. It follows a Unidirectional Data Flow (UDF) architecture, meaning the UI is a pure function of state, and user interactions trigger events that update that state.

Here is a complete, end‑to‑end explanation of how the UI works, from the moment the app launches to the deepest gesture interaction.


1. The Big Picture: Layers of the UI

The UI system is divided into four interconnected layers:

  1. Entry & Navigation – How the app starts and moves between screens.
  2. Theming & Styling – How colours, shapes, and typography are dynamically applied.
  3. Screen Architecture (MVI) – The internal structure of each screen (State, Events, ViewModel).
  4. Component Library – The reusable building blocks that ensure consistency.

2. Entry Point & Root Container

MainActivity – The Launchpad

  • Splash Screen: Uses installSplashScreen() and keeps it visible while the app loads resources and checks permissions.
  • Onboarding Check: If prefs.isOnboardingCompleted() is false, it immediately launches OnboardingActivity and finishes. Otherwise, it proceeds.
  • Permission Handling: Checks for Camera, Bluetooth, Microphone, and Notifications permissions. Shows a rationale screen if they are missing, and retries.
  • Theme Application: Reads theme and accent_color from preferences and applies them via AirMouseTheme.
  • Sensor Initialisation: Starts the SensorService and sets the onOrientationChanged callback to send movement data via the ConnectionManager.
  • Set Content: Finally, it calls setContent { MainScreen() }, rendering the root UI.

MainScreen.kt – The Root UI Container

This is the heart of the UI. It sets up the NavHostController, DrawerState, and the top-level Scaffold.

@Composable
fun MainScreen(...) {
    val navController = rememberNavController()
    val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)

    ModalNavigationDrawer(
        drawerState = drawerState,
        drawerContent = { ModernDrawerContent(...) }
    ) {
        Scaffold(
            topBar = { /* Top App Bar with menu, title, status chip */ },
            bottomBar = { if (shouldShowBottomBar(currentRoute)) ModernBottomBar(...) },
            floatingActionButton = { /* Contextual FAB (Calibrate, Touchpad, etc.) */ }
        ) { paddingValues ->
            MainNavHost(
                navController = navController,
                modifier = Modifier.padding(paddingValues),
                onOpenDrawer = { scope.launch { drawerState.open() } }
            )
        }
    }
}

Key components inside MainScreen:

  • ModalNavigationDrawer: The side menu (drawer) that appears when the hamburger icon is clicked.
  • TopAppBar: Displays the app title and connection status (e.g., "Online" or "Waiting for approval").
  • ModernBottomBar: Shows 4 tabs: Home, Statistics, Settings, Help. It only appears when the current route is one of these four (controlled by shouldShowBottomBar).
  • FloatingActionButton: Dynamically changes based on the current route and control mode (e.g., shows "Calibrate" on Home, "Touchpad" when in touchpad mode).
  • MainNavHost: The actual navigation graph where all screens are defined.

3. Navigation System

Destinations.kt – The Route Registry

Every screen is defined as a sealed class Destinations with a unique route, a title, and an icon. This centralises all routing logic.

sealed class Destinations(val route: String, val title: String, val icon: ImageVector) {
    object Home : Destinations("home", "Home", Icons.Default.Home)
    object Settings : Destinations("settings", "Settings", Icons.Default.Settings)
    // ... over 20 destinations
}

NavigationActions.kt – Abstraction for Navigation

Instead of calling navController.navigate() directly, the UI uses the NavigationActions interface. This makes the UI testable and decouples it from the specific NavController implementation.

interface NavigationActions {
    fun navigateTo(route: String)
    fun navigateBack()
    fun navigateToHome()
    fun navigateToSettings()
    // ... etc.
}

MainNavHost.kt – The NavGraph

This function defines the NavHost and maps each composable route to its respective screen composable.

  • Transitions: Uses enterTransition and exitTransition with fadeIn and slideInHorizontally for smooth screen changes.
  • Arguments: Supports passing arguments (e.g., quality for the calibration result screen).
  • Dependency Injection: Each screen uses hiltViewModel() to get its ViewModel.

How navigation works:

  1. A user clicks a button in HomeScreen.
  2. The button calls navigationActions.navigateTo(Destinations.Settings.route).
  3. NavigationActionsImpl calls navController.navigate(route).
  4. The NavHost matches the route and renders the SettingsScreen composable.
  5. The back button is handled by navigationActions.navigateBack().

4. Theming & Styling – Dynamic and Centralised

Theme.kt & ThemeColors.kt – The Colour System

The UI supports light, dark, pure black, high contrast, and several premium themes (Ocean, Sunset, Forest, etc.), all with customisable accent colours.

  • ThemeColorScheme: A data class containing all Material 3 colour roles (background, surface, primary, onSurface, etc.).
  • ThemeColorSchemes: An object that provides predefined schemes (e.g., darkTheme(), oceanTheme()).
  • AccentColor: An enum with 14 colours (Orange, Blue, Green, Purple, Pink, etc.), each with its own hex codes.

LocalThemeColors – CompositionLocal

To avoid passing colours down through every composable, the theme is provided via a CompositionLocal.

val LocalThemeColors = staticCompositionLocalOf { ThemeColorSchemes.darkTheme(AccentColor.ORANGE) }

@Composable
fun ProvideThemeColors(colors: ThemeColorScheme, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalThemeColors provides colors) {
        content()
    }
}

Any composable can access the current theme colours with val colors = LocalThemeColors.current.

AirMouseTheme – The Wrapper

This composable reads the current themeId and accentColor from preferences, generates the ThemeColorScheme, and wraps the entire app content.

@Composable
fun AirMouseTheme(themeId: String, accentColor: AccentColor, content: @Composable () -> Unit) {
    val colors = getThemeColorScheme(themeId, accentColor)
    ProvideThemeColors(colors) {
        MaterialTheme(
            colorScheme = // Maps ThemeColorScheme to M3 ColorScheme,
            typography = AirMouseTypography,
            shapes = AirMouseShapes,
            content = content
        )
    }
}

Dimensions.kt & Shapes.kt

  • Dimensions: Centralises all spacing (4dp, 8dp, 16dp...), padding, corner radii, icon sizes, button heights, and font sizes. Ensures consistent spacing across the entire app.
  • AppShapesProvider: Defines reusable RoundedCornerShapes (e.g., card, button, bottomSheet).

5. Screen Architecture – The MVI Pattern

Every screen follows a consistent Model-View-Intent (MVI) pattern, which is a flavour of Unidirectional Data Flow.

Components of the MVI Pattern

  1. State (UiState) – A data class that represents the entire state of the screen (e.g., HomeUiState, SettingsUiState). It contains all the data needed to render the UI (loading state, lists, booleans, error messages, etc.).

  2. Event (Event) – A sealed class that represents user actions or system events (e.g., SettingsEvent.ToggleHaptic, TouchpadEvent.TouchEvent).

  3. Effect (Effect) – A sealed class for one‑time events that should not survive recomposition (e.g., showing a Toast, navigating to another screen, opening a URL).

  4. ViewModel – The bridge between the UI and the domain/data layers. It holds the State and Effect flows, handles incoming Events, and executes business logic.

The Flow (Unidirectional Data Flow)

graph LR
    A[User Interaction] --> B[UI sends Event]
    B --> C[ViewModel processes Event]
    C --> D[ViewModel updates State]
    D --> E[UI recomposes with new State]
    C --> F[ViewModel emits Effect]
    F --> G[UI handles Effect (Toast, Nav)]

Example: SettingsScreen in Action

1. State Definition

data class SettingsUiState(
    val sensitivity: Float = 0.5f,
    val hapticEnabled: Boolean = true,
    val theme: String = "system",
    // ... 50+ other settings
)

2. Event Definition

sealed class SettingsEvent {
    data class UpdateSensitivity(val value: Float) : SettingsEvent()
    object ToggleHaptic : SettingsEvent()
    data class UpdateTheme(val theme: String) : SettingsEvent()
    // ...
}

3. Effect Definition

sealed class SettingsEffect {
    data class ShowToast(val message: String) : SettingsEffect()
    data class NavigateTo(val route: String) : SettingsEffect()
}

4. ViewModel Logic

@HiltViewModel
class SettingsViewModel @Inject constructor(private val prefs: PreferencesManager) : ViewModel() {
    private val _uiState = MutableStateFlow(SettingsUiState())
    val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()

    private val _effect = MutableSharedFlow<SettingsEffect>()
    val effect: SharedFlow<SettingsEffect> = _effect.asSharedFlow()

    fun handleEvent(event: SettingsEvent) {
        viewModelScope.launch {
            when (event) {
                is SettingsEvent.UpdateSensitivity -> updateSensitivity(event.value)
                SettingsEvent.ToggleHaptic -> toggleHaptic()
                // ...
            }
        }
    }

    private suspend fun toggleHaptic() {
        val current = _uiState.value.hapticEnabled
        prefs.putBoolean("haptic_enabled", !current)
        _uiState.update { it.copy(hapticEnabled = !current) }
        _effect.emit(SettingsEffect.ShowToast("Haptic ${if (!current) "enabled" else "disabled"}"))
    }
}

5. UI Observation and Event Dispatch

@Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val effect by viewModel.effect.collectAsStateWithLifecycle(null)

    // Handle Effects
    LaunchedEffect(effect) {
        when (effect) {
            is SettingsEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
            is SettingsEffect.NavigateTo -> navigationActions.navigateTo(effect.route)
            null -> { /* ignore */ }
        }
    }

    // Render UI based on State
    SettingsSwitch(
        title = "Haptic Feedback",
        checked = uiState.hapticEnabled,
        onCheckedChange = { viewModel.handleEvent(SettingsEvent.ToggleHaptic) }
    )
}

6. Deep Dive: Major Screens and Their Unique Mechanisms

A. HomeScreen – The Command Centre

The HomeScreen is the most complex UI component. It uses a LazyColumn to vertically stack numerous information cards.

  • Real‑time Sensor Data: It subscribes to HomeViewModel.sensorState (which updates at ~30 FPS) to render:
    • A 3D SensorVisualizer (rotating cube) showing the phone's orientation.
    • A 2D MouseMotionPreviewCard showing a green square that follows the phone's tilt.
    • Live gyroscope charts (InlineSensorChartsCard).
  • Connection Management: The top card (ConnectionToggleButton) handles the primary CTA (Connect/Disconnect). The StateOverviewBanner shows detailed status (Approval countdown, server IP, calibration status).
  • Collaboration Gate: The CollaborationGateCard displays a donut chart showing calibration progress. It blocks access to certain features until calibration is complete.
  • Quick Actions: A row of four small cards (QuickActionsRow) provides shortcuts to Calibration, Gesture Studio, Voice Commands, and Network Discovery.

B. TouchpadScreen – Full Gesture Surface

This screen turns the phone into a virtual touchpad. It uses the pointerInput modifier to capture raw touch events.

  • Event Capture: Modifier.pointerInput(Unit) { awaitEachGesture { ... } } tracks down, move, and up events.
  • Gesture Processing: The ViewModel analyses the pointer count (1, 2, 3, or 4 fingers) and movement to determine the gesture (Drag, Scroll, Swipe, Pinch, Tap, Long‑Press).
  • Visual Feedback: If showTouchPoints is enabled, the TouchpadSurface draws circles at the finger positions with a Canvas.
  • Haptic Feedback: On tap or gesture completion, the ViewModel triggers Vibrator (if enabled).

C. SettingsScreen – The Configuration Hub

This screen uses a LazyColumn with a SettingsCard for each section. Clicking a card navigates to a SectionDetailScreen within the same Scaffold (using a selectedSection state variable).

  • In‑line Editing: Cursor and gesture settings use SettingsSlider and SettingsSwitch composables that directly dispatch events to the ViewModel.
  • Persistent State: Every slider or switch change immediately writes to PreferencesManager via the ViewModel, ensuring settings persist across app restarts.
  • Import/Export: The Privacy section allows exporting all settings as a .txt file and importing them back, using the ActivityResultContracts.OpenDocument() launcher.

D. CalibrationScreen – Step‑by‑Step Wizard

This is a state‑driven wizard with three phases: INTRO, COUNTDOWN, and SAMPLING.

  • Phase Management: The UI changes drastically based on the CalibrationPhase. In SAMPLING, it shows a pulsing animation and a linear progress bar.
  • Sensor Sampling: The CalibrationViewModel requests sensor data via the CalibrationHelper and updates the UI with live gyro/accel values.
  • Quality Assessment: After completion, it navigates to CalibrationResultScreen which displays the CalibrationQuality (Excellent, Good, Fair, Poor) with emojis and descriptions.

7. Reusable UI Component Library

To maintain consistency and speed up development, the app has a rich library of composable components (mostly in AllUIComponents.kt and /components).

Atomic Components

  • GlassCard: A card with a blurred, translucent background (glassmorphism).
  • NeonButton: A button with a pulsing glow effect.
  • AnimatedSwitch / AnimatedCheckbox: Components with spring animations.
  • ShimmerEffect: A loading placeholder with a moving gradient.
  • ParticleBackground: A background with floating particles.

Compound Components

  • ControlDashboard: A reusable dashboard card showing connection status, battery, cursor position, and quick actions.
  • GestureTrainingCenter: A card displaying a list of gestures with training progress bars and "Train" buttons.
  • AnalyticsDashboard: Displays a gesture heatmap and activity chart using custom Canvas drawing.
  • CommunityHub: A social feed with posts, likes, and comments.

8. State Persistence and Lifecycle

  • PreferencesManager: All settings (sensitivity, theme, haptic, etc.) are stored in SharedPreferences (via DataStore or the custom PreferencesManager interface). This ensures that when the user re‑opens the app, their preferences are restored.
  • Room Database: Large datasets (calibration data, gesture templates, user profiles, statistics) are stored in Room. The LocalDataSourceImpl handles all CRUD operations.
  • ViewModel Lifecycle: ViewModels survive configuration changes (screen rotation). They are scoped to the NavBackStackEntry of their respective screen, so state is preserved when navigating back.
  • State Restoration: The StateFlow in ViewModels automatically retains the last emitted state. The collectAsStateWithLifecycle() API suspends collection when the screen is in the background to save resources.

9. Interaction Flow: From Touch to Server

To illustrate how the UI connects to the outside world, here is the complete flow of a touchpad drag:

  1. User Touch: User touches the TouchpadSurface and drags their finger.
  2. Touch Event: The Modifier.pointerInput block captures the motion event and calls viewModel.handleEvent(TouchpadEvent.TouchEvent(x, y, pointerCount, pointers, pressure)).
  3. ViewModel Processing: TouchpadViewModel calculates dx and dy based on the movement, applies sensitivity, acceleration, and inversion settings.
  4. Command Dispatch: The ViewModel calls connectionManager.sendMove(dx, dy).
  5. Network Layer: ConnectionManager formats the message as JSON {"type":"move","dx":12.5,"dy":-3.2} and sends it via WebSocket/TCP/UDP.
  6. UI Feedback: Simultaneously, the ViewModel updates the _uiState (e.g., updating currentX, currentY, and touchPoints), which triggers a recomposition of the touch points on the Canvas.

10. Performance Optimisation

  • Throttling: HomeViewModel limits sensor processing to 100Hz (10ms interval) and UI updates to ~30Hz (33ms interval). Move messages are sent at ~60Hz (16ms interval).
  • Lazy Loading: All scrollable lists use LazyColumn or LazyRow to only compose visible items.
  • Remember and DeriveState: Heavy calculations are wrapped in remember or derivedStateOf to prevent recomputation on every recomposition.
  • Animations: Uses AnimatedVisibility and animate*AsState for smooth transitions without blocking the UI thread. Heavy particle systems run on a separate Canvas with minimal recomposition.

✅ Summary

The Air Mouse UI is a state‑of‑the‑art Jetpack Compose application that excels in:

  • Reactive UDF: Unidirectional data flow makes the app predictable and easy to debug.
  • Modularity: Every screen and component is isolated and reusable.
  • Dynamic Theming: Full M3 support with custom colours and accent schemes.
  • Real‑time Performance: Optimised sensor and touch handling for smooth, lag‑free control.
  • Rich Interaction: Supports complex gestures (click, scroll, pinch, swipe, long‑press) across multiple modes (mouse, touchpad, voice, proximity).

The UI seamlessly ties together the sensor pipeline, network communication, and data persistence, providing a cohesive and polished user experience.

Xet Storage Details

Size:
18.7 kB
·
Xet hash:
8dbf0d65e551a300e4203563f95544e608c1e92e8aa7fd9a1b8232bbfd70aff7

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.