mifos-mobile/core-base/ui
2026-01-14 11:19:37 -05:00
..
src feat(network): migrate domain models to DTOs and update API services (#3065) 2026-01-14 11:19:37 -05:00
.gitignore chore: sync missing root files and folders from KMP project template (#2965) 2025-09-03 11:35:53 +05:30
build.gradle.kts feat(network): migrate domain models to DTOs and update API services (#3065) 2026-01-14 11:19:37 -05:00
consumer-rules.pro chore: sync missing root files and folders from KMP project template (#2965) 2025-09-03 11:35:53 +05:30
README.md chore: sync missing root files and folders from KMP project template (#2965) 2025-09-03 11:35:53 +05:30

Documentation: core-base/ui Module

The core-base/ui module serves as the foundation for building consistent, cross-platform user interfaces in Kotlin Multiplatform projects. This documentation explores the inner workings, implementation details, and best practices for each component.

Architectural Foundation

This module implements the unidirectional data flow pattern within the MVVM architecture, which creates a predictable and testable application structure:

  1. State flows down: UI receives immutable state snapshots
  2. Actions flow up: User interactions are sent as discrete actions
  3. Events are one-shot: Navigation and notifications occur once, not continuously

This pattern helps prevent common UI bugs like inconsistent state, navigation loops, and race conditions by enforcing a strict cycle of state updates.

1. BaseViewModel Implementation (BaseViewModel.kt)

Core Mechanics

The BaseViewModel serves as the cornerstone for UI state management, using Kotlin coroutines and channels to manage the application's data flow:

abstract class BaseViewModel<S, E, A>(initialState: S) : ViewModel() {
    protected val mutableStateFlow: MutableStateFlow<S> = MutableStateFlow(initialState)
    private val eventChannel: Channel<E> = Channel(capacity = Channel.UNLIMITED)
    private val internalActionChannel: Channel<A> = Channel(capacity = Channel.UNLIMITED)

    // Public immutable interfaces
    val stateFlow: StateFlow<S> = mutableStateFlow.asStateFlow()
    val eventFlow: Flow<E> = eventChannel.receiveAsFlow()
    val actionChannel: SendChannel<A> = internalActionChannel

    // Initialize action processing
    init {
        viewModelScope.launch {
            internalActionChannel
                .consumeAsFlow()
                .collect { action -> handleAction(action) }
        }
    }

    protected abstract fun handleAction(action: A)
}

The Channel.UNLIMITED capacity ensures that actions and events won't be dropped if they're emitted faster than they can be processed, which is crucial for maintaining UI integrity.

Advanced Usage Patterns

Beyond the basic implementation, effective BaseViewModel usage includes:

1. State Splitting

For complex screens, consider splitting state into logical subgroups:

data class ProfileState(
    val userData: UserDataState = UserDataState(),
    val settings: SettingsState = SettingsState(),
    val interaction: InteractionState = InteractionState()
)

data class UserDataState(
    val isLoading: Boolean = false,
    val user: User? = null,
    val error: String? = null
)

data class SettingsState(
    val notifications: Boolean = true,
    val darkMode: Boolean = false
)

data class InteractionState(
    val selectedTab: Tab = Tab.PROFILE,
    val isEditMode: Boolean = false
)

This approach makes it easier to update only relevant portions of state and prevents unnecessary recompositions.

2. Action Chaining

For complex operations that require multiple state updates:

override fun handleAction(action: ProfileAction) {
    when (action) {
        is ProfileAction.UpdateProfile -> {
            mutableStateFlow.value = state.copy(
                userData = state.userData.copy(isLoading = true)
            )

            viewModelScope.launch {
                try {
                    val updatedUser = userRepository.updateProfile(action.updates)
                    sendAction(ProfileAction.ProfileUpdateSuccess(updatedUser))
                } catch (e: Exception) {
                    sendAction(ProfileAction.ProfileUpdateFailure(e.message ?: "Unknown error"))
                }
            }
        }

        is ProfileAction.ProfileUpdateSuccess -> {
            mutableStateFlow.value = state.copy(
                userData = state.userData.copy(
                    isLoading = false,
                    user = action.user,
                    error = null
                )
            )
            sendEvent(ProfileEvent.ShowSuccessMessage("Profile updated successfully"))
        }

        is ProfileAction.ProfileUpdateFailure -> {
            mutableStateFlow.value = state.copy(
                userData = state.userData.copy(
                    isLoading = false,
                    error = action.message
                )
            )
            sendEvent(ProfileEvent.ShowErrorMessage(action.message))
        }
    }
}

3. Shared Actions

For actions that need to be processed by multiple ViewModels, define them in a shared location and have each ViewModel handle the subset it cares about:

sealed class AppAction {
    object LogOut : AppAction()
    data class NetworkStatusChanged(val isConnected: Boolean) : AppAction()
    data class ThemeChanged(val isDarkMode: Boolean) : AppAction()
}

// Then in ViewModels, handle relevant actions:
override fun handleAction(action: AppAction) {
    when (action) {
        is AppAction.ThemeChanged -> {
            // Only handle theme changes in this ViewModel
            mutableStateFlow.value = state.copy(isDarkMode = action.isDarkMode)
        }
        else -> {
            // Ignore other AppActions
        }
    }
}

2. Events System (BackgroundEvent.kt, EventsEffect.kt)

Understanding the Event Flow

The events system has several critical components working together:

  1. eventChannel: A backing Channel that buffers events
  2. eventFlow: Public Flow for consuming events once
  3. EventsEffect: A composable that consumes events with lifecycle awareness
  4. BackgroundEvent: A marker interface for events that bypass lifecycle checks

Implementation Details

The EventsEffect composable uses a LaunchedEffect to safely collect events within the composition lifecycle:

@Composable
fun <E> EventsEffect(
    viewModel: BaseViewModel<*, E, *>,
    lifecycleOwner: Lifecycle = LocalLifecycleOwner.current.lifecycle,
    handler: suspend (E) -> Unit,
) {
    LaunchedEffect(key1 = Unit) {
        viewModel.eventFlow
            .filter {
                it is BackgroundEvent ||
                    lifecycleOwner.currentState.isAtLeast(Lifecycle.State.RESUMED)
            }
            .onEach { handler.invoke(it) }
            .launchIn(this)
    }
}

The filter operator is crucial here—it ensures that events are only processed when:

  • The event implements the BackgroundEvent interface, OR
  • The screen is currently visible (in the RESUMED state)

This prevents navigation events from triggering multiple times during configuration changes or when returning to a screen from the background.

Types of Events

Events typically fall into four categories:

  1. Navigation Events: Direct the user to a new screen

    data class NavigateTo(val route: String, val popUpTo: String? = null) : UiEvent
    
  2. Message Events: Show transient UI like toasts or snackbars

    data class ShowMessage(val message: String, val type: MessageType) : UiEvent
    
  3. Dialog Events: Display modal UI elements

    data class ShowDialog(val title: String, val message: String) : UiEvent
    
  4. System Events: Interact with system components like camera or permissions

    object RequestCameraPermission : UiEvent, BackgroundEvent
    

3. Lifecycle Observer (LifecycleEventEffect.kt)

The LivecycleEventEffect composable provides a clean way to observe and respond to Android lifecycle events within compositions. It uses the DisposableEffect API to ensure proper cleanup.

Implementation Analysis

The implementation uses a clever combination of rememberUpdatedState and DisposableEffect:

@Composable
fun LivecycleEventEffect(
    onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit,
) {
    val eventHandler = rememberUpdatedState(onEvent)
    val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)

    DisposableEffect(lifecycleOwner.value) {
        val lifecycle = lifecycleOwner.value.lifecycle
        val observer = LifecycleEventObserver { owner, event ->
            eventHandler.value(owner, event)
        }

        lifecycle.addObserver(observer)
        onDispose {
            lifecycle.removeObserver(observer)
        }
    }
}

The rememberUpdatedState calls are essential: they ensure that if onEvent or thelifecycleOwner changes during composition, the observer always uses the most current versions without needing to resubscribe.

Advanced Lifecycle Handling

When working with complex screens that may have their own internal composition lifecycles:

@Composable
fun ComplexScreenWithTabs(viewModel: ComplexViewModel) {
    var currentTab by remember { mutableStateOf(Tab.HOME) }

    // Main screen lifecycle
    LivecycleEventEffect { owner, event ->
        when (event) {
            Lifecycle.Event.ON_RESUME -> {
                viewModel.trySendAction(ComplexAction.ScreenResumed)
                Analytics.logScreenView("ComplexScreen")
            }
            Lifecycle.Event.ON_PAUSE -> {
                viewModel.trySendAction(ComplexAction.ScreenPaused)
            }
            Lifecycle.Event.ON_DESTROY -> {
                viewModel.trySendAction(ComplexAction.SaveState)
            }
            else -> { /* Ignore other events */
            }
        }
    }

    // Tab-specific behavior
    when (currentTab) {
        Tab.HOME -> HomeTab(
            onEnter = { /* Tab-specific enter logic */ },
            onExit = { /* Tab-specific exit logic */ }
        )
        Tab.PROFILE -> ProfileTab(
            onEnter = { /* Tab-specific enter logic */ },
            onExit = { /* Tab-specific exit logic */ }
        )
    }
}

This pattern allows separation of screen-level lifecycle events from tab-specific behavior.

4. Navigation Extensions (NavGraphBuilderExtensions.kt, Transition.kt)

The navigation system provides a rich set of transition patterns that create a cohesive, motion-driven navigation experience.

Transition Animation Details

Each transition type is carefully timed and coordinated:

Slide Transitions (450ms):

  • Slide content from bottom to top (enter) or top to bottom (exit)
  • Used for modal dialogs and bottom sheets

Push Transitions (350ms):

  • Horizontal sliding with synchronized fading
  • Content slides in from right/out to left for forward navigation
  • Content slides in from left/out to right when going back
  • Includes a subtle overlap timing to create a natural feeling of depth

Stay Transitions:

  • No visible movement to maintain context
  • Maintains visibility for the duration of other concurrent transitions
  • Uses fade transitions with minimal alpha changes (from 1.0 to 0.99) to keep Compose from optimizing away the animation

Intelligent Transition Handling

The most sophisticated aspect is the handling of nested navigation:

val AnimatedContentTransitionScope<NavBackStackEntry>.isSameGraphNavigation: Boolean
    get() = initialState.destination.parent == targetState.destination.parent

This property checks if we're navigating between destinations within the same parent graph, and transitions will only apply within the same graph, allowing for hierarchical navigation patterns.

val fadeIn: EnterTransitionProvider = {
    RootTransitionProviders.Enter
        .fadeIn(this)
        .takeIf { isSameGraphNavigation }
}

By returning null when navigating between different graphs, this allows parent navigators to define transitions for cross-graph navigation while child navigators handle transitions within their scope.

5. Image Loading (ImageLoaderExt.kt)

The image loading system abstracts Coil's capabilities across platforms while providing sensible defaults and optimization.

Memory Management

The system intelligently manages memory based on platform constraints:

internal fun rememberDefaultImageLoader(context: PlatformContext): ImageLoader {
    return remember(context) {
        ImageLoader.Builder(context)
            .memoryCache {
                MemoryCache.Builder()
                    .maxSizePercent(context, 0.25)  // Use 25% of available memory
                    .build()
            }
            .logger(DebugLogger())
            .build()
    }
}

The maxSizePercent call is crucial—it adapts the cache size to the device's available memory, ensuring efficient resource usage across a wide range of devices.

Request Optimization

The image request builder includes memory cache optimization:

@Composable
fun rememberImageRequest(
    context: PlatformContext,
    wallpaper: String,
): ImageRequest {
    return remember(wallpaper) {
        ImageRequest.Builder(context)
            .data(wallpaper)
            .memoryCacheKey(wallpaper)
            .placeholderMemoryCacheKey(wallpaper)
            .build()
    }
}

The use of memoryCacheKey and placeholderMemoryCacheKey ensures that images with the same URL share the same cache entry, reducing memory usage and improving load times.

Common Use Patterns

For profile pictures and avatars:

@Composable
fun CircularProfileImage(url: String, size: Dp = 48.dp) {
    val context = LocalPlatformContext.current
    val imageLoader = rememberImageLoader(context)

    AsyncImage(
        model = rememberImageRequest(context, url),
        contentDescription = "Profile picture",
        imageLoader = imageLoader,
        modifier = Modifier
            .size(size)
            .clip(CircleShape)
            .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape),
        contentScale = ContentScale.Crop,
        placeholder = painterResource(R.drawable.placeholder_profile),
        error = painterResource(R.drawable.error_profile)
    )
}

For background images:

@Composable
fun BackgroundImage(url: String, overlay: Color = Color.Black.copy(alpha = 0.3f)) {
    val context = LocalPlatformContext.current
    val imageLoader = rememberImageLoader(context)

    Box {
        AsyncImage(
            model = rememberImageRequest(context, url),
            contentDescription = null,
            imageLoader = imageLoader,
            modifier = Modifier.fillMaxSize(),
            contentScale = ContentScale.FillBounds
        )

        // Overlay for better text visibility
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(overlay)
        )
    }
}

6. Performance Monitoring (JankStatsExtensions.kt)

Performance monitoring is crucial for delivering smooth UIs. The JankStats integration helps identify and address UI performance issues.

Jank Detection Mechanism

"Jank" refers to frames that take longer than 16.67ms (for 60fps) to render, causing visible stuttering. The Android-specific implementation uses the Metrics API:

@Composable
actual fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) {
    TrackJank(scrollableState) { metricsHolder ->
        snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress ->
            metricsHolder.state?.apply {
                if (isScrollInProgress) {
                    putState(stateName, "Scrolling=true")
                } else {
                    removeState(stateName)
                }
            }
        }
    }
}

When scrolling starts, the system marks the current frames with the provided state name. This allows the performance tools to attribute jank to specific UI interactions.

Performance Optimization Strategies

To minimize jank in scrolling lists:

  1. Minimize composition cost: Use key for list items to prevent unnecessary recomposition
  2. Avoid nested scrolling: Nested scrollable containers can compound performance issues
  3. Lazy loading: Only load visible items and maintain a reasonable buffer
  4. Pre-compute complex layouts: Calculate layout parameters ahead of time
  5. Bitmap caching: For complex images, pre-compute and cache bitmaps
  6. Avoid allocation in scroll: Don't create new objects during scrolling

Example of a performance-optimized list:

@Composable
fun OptimizedList(items: List<ListItem>) {
    val listState = rememberLazyListState()

    // Track scrolling performance
    TrackScrollJank(listState, "main_list")

    // Pre-compute expensive stuff
    val coloredItems = remember(items) {
        items.map { it.copy(color = calculateComplexColor(it)) }
    }

    LazyColumn(state = listState) {
        items(
            items = coloredItems,
            key = { it.id } // Stable key for efficient updates
        ) { item ->
            // Cached layout calculation
            val layoutInfo = remember(item.id) {
                calculateLayout(item)
            }

            ListItemRow(
                item = item,
                layoutInfo = layoutInfo,
                modifier = Modifier.animateItemPlacement()
            )
        }
    }
}

7. Cross-Platform Sharing (ShareUtils.kt)

The ShareUtils object provides a unified API for sharing content, with platform-specific implementations handling the technical details.

Platform Implementation Details

Android Implementation:

  • Uses Android's Intent system with ACTION_SEND
  • For images, first saves to cache directory, then creates a FileProvider URI
  • Requires a valid Activity context from activityProvider

iOS (Native) Implementation:

  • Uses UIActivityViewController for sharing
  • Requires the root view controller from UIApplication.sharedApplication()

Desktop/JS/WASM Implementations:

  • Use FileKit to save content to disk since direct sharing is less standardized
  • For images, converts ImageBitmap to pixel data before saving

Security Considerations

The Android implementation includes important security features:

private suspend fun saveImage(image: Bitmap, context: Context): Uri? {
    return withContext(Dispatchers.IO) {
        try {
            val imagesFolder = File(context.cacheDir, "images")
            // ... save image ...

            // Use FileProvider for secure content sharing
            FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
        } catch (e: IOException) {
            Log.d("saving bitmap", "saving bitmap error ${e.message}")
            null
        }
    }
}

Using FileProvider instead of direct file URIs is essential for API 24+ compatibility and security. The provider creates content URIs that grant temporary access to the files being shared, without exposing file system paths.

8. String Extensions (StringExt.kt)

The capitalizeEachWord extension demonstrates how simple utility functions can improve code clarity and consistency.

val String.capitalizeEachWord: String
    get() = this.split(" ").joinToString(" ") { word ->
        word.takeIf { it.isNotEmpty() }
            ?.let { it.first().uppercase() + it.substring(1).lowercase() }
            ?: ""
    }

This implementation handles edge cases like:

  • Empty strings
  • Words containing only a single character
  • Strings with multiple consecutive spaces

For multi-lingual applications, consider extending this with locale-aware capitalization:

fun String.capitalizeEachWordWithLocale(locale: Locale): String {
    return this.split(" ").joinToString(" ") { word ->
        word.takeIf { it.isNotEmpty() }
            ?.let { it.replaceFirstChar { char -> char.titlecase(locale) } }
            ?: ""
    }
}

9. Reporting Drawn State (ReportDrawnExt.kt)

The ReportDrawnWhen composable is an important performance optimization that tells the system when content is considered meaningfully drawn.

Platform-Specific Implementations

On Android, the implementation delegates to the Android Compose implementation:

@Composable
actual fun ReportDrawnWhen(block: () -> Boolean) {
    androidx.activity.compose.ReportDrawnWhen { block() }
}

On other platforms, the implementation is a no-op, preserving the API surface without requiring platform-specific functionality:

@Composable
actual fun ReportDrawnWhen(block: () -> Boolean) {
    // No-op implementation
}

Performance Impact

This composable has a significant performance impact on initial screen rendering. Android uses this signal to:

  1. Mark the activity as drawn for launcher animations
  2. Complete "warm start" timing measurements
  3. Report performance metrics to developer tools

A common pattern is to report drawn status once critical content is visible, even if background loading continues:

@Composable
fun NewsScreen(viewModel: NewsViewModel) {
    val state by viewModel.stateFlow.collectAsState()

    Column {
        TopBar()

        when (val currentState = state) {
            is Loading -> LoadingIndicator()
            is Success -> {
                NewsList(currentState.headlines)

                // Asynchronously load recommended stories
                LaunchedEffect(Unit) {
                    viewModel.trySendAction(LoadRecommendations)
                }
            }
            is Error -> ErrorView(currentState.message)
        }
    }

    // Report as drawn once headlines are loaded, even if recommendations are still loading
    ReportDrawnWhen {
        state is Success
    }
}

10. Shared Element Transitions (SharedElementExt.kt)

The shared element transition system enables smooth visual continuity between screens using Material 3's shared element transitions.

Implementation Details

The system uses composition locals to provide access to animation scopes:

val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }

@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }

These locals enable any composable in the hierarchy to participate in transitions regardless of their depth in the UI tree.

Advanced Transition Patterns

Beyond basic image transitions, shared elements can be used for:

  1. Expanding Cards: A card expands into a full screen detail view

    Card(
        modifier = Modifier
            .sharedElement(
                scope = sharedTransitionScope,
                state = rememberSharedContentState(key = "card-${item.id}")
            )
    ) {
        // Card content
    }
    
    // In detail screen:
    Surface(
        modifier = Modifier
            .fillMaxSize()
            .sharedElement(
                scope = sharedTransitionScope,
                state = rememberSharedContentState(key = "card-${item.id}")
            )
    ) {
        // Detail content
    }
    
  2. Text Transitions: Text elements that move and resize

    Text(
        text = item.title,
        style = MaterialTheme.typography.titleMedium,
        modifier = Modifier.sharedElement(
            scope = sharedTransitionScope,
            state = rememberSharedContentState(key = "title-${item.id}")
        )
    )
    
    // In detail screen:
    Text(
        text = item.title,
        style = MaterialTheme.typography.headlineLarge,
        modifier = Modifier.sharedElement(
            scope = sharedTransitionScope,
            state = rememberSharedContentState(key = "title-${item.id}")
        )
    )
    
  3. Color Transitions: Smoothly changing colors between screens

    Box(
        modifier = Modifier
            .background(item.color)
            .sharedElement(
                scope = sharedTransitionScope,
                state = rememberSharedContentState(key = "color-${item.id}")
            )
    )
    

Comprehensive Testing Strategy

A robust testing strategy ensures the module's reliability across platforms.

Unit Testing ViewModels

Test ViewModels by verifying state changes, action handling, and event emission:

@Test
fun `when profile loaded successfully, state updated and success event emitted`() = runTest {
        // Given
        val repository = FakeUserRepository()
        val viewModel = ProfileViewModel(repository)
        val events = mutableListOf<ProfileEvent>()
        val job = launch { viewModel.eventFlow.collect { events.add(it) } }

        // When
        viewModel.trySendAction(ProfileAction.LoadProfile("user123"))

        // Then
        assertEquals(false, viewModel.stateFlow.value.userData.isLoading)
        assertNotNull(viewModel.stateFlow.value.userData.user)
        assertEquals("user123", viewModel.stateFlow.value.userData.user?.id)
        assertEquals(1, events.size)
        assertTrue(events[0] is ProfileEvent.ProfileLoaded)

        job.cancel()
    }

Testing Composables

Use the Compose testing library to verify UI behavior:

@Test
fun profileScreen_showsUserData_whenProvided() {
    // Given
    val user = User("123", "Jane Doe", "jane@example.com")
    val state = ProfileState(userData = UserDataState(user = user))

    // When
    composeTestRule.setContent {
        MaterialTheme {
            ProfileScreen(state = state, onAction = {})
        }
    }

    // Then
    composeTestRule.onNodeWithText("Jane Doe").assertIsDisplayed()
    composeTestRule.onNodeWithText("jane@example.com").assertIsDisplayed()
}

Integration Testing

Test component interactions using fake implementations:

@Test
fun navigationEvents_triggerCorrectNavigation() {
    // Given
    val navController = TestNavHostController(ApplicationProvider.getApplicationContext())

    composeTestRule.setContent {
        NavigationTestHost(navController = navController) {
            val viewModel = ProfileViewModel(FakeUserRepository())
            ProfileScreen(viewModel = viewModel)

            // Set up event observation
            EventsEffect(viewModel) { event ->
                when (event) {
                    is ProfileEvent.NavigateToSettings -> {
                        navController.navigate("settings")
                    }
                }
            }
        }
    }

    // When - click settings button
    composeTestRule.onNodeWithContentDescription("Settings").performClick()

    // Then - verify navigation occurred
    assertEquals("settings", navController.currentDestination?.route)
}

By combining these testing approaches, you can ensure the core-base/ui module functions correctly across all supported platforms and integration points.