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:
- Entry & Navigation – How the app starts and moves between screens.
- Theming & Styling – How colours, shapes, and typography are dynamically applied.
- Screen Architecture (MVI) – The internal structure of each screen (State, Events, ViewModel).
- 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 launchesOnboardingActivityand 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
themeandaccent_colorfrom preferences and applies them viaAirMouseTheme. - Sensor Initialisation: Starts the
SensorServiceand sets theonOrientationChangedcallback to send movement data via theConnectionManager. - 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 byshouldShowBottomBar).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
enterTransitionandexitTransitionwithfadeInandslideInHorizontallyfor smooth screen changes. - Arguments: Supports passing arguments (e.g.,
qualityfor the calibration result screen). - Dependency Injection: Each screen uses
hiltViewModel()to get its ViewModel.
How navigation works:
- A user clicks a button in
HomeScreen. - The button calls
navigationActions.navigateTo(Destinations.Settings.route). NavigationActionsImplcallsnavController.navigate(route).- The
NavHostmatches the route and renders theSettingsScreencomposable. - 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 reusableRoundedCornerShapes (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
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.).Event (
Event) – A sealed class that represents user actions or system events (e.g.,SettingsEvent.ToggleHaptic,TouchpadEvent.TouchEvent).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).ViewModel – The bridge between the UI and the domain/data layers. It holds the
StateandEffectflows, handles incomingEvents, 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
MouseMotionPreviewCardshowing a green square that follows the phone's tilt. - Live gyroscope charts (
InlineSensorChartsCard).
- A 3D
- Connection Management: The top card (
ConnectionToggleButton) handles the primary CTA (Connect/Disconnect). TheStateOverviewBannershows detailed status (Approval countdown, server IP, calibration status). - Collaboration Gate: The
CollaborationGateCarddisplays 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
showTouchPointsis enabled, theTouchpadSurfacedraws 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
SettingsSliderandSettingsSwitchcomposables that directly dispatch events to the ViewModel. - Persistent State: Every slider or switch change immediately writes to
PreferencesManagervia the ViewModel, ensuring settings persist across app restarts. - Import/Export: The Privacy section allows exporting all settings as a
.txtfile and importing them back, using theActivityResultContracts.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. InSAMPLING, it shows a pulsing animation and a linear progress bar. - Sensor Sampling: The
CalibrationViewModelrequests sensor data via theCalibrationHelperand updates the UI with live gyro/accel values. - Quality Assessment: After completion, it navigates to
CalibrationResultScreenwhich displays theCalibrationQuality(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 customPreferencesManagerinterface). 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
LocalDataSourceImplhandles all CRUD operations. - ViewModel Lifecycle: ViewModels survive configuration changes (screen rotation). They are scoped to the
NavBackStackEntryof their respective screen, so state is preserved when navigating back. - State Restoration: The
StateFlowin ViewModels automatically retains the last emitted state. ThecollectAsStateWithLifecycle()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:
- User Touch: User touches the
TouchpadSurfaceand drags their finger. - Touch Event: The
Modifier.pointerInputblock captures the motion event and callsviewModel.handleEvent(TouchpadEvent.TouchEvent(x, y, pointerCount, pointers, pressure)). - ViewModel Processing:
TouchpadViewModelcalculatesdxanddybased on the movement, applies sensitivity, acceleration, and inversion settings. - Command Dispatch: The ViewModel calls
connectionManager.sendMove(dx, dy). - Network Layer:
ConnectionManagerformats the message as JSON{"type":"move","dx":12.5,"dy":-3.2}and sends it via WebSocket/TCP/UDP. - UI Feedback: Simultaneously, the ViewModel updates the
_uiState(e.g., updatingcurrentX,currentY, andtouchPoints), which triggers a recomposition of the touch points on the Canvas.
10. Performance Optimisation
- Throttling:
HomeViewModellimits 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
LazyColumnorLazyRowto only compose visible items. - Remember and DeriveState: Heavy calculations are wrapped in
rememberorderivedStateOfto prevent recomputation on every recomposition. - Animations: Uses
AnimatedVisibilityandanimate*AsStatefor smooth transitions without blocking the UI thread. Heavy particle systems run on a separateCanvaswith 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.