Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124

Welcome back to our Kotlin Multiplatform journey! In Part 2, we created our first KMP project and saw shared data classes in action. Now we’re ready to dive deeper and build the business logic that will power our Movie Database app.
Today, we’ll transform our simple movie list into a professional, well-architected application with proper separation of concerns, error handling, and shared business logic that both platforms can rely on.
By the end of this tutorial, you’ll have:
Let’s build something production-ready!

Before we start coding, let’s understand how to properly organize our shared business logic. We’ll follow Clean Architecture principles adapted for Kotlin Multiplatform:
shared/src/commonMain/kotlin/
├── data/ # Data layer
│ ├── repository/ # Repository implementations
│ └── source/ # Data sources (API, local, etc.)
├── domain/ # Business logic layer
│ ├── model/ # Data models (we already have this)
│ ├── repository/ # Repository contracts/interfaces
│ └── usecase/ # Business use cases
├── presentation/ # Presentation layer
│ └── viewmodel/ # Shared ViewModels
└── di/ # Dependency injection
This structure ensures:
Let’s start by creating a clean contract for accessing movie data. This pattern allows us to easily switch between different data sources (API, local database, cache) without affecting our business logic.
Create shared/src/commonMain/kotlin/domain/repository/MovieRepository.kt:
// shared/src/commonMain/kotlin/domain/repository/MovieRepository.kt
package domain.repository
import domain.model.Movie
/**
* Contract for accessing movie data from various sources.
* This interface defines what operations are available without
* specifying how they're implemented.
*/
interface MovieRepository {
/**
* Retrieves a list of popular movies
* @param page The page number for pagination (starting from 1)
* @return Result containing list of movies or error
*/
suspend fun getPopularMovies(page: Int = 1): Result<List<Movie>>
/**
* Searches for movies by title
* @param query The search query
* @param page The page number for pagination
* @return Result containing list of matching movies or error
*/
suspend fun searchMovies(query: String, page: Int = 1): Result<List<Movie>>
/**
* Gets detailed information for a specific movie
* @param movieId The movie ID
* @return Result containing movie details or error
*/
suspend fun getMovieDetails(movieId: Int): Result<Movie>
/**
* Gets a list of favorite movies stored locally
* @return Result containing list of favorite movies or error
*/
suspend fun getFavoriteMovies(): Result<List<Movie>>
/**
* Adds a movie to favorites
* @param movie The movie to add to favorites
* @return Result indicating success or failure
*/
suspend fun addToFavorites(movie: Movie): Result<Unit>
/**
* Removes a movie from favorites
* @param movieId The ID of the movie to remove
* @return Result indicating success or failure
*/
suspend fun removeFromFavorites(movieId: Int): Result<Unit>
/**
* Checks if a movie is in the user's favorites
* @param movieId The movie ID to check
* @return Result containing boolean indicating if movie is favorited
*/
suspend fun isFavorite(movieId: Int): Result<Boolean>
}
You’ll notice we’re using a Result type. Let’s create our own error-handling system that works well across platforms:
Create shared/src/commonMain/kotlin/domain/model/Result.kt:
// shared/src/commonMain/kotlin/domain/model/Result.kt
package domain.model
/**
* A generic wrapper for handling success and error states
* across all platforms in a consistent way.
*/
sealed class Result<out T> {
/**
* Represents a successful operation with data
*/
data class Success<T>(val data: T) : Result<T>()
/**
* Represents a failed operation with error information
*/
data class Error<T>(
val exception: Throwable,
val message: String = exception.message ?: "Unknown error occurred"
) : Result<T>()
/**
* Represents a loading state (useful for UI)
*/
data class Loading<T>(val message: String = "Loading...") : Result<T>()
// Convenience methods for working with Results
/**
* Returns true if this result represents a successful operation
*/
val isSuccess: Boolean
get() = this is Success
/**
* Returns true if this result represents a failed operation
*/
val isError: Boolean
get() = this is Error
/**
* Returns true if this result represents a loading state
*/
val isLoading: Boolean
get() = this is Loading
/**
* Returns the data if success, null otherwise
*/
fun getOrNull(): T? = when (this) {
is Success -> data
else -> null
}
/**
* Returns the data if success, or throws the exception if error
*/
fun getOrThrow(): T = when (this) {
is Success -> data
is Error -> throw exception
is Loading -> throw IllegalStateException("Cannot get data from loading state")
}
/**
* Transforms the data if this is a success result
*/
inline fun <R> map(transform: (T) -> R): Result<R> = when (this) {
is Success -> try {
Success(transform(data))
} catch (e: Exception) {
Error(e)
}
is Error -> Error(exception, message)
is Loading -> Loading(message)
}
/**
* Executes the given action if this is a success result
*/
inline fun onSuccess(action: (T) -> Unit): Result<T> {
if (this is Success) action(data)
return this
}
/**
* Executes the given action if this is an error result
*/
inline fun onError(action: (Throwable) -> Unit): Result<T> {
if (this is Error) action(exception)
return this
}
}
/**
* Creates a success result
*/
fun <T> Result.Companion.success(data: T): Result<T> = Result.Success(data)
/**
* Creates an error result
*/
fun <T> Result.Companion.error(exception: Throwable, message: String? = null): Result<T> =
Result.Error(exception, message ?: exception.message ?: "Unknown error")
/**
* Creates a loading result
*/
fun <T> Result.Companion.loading(message: String = "Loading..."): Result<T> =
Result.Loading(message)
Now let’s create a concrete implementation of our repository. For now, we’ll use mock data, but this structure will make it easy to switch to real API calls later:
Create shared/src/commonMain/kotlin/data/repository/MovieRepositoryImpl.kt:
// shared/src/commonMain/kotlin/data/repository/MovieRepositoryImpl.kt
package data.repository
import domain.model.Movie
import domain.model.MovieSamples
import domain.model.Result
import domain.repository.MovieRepository
import kotlinx.coroutines.delay
/**
* Implementation of MovieRepository using mock data.
* This simulates real network calls with delays and occasional errors.
*/
class MovieRepositoryImpl : MovieRepository {
// Simulate a local favorites storage
private val favoriteMovies = mutableSetOf<Int>()
override suspend fun getPopularMovies(page: Int): Result<List<Movie>> {
return try {
// Simulate network delay
delay(1000)
// Simulate occasional network errors
if (Math.random() < 0.1) { // 10% chance of error
throw Exception("Network connection failed")
}
// Return paginated results
val allMovies = MovieSamples.sampleMovies
val startIndex = (page - 1) * 10
val endIndex = minOf(startIndex + 10, allMovies.size)
if (startIndex >= allMovies.size) {
Result.success(emptyList())
} else {
Result.success(allMovies.subList(startIndex, endIndex))
}
} catch (e: Exception) {
Result.error(e)
}
}
override suspend fun searchMovies(query: String, page: Int): Result<List<Movie>> {
return try {
delay(800) // Simulate search delay
if (query.isBlank()) {
return Result.success(emptyList())
}
// Simple search implementation
val filteredMovies = MovieSamples.sampleMovies.filter { movie ->
movie.title.contains(query, ignoreCase = true) ||
movie.overview.contains(query, ignoreCase = true)
}
Result.success(filteredMovies)
} catch (e: Exception) {
Result.error(e)
}
}
override suspend fun getMovieDetails(movieId: Int): Result<Movie> {
return try {
delay(500)
val movie = MovieSamples.sampleMovies.find { it.id == movieId }
?: throw Exception("Movie not found with ID: $movieId")
Result.success(movie)
} catch (e: Exception) {
Result.error(e)
}
}
override suspend fun getFavoriteMovies(): Result<List<Movie>> {
return try {
delay(300) // Simulate database access
val favorites = MovieSamples.sampleMovies.filter {
favoriteMovies.contains(it.id)
}
Result.success(favorites)
} catch (e: Exception) {
Result.error(e)
}
}
override suspend fun addToFavorites(movie: Movie): Result<Unit> {
return try {
delay(200)
favoriteMovies.add(movie.id)
Result.success(Unit)
} catch (e: Exception) {
Result.error(e)
}
}
override suspend fun removeFromFavorites(movieId: Int): Result<Unit> {
return try {
delay(200)
favoriteMovies.remove(movieId)
Result.success(Unit)
} catch (e: Exception) {
Result.error(e)
}
}
override suspend fun isFavorite(movieId: Int): Result<Boolean> {
return try {
delay(100)
Result.success(favoriteMovies.contains(movieId))
} catch (e: Exception) {
Result.error(e)
}
}
}
Use cases encapsulate specific business operations and make our code more testable and maintainable. Let’s create some key use cases for our movie app:
Create shared/src/commonMain/kotlin/domain/usecase/GetPopularMoviesUseCase.kt:
// shared/src/commonMain/kotlin/domain/usecase/GetPopularMoviesUseCase.kt
package domain.usecase
import domain.model.Movie
import domain.model.Result
import domain.repository.MovieRepository
/**
* Use case for retrieving popular movies with additional business logic
*/
class GetPopularMoviesUseCase(
private val repository: MovieRepository
) {
/**
* Executes the use case to get popular movies
* @param page Page number for pagination
* @param includeAdult Whether to include adult content
* @return Result containing filtered and processed movie list
*/
suspend operator fun invoke(
page: Int = 1,
includeAdult: Boolean = false
): Result<List<Movie>> {
// Input validation
if (page < 1) {
return Result.error(
IllegalArgumentException("Page number must be positive"),
"Invalid page number"
)
}
return when (val result = repository.getPopularMovies(page)) {
is Result.Success -> {
val movies = result.data
// Apply business rules
val filteredMovies = movies
.let { movieList ->
if (!includeAdult) {
movieList.filter { !it.isAdult }
} else {
movieList
}
}
.sortedByDescending { it.voteAverage } // Sort by rating
Result.success(filteredMovies)
}
is Result.Error -> result
is Result.Loading -> result
}
}
}
Create shared/src/commonMain/kotlin/domain/usecase/SearchMoviesUseCase.kt:
// shared/src/commonMain/kotlin/domain/usecase/SearchMoviesUseCase.kt
package domain.usecase
import domain.model.Movie
import domain.model.Result
import domain.repository.MovieRepository
/**
* Use case for searching movies with business logic and validation
*/
class SearchMoviesUseCase(
private val repository: MovieRepository
) {
/**
* Searches for movies with the given query
* @param query Search term
* @param minRating Minimum rating filter (optional)
* @return Result containing filtered search results
*/
suspend operator fun invoke(
query: String,
minRating: Double? = null
): Result<List<Movie>> {
// Input validation
if (query.isBlank()) {
return Result.success(emptyList())
}
if (query.length < 2) {
return Result.error(
IllegalArgumentException("Search query too short"),
"Please enter at least 2 characters"
)
}
return when (val result = repository.searchMovies(query.trim())) {
is Result.Success -> {
val movies = result.data
// Apply additional filtering
val filteredMovies = movies
.let { movieList ->
if (minRating != null) {
movieList.filter { it.voteAverage >= minRating }
} else {
movieList
}
}
.sortedWith(
compareByDescending<Movie> { it.voteAverage }
.thenByDescending { it.voteCount }
)
Result.success(filteredMovies)
}
is Result.Error -> result
is Result.Loading -> result
}
}
}
Create shared/src/commonMain/kotlin/domain/usecase/FavoriteMoviesUseCase.kt:
// shared/src/commonMain/kotlin/domain/usecase/FavoriteMoviesUseCase.kt
package domain.usecase
import domain.model.Movie
import domain.model.Result
import domain.repository.MovieRepository
/**
* Use case for managing favorite movies
*/
class FavoriteMoviesUseCase(
private val repository: MovieRepository
) {
/**
* Gets all favorite movies
*/
suspend fun getFavorites(): Result<List<Movie>> {
return when (val result = repository.getFavoriteMovies()) {
is Result.Success -> {
// Sort favorites by rating, then by title
val sortedFavorites = result.data.sortedWith(
compareByDescending<Movie> { it.voteAverage }
.thenBy { it.title }
)
Result.success(sortedFavorites)
}
is Result.Error -> result
is Result.Loading -> result
}
}
/**
* Toggles favorite status for a movie
* @param movie The movie to toggle
* @return Result containing the new favorite status
*/
suspend fun toggleFavorite(movie: Movie): Result<Boolean> {
return when (val isFavoriteResult = repository.isFavorite(movie.id)) {
is Result.Success -> {
val isFavorite = isFavoriteResult.data
val toggleResult = if (isFavorite) {
repository.removeFromFavorites(movie.id)
} else {
repository.addToFavorites(movie)
}
when (toggleResult) {
is Result.Success -> Result.success(!isFavorite)
is Result.Error -> toggleResult.map { false }
is Result.Loading -> toggleResult.map { false }
}
}
is Result.Error -> isFavoriteResult.map { false }
is Result.Loading -> isFavoriteResult.map { false }
}
}
/**
* Checks if a movie is in favorites
*/
suspend fun isFavorite(movieId: Int): Result<Boolean> {
return repository.isFavorite(movieId)
}
}
Now let’s create ViewModels that can be shared between both Android and iOS. These will manage the UI state and handle user interactions:
First, let’s add the necessary dependencies. Update shared/build.gradle.kts:
// Add this to your shared/build.gradle.kts dependencies
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
}
Create shared/src/commonMain/kotlin/presentation/viewmodel/BaseViewModel.kt:
// shared/src/commonMain/kotlin/presentation/viewmodel/BaseViewModel.kt
package presentation.viewmodel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
/**
* Base class for all ViewModels that provides common functionality
* like coroutine scope management and error handling
*/
abstract class BaseViewModel {
// Create a scope for this ViewModel that will be cancelled when disposed
private val viewModelJob = SupervisorJob()
protected val viewModelScope = CoroutineScope(Dispatchers.Main + viewModelJob)
// Common error handling
private val _errorState = MutableStateFlow<String?>(null)
val errorState: StateFlow<String?> = _errorState.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
/**
* Executes a suspend function safely, handling errors and loading states
*/
protected suspend fun <T> safeExecute(
showLoading: Boolean = true,
onError: ((String) -> Unit)? = null,
action: suspend () -> T
): T? {
return try {
if (showLoading) _isLoading.value = true
_errorState.value = null
action()
} catch (e: Exception) {
val errorMessage = e.message ?: "An unexpected error occurred"
_errorState.value = errorMessage
onError?.invoke(errorMessage)
null
} finally {
if (showLoading) _isLoading.value = false
}
}
/**
* Clears the current error state
*/
fun clearError() {
_errorState.value = null
}
/**
* Dispose of the ViewModel and cancel all coroutines
*/
open fun dispose() {
viewModelJob.cancel()
}
}
Create shared/src/commonMain/kotlin/presentation/viewmodel/MovieListViewModel.kt:
// shared/src/commonMain/kotlin/presentation/viewmodel/MovieListViewModel.kt
package presentation.viewmodel
import domain.model.Movie
import domain.model.Result
import domain.usecase.GetPopularMoviesUseCase
import domain.usecase.SearchMoviesUseCase
import domain.usecase.FavoriteMoviesUseCase
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
/**
* Shared ViewModel for managing movie list state and operations
*/
class MovieListViewModel(
private val getPopularMoviesUseCase: GetPopularMoviesUseCase,
private val searchMoviesUseCase: SearchMoviesUseCase,
private val favoriteMoviesUseCase: FavoriteMoviesUseCase
) : BaseViewModel() {
// UI State
private val _movies = MutableStateFlow<List<Movie>>(emptyList())
val movies: StateFlow<List<Movie>> = _movies.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
private val _selectedTab = MutableStateFlow(MovieTab.POPULAR)
val selectedTab: StateFlow<MovieTab> = _selectedTab.asStateFlow()
// Keep track of favorite status for quick UI updates
private val _favoriteStatus = MutableStateFlow<Map<Int, Boolean>>(emptyMap())
val favoriteStatus: StateFlow<Map<Int, Boolean>> = _favoriteStatus.asStateFlow()
init {
// Load popular movies when ViewModel is created
loadPopularMovies()
// Set up search functionality
setupSearch()
}
/**
* Loads popular movies
*/
fun loadPopularMovies() {
viewModelScope.launch {
safeExecute {
when (val result = getPopularMoviesUseCase()) {
is Result.Success -> {
_movies.value = result.data
updateFavoriteStatus(result.data)
}
is Result.Error -> {
_movies.value = emptyList()
}
is Result.Loading -> {
// Loading state is handled by base class
}
}
}
}
}
/**
* Searches for movies based on query
*/
fun searchMovies(query: String) {
_searchQuery.value = query
if (query.isBlank()) {
loadPopularMovies()
_isSearching.value = false
return
}
_isSearching.value = true
viewModelScope.launch {
safeExecute(showLoading = false) { // Don't show global loading for search
when (val result = searchMoviesUseCase(query)) {
is Result.Success -> {
_movies.value = result.data
updateFavoriteStatus(result.data)
}
is Result.Error -> {
_movies.value = emptyList()
}
is Result.Loading -> {
// Handle if needed
}
}
}
}
}
/**
* Loads favorite movies
*/
fun loadFavoriteMovies() {
viewModelScope.launch {
safeExecute {
when (val result = favoriteMoviesUseCase.getFavorites()) {
is Result.Success -> {
_movies.value = result.data
updateFavoriteStatus(result.data)
}
is Result.Error -> {
_movies.value = emptyList()
}
is Result.Loading -> {
// Loading handled by base class
}
}
}
}
}
/**
* Toggles favorite status for a movie
*/
fun toggleFavorite(movie: Movie) {
viewModelScope.launch {
safeExecute(showLoading = false) {
when (val result = favoriteMoviesUseCase.toggleFavorite(movie)) {
is Result.Success -> {
val newStatus = result.data
_favoriteStatus.value = _favoriteStatus.value.toMutableMap().apply {
this[movie.id] = newStatus
}
// If we're viewing favorites and this movie is no longer a favorite,
// remove it from the current list
if (_selectedTab.value == MovieTab.FAVORITES && !newStatus) {
_movies.value = _movies.value.filter { it.id != movie.id }
}
}
is Result.Error -> {
// Error is handled by base class
}
is Result.Loading -> {
// Handle if needed
}
}
}
}
}
/**
* Changes the selected tab
*/
fun selectTab(tab: MovieTab) {
_selectedTab.value = tab
_searchQuery.value = ""
_isSearching.value = false
when (tab) {
MovieTab.POPULAR -> loadPopularMovies()
MovieTab.FAVORITES -> loadFavoriteMovies()
}
}
/**
* Refreshes the current view
*/
fun refresh() {
when (_selectedTab.value) {
MovieTab.POPULAR -> loadPopularMovies()
MovieTab.FAVORITES -> loadFavoriteMovies()
}
}
/**
* Sets up reactive search functionality
*/
private fun setupSearch() {
// This would typically use a debounce in a real app
// For now, we'll keep it simple
}
/**
* Updates favorite status for a list of movies
*/
private suspend fun updateFavoriteStatus(movies: List<Movie>) {
val statusMap = mutableMapOf<Int, Boolean>()
movies.forEach { movie ->
when (val result = favoriteMoviesUseCase.isFavorite(movie.id)) {
is Result.Success -> statusMap[movie.id] = result.data
else -> statusMap[movie.id] = false
}
}
_favoriteStatus.value = statusMap
}
}
/**
* Represents different tabs/sections in the movie app
*/
enum class MovieTab {
POPULAR,
FAVORITES
}
To tie everything together properly, let’s create a simple dependency injection system:
Create shared/src/commonMain/kotlin/di/AppModule.kt:
// shared/src/commonMain/kotlin/di/AppModule.kt
package di
import data.repository.MovieRepositoryImpl
import domain.repository.MovieRepository
import domain.usecase.FavoriteMoviesUseCase
import domain.usecase.GetPopularMoviesUseCase
import domain.usecase.SearchMoviesUseCase
import presentation.viewmodel.MovieListViewModel
/**
* Simple dependency injection container for the shared module
*/
object AppModule {
// Repository
private val movieRepository: MovieRepository by lazy {
MovieRepositoryImpl()
}
// Use Cases
private val getPopularMoviesUseCase: GetPopularMoviesUseCase by lazy {
GetPopularMoviesUseCase(movieRepository)
}
private val searchMoviesUseCase: SearchMoviesUseCase by lazy {
SearchMoviesUseCase(movieRepository)
}
private val favoriteMoviesUseCase: FavoriteMoviesUseCase by lazy {
FavoriteMoviesUseCase(movieRepository)
}
// ViewModels
fun createMovieListViewModel(): MovieListViewModel {
return MovieListViewModel(
getPopularMoviesUseCase = getPopularMoviesUseCase,
searchMoviesUseCase = searchMoviesUseCase,
favoriteMoviesUseCase = favoriteMoviesUseCase
)
}
}
Let’s add some tests to make sure our business logic works correctly. First, update your shared/build.gradle.kts to include test dependencies:
// Add to your shared/build.gradle.kts
commonTest.dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
Create shared/src/commonTest/kotlin/domain/usecase/GetPopularMoviesUseCaseTest.kt:
// shared/src/commonTest/kotlin/domain/usecase/GetPopularMoviesUseCaseTest.kt
package domain.usecase
import domain.model.Movie
import domain.model.MovieSamples
import domain.model.Result
import domain.repository.MovieRepository
import kotlinx.coroutines.test.runTest
import kotlin.test.*
class GetPopularMoviesUseCaseTest {
private val mockRepository = MockMovieRepository()
private val useCase = GetPopularMoviesUseCase(mockRepository)
@Test
fun `should return movies when repository succeeds`() = runTest {
// Given
val expectedMovies = MovieSamples.sampleMovies
mockRepository.mockResponse = Result.success(expectedMovies)
// When
val result = useCase()
// Then
assertTrue(result.isSuccess)
assertEquals(expectedMovies.size, result.getOrNull()?.size)
}
@Test
fun `should filter adult movies when includeAdult is false`() = runTest {
// Given
val moviesWithAdult = listOf(
MovieSamples.sampleMovies.first(),
MovieSamples.sampleMovies.first().copy(id = 999, isAdult = true)
)
mockRepository.mockResponse = Result.success(moviesWithAdult)
// When
val result = useCase(includeAdult = false)
// Then
assertTrue(result.isSuccess)
val movies = result.getOrNull()!!
assertFalse(movies.any { it.isAdult })
}
@Test
fun `should return error for invalid page number`() = runTest {
// When
val result = useCase(page = -1)
// Then
assertTrue(result.isError)
}
@Test
fun `should sort movies by rating descending`() = runTest {
// Given
val movies = listOf(
Movie(1, "Low Rating", "", "", null, null, 5.0, 100),
Movie(2, "High Rating", "", "", null, null, 9.0, 200),
Movie(3, "Medium Rating", "", "", null, null, 7.0, 150)
)
mockRepository.mockResponse = Result.success(movies)
// When
val result = useCase()
// Then
assertTrue(result.isSuccess)
val sortedMovies = result.getOrNull()!!
assertEquals(9.0, sortedMovies[0].voteAverage)
assertEquals(7.0, sortedMovies[1].voteAverage)
assertEquals(5.0, sortedMovies[2].voteAverage)
}
// Mock repository for testing
private class MockMovieRepository : MovieRepository {
var mockResponse: Result<List<Movie>> = Result.success(emptyList())
override suspend fun getPopularMovies(page: Int): Result<List<Movie>> = mockResponse
override suspend fun searchMovies(query: String, page: Int): Result<List<Movie>> = Result.success(emptyList())
override suspend fun getMovieDetails(movieId: Int): Result<Movie> = Result.error(Exception("Not implemented"))
override suspend fun getFavoriteMovies(): Result<List<Movie>> = Result.success(emptyList())
override suspend fun addToFavorites(movie: Movie): Result<Unit> = Result.success(Unit)
override suspend fun removeFromFavorites(movieId: Int): Result<Unit> = Result.success(Unit)
override suspend fun isFavorite(movieId: Int): Result<Boolean> = Result.success(false)
}
}
Now let’s update our Android and iOS apps to use our new business logic layer.
Update your Android MainActivity.kt to use the shared ViewModel:
// androidApp/src/main/java/com/yourname/moviedatabase/android/MainActivity.kt
package com.yourname.moviedatabase.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import di.AppModule
import domain.model.Movie
import presentation.viewmodel.MovieListViewModel
import presentation.viewmodel.MovieTab
class MainActivity : ComponentActivity() {
private val viewModel = AppModule.createMovieListViewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MovieApp(viewModel = viewModel)
}
}
}
}
override fun onDestroy() {
super.onDestroy()
viewModel.dispose()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieApp(viewModel: MovieListViewModel) {
val movies by viewModel.movies.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val errorState by viewModel.errorState.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
val favoriteStatus by viewModel.favoriteStatus.collectAsStateWithLifecycle()
Column {
// App Bar with search and refresh
TopAppBar(
title = {
Text(when (selectedTab) {
MovieTab.POPULAR -> "Popular Movies"
MovieTab.FAVORITES -> "Favorite Movies"
})
},
actions = {
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
)
// Search Bar
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.searchMovies(it) },
label = { Text("Search movies...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
singleLine = true
)
// Tab Row
TabRow(selectedTabIndex = selectedTab.ordinal) {
Tab(
selected = selectedTab == MovieTab.POPULAR,
onClick = { viewModel.selectTab(MovieTab.POPULAR) },
text = { Text("Popular") }
)
Tab(
selected = selectedTab == MovieTab.FAVORITES,
onClick = { viewModel.selectTab(MovieTab.FAVORITES) },
text = { Text("Favorites") }
)
}
// Error handling
errorState?.let { error ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
TextButton(onClick = { viewModel.clearError() }) {
Text("Dismiss")
}
}
}
}
// Loading indicator
if (isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// Movie list
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(movies, key = { it.id }) { movie ->
MovieCard(
movie = movie,
isFavorite = favoriteStatus[movie.id] ?: false,
onFavoriteClick = { viewModel.toggleFavorite(movie) }
)
}
// Empty state
if (movies.isEmpty() && !isLoading) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = when {
searchQuery.isNotBlank() -> "No movies found for \"$searchQuery\""
selectedTab == MovieTab.FAVORITES -> "No favorite movies yet"
else -> "No movies available"
},
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}
}
@Composable
fun MovieCard(
movie: Movie,
isFavorite: Boolean,
onFavoriteClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = movie.title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onFavoriteClick) {
Icon(
if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = movie.overview,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = movie.getFormattedRating(),
style = MaterialTheme.typography.bodySmall
)
if (movie.isHighlyRated()) {
AssistChip(
onClick = { },
label = { Text("Highly Rated") }
)
}
}
Text(
text = "Released: ${movie.releaseDate}",
style = MaterialTheme.typography.bodySmall
)
}
}
}
For iOS, we need to create a bridge to work with our Kotlin ViewModel. Create iosApp/iosApp/ViewModelBridge.swift:
// iosApp/iosApp/ViewModelBridge.swift
import Foundation
import shared
import Combine
/**
* Bridge class to make Kotlin StateFlow work nicely with SwiftUI
*/
class MovieListViewModelBridge: ObservableObject {
private let viewModel: MovieListViewModel
@Published var movies: [Movie] = []
@Published var isLoading: Bool = false
@Published var errorMessage: String? = nil
@Published var searchQuery: String = ""
@Published var selectedTab: MovieTab = .popular
@Published var favoriteStatus: [Int32: Bool] = [:]
init() {
self.viewModel = AppModule().createMovieListViewModel()
// Observe StateFlow changes
observeMovies()
observeLoadingState()
observeErrorState()
observeSearchQuery()
observeSelectedTab()
observeFavoriteStatus()
}
deinit {
viewModel.dispose()
}
// MARK: - Public Methods
func loadPopularMovies() {
viewModel.loadPopularMovies()
}
func searchMovies(query: String) {
viewModel.searchMovies(query: query)
}
func loadFavoriteMovies() {
viewModel.loadFavoriteMovies()
}
func toggleFavorite(movie: Movie) {
viewModel.toggleFavorite(movie: movie)
}
func selectTab(tab: MovieTab) {
viewModel.selectTab(tab: tab)
}
func refresh() {
viewModel.refresh()
}
func clearError() {
viewModel.clearError()
}
// MARK: - Private Methods
private func observeMovies() {
viewModel.movies.collect { [weak self] movies in
DispatchQueue.main.async {
self?.movies = movies?.compactMap { $0 as? Movie } ?? []
}
}
}
private func observeLoadingState() {
viewModel.isLoading.collect { [weak self] loading in
DispatchQueue.main.async {
self?.isLoading = loading?.boolValue ?? false
}
}
}
private func observeErrorState() {
viewModel.errorState.collect { [weak self] error in
DispatchQueue.main.async {
self?.errorMessage = error
}
}
}
private func observeSearchQuery() {
viewModel.searchQuery.collect { [weak self] query in
DispatchQueue.main.async {
self?.searchQuery = query ?? ""
}
}
}
private func observeSelectedTab() {
viewModel.selectedTab.collect { [weak self] tab in
DispatchQueue.main.async {
self?.selectedTab = tab ?? .popular
}
}
}
private func observeFavoriteStatus() {
viewModel.favoriteStatus.collect { [weak self] status in
DispatchQueue.main.async {
var swiftDict: [Int32: Bool] = [:]
if let kotlinMap = status {
// Convert Kotlin Map to Swift Dictionary
for entry in kotlinMap {
if let key = entry.key as? Int32, let value = entry.value as? Bool {
swiftDict[key] = value
}
}
}
self?.favoriteStatus = swiftDict
}
}
}
}
// Extension to make StateFlow collection work in Swift
extension StateFlow {
func collect(callback: @escaping (Any?) -> Void) {
let collector = StateFlowCollector(callback: callback)
self.collect(collector: collector) { error in
print("StateFlow collection error: \(error)")
}
}
}
class StateFlowCollector: NSObject, FlowCollector {
private let callback: (Any?) -> Void
init(callback: @escaping (Any?) -> Void) {
self.callback = callback
}
func emit(value: Any?) async throws {
callback(value)
}
}
Now update iosApp/iosApp/ContentView.swift:
// iosApp/iosApp/ContentView.swift
import SwiftUI
import shared
struct ContentView: View {
@StateObject private var viewModel = MovieListViewModelBridge()
var body: some View {
NavigationView {
VStack {
// Search Bar
SearchBar(text: $viewModel.searchQuery) { query in
viewModel.searchMovies(query: query)
}
// Tab Selection
Picker("Tab", selection: $viewModel.selectedTab) {
Text("Popular").tag(MovieTab.popular)
Text("Favorites").tag(MovieTab.favorites)
}
.pickerStyle(SegmentedPickerStyle())
.padding(.horizontal)
.onChange(of: viewModel.selectedTab) { tab in
viewModel.selectTab(tab: tab)
}
// Error Display
if let error = viewModel.errorMessage {
ErrorView(message: error) {
viewModel.clearError()
}
}
// Loading Indicator
if viewModel.isLoading {
ProgressView()
.scaleEffect(1.2)
.padding()
}
// Movie List
if viewModel.movies.isEmpty && !viewModel.isLoading {
EmptyStateView(
selectedTab: viewModel.selectedTab,
searchQuery: viewModel.searchQuery
)
} else {
List(viewModel.movies, id: \.id) { movie in
MovieRow(
movie: movie,
isFavorite: viewModel.favoriteStatus[movie.id] ?? false
) {
viewModel.toggleFavorite(movie: movie)
}
}
}
}
.navigationTitle(viewModel.selectedTab == .popular ? "Popular Movies" : "Favorites")
.navigationBarItems(trailing:
Button("Refresh") {
viewModel.refresh()
}
)
}
.onAppear {
viewModel.loadPopularMovies()
}
}
}
struct SearchBar: View {
@Binding var text: String
let onSearchChanged: (String) -> Void
var body: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("Search movies...", text: $text)
.onChange(of: text) { newValue in
onSearchChanged(newValue)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(10)
.padding(.horizontal)
}
}
struct MovieRow: View {
let movie: Movie
let isFavorite: Bool
let onFavoriteToggle: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(movie.title)
.font(.headline)
.fontWeight(.bold)
Spacer()
Button(action: onFavoriteToggle) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.foregroundColor(isFavorite ? .red : .gray)
}
}
Text(movie.overview)
.font(.body)
.lineLimit(3)
HStack {
Text(movie.getFormattedRating())
.font(.caption)
.foregroundColor(.secondary)
Spacer()
if movie.isHighlyRated() {
Text("Highly Rated")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.2))
.cornerRadius(8)
}
}
Text("Released: \(movie.releaseDate)")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
struct ErrorView: View {
let message: String
let onDismiss: () -> Void
var body: some View {
HStack {
Text(message)
.foregroundColor(.red)
.font(.caption)
Spacer()
Button("Dismiss", action: onDismiss)
.font(.caption)
}
.padding()
.background(Color.red.opacity(0.1))
.cornerRadius(8)
.padding(.horizontal)
}
}
struct EmptyStateView: View {
let selectedTab: MovieTab
let searchQuery: String
var body: some View {
VStack {
Image(systemName: selectedTab == .favorites ? "heart" : "film")
.font(.system(size: 50))
.foregroundColor(.gray)
Text(emptyMessage)
.font(.body)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
.padding()
}
private var emptyMessage: String {
if !searchQuery.isEmpty {
return "No movies found for \"\(searchQuery)\""
} else if selectedTab == .favorites {
return "No favorite movies yet"
} else {
return "No movies available"
}
}
}
Congratulations! You’ve just built a robust, production-ready business logic layer for your Kotlin Multiplatform app. Here’s what we’ve created:
Clean Architecture: Separated data, domain, and presentation layers
Repository Pattern: Abstracted data access with clean interfaces
Use Cases: Encapsulated business logic with proper validation
Error Handling: Robust error management across platforms
Shared ViewModels: State management that works on both platforms
Dependency Injection: Proper component organization
Unit Tests: Testable business logic with mock implementations
Your apps now have:
In Part 4: Connecting Your App to an API (Networking in KMP), we’ll:
We’ll transform our app from using sample data to fetching real movie information from a live API!
Before moving to Part 4, try these exercises:
GetMovieDetailsUseCase for getting detailed movie informationruntime, budget, and revenueGreat work! You now have a solid foundation of shared business logic that will serve as the backbone of your movie app. The architecture we’ve built will make adding new features much easier and ensure consistent behavior across platforms.