Mehedi Hassan Piash [Sr. Software Engineer]

November 26, 2023

Country search list with flag in jetpack compose

November 26, 2023 Posted by Piash , No comments

A country search list is a common use case for Android app development. we can develop a country search list with a flag using Android SDK and Jetpack compose. Now it's super easy to implement it using a lazy column. So let’s start step by step.

Step 1. We need a country list with the country flag. we can get it from Locale.

fun getCountries(): ArrayList<String> {
val isoCountryCodes: Array<String> = Locale.getISOCountries()
val countriesWithEmojis: ArrayList<String> = arrayListOf()
for (countryCode in isoCountryCodes) {
val locale = Locale("", countryCode)
val countryName: String = locale.displayCountry.
val flagOffset = 0x1F1E6
val asciiOffset = 0x41
val firstChar = Character.codePointAt(countryCode, 0) - asciiOffset + flagOffset
val secondChar = Character.codePointAt(countryCode, 1) - asciiOffset + flagOffset
val flag =
(String(Character.toChars(firstChar)) + String(Character.toChars(secondChar)))
countriesWithEmojis.add("$countryName $flag")
}
return countriesWithEmojis
}

Step 2. We have our country list with the country flag. Let’s implement it TextField with search functionality.

@Composable
fun CountryList() {
val searchText = rememberSaveable { mutableStateOf("") }
val countries = remember { mutableStateOf(getCountries()) }
var filteredCountries: ArrayList<String>

Column(
Modifier
.fillMaxWidth()
.padding(16.dp)
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = searchText.value,
onValueChange = {
searchText.value = it
},
trailingIcon = {
Icon(
Icons.Default.Clear,
contentDescription = "clear text",
modifier = Modifier
.clickable {
searchText.value = ""
}
)
}
)
LazyColumn(Modifier.padding(top = 8.dp)) {
filteredCountries = if (searchText.value.isEmpty()) {
countries.value
} else {
val resultList = ArrayList<String>()
for (country in countries.value) {
if (country.lowercase(Locale.getDefault())
.contains(searchText.value.lowercase(Locale.getDefault()))
) {
resultList.add(country)
}
}
resultList
}
items(filteredCountries, itemContent = { item ->
Text(modifier = Modifier.padding(top = 6.dp), text = item)
Divider(modifier = Modifier.padding(top = 6.dp))
})
}
}
}

Ref: github link
Ref: medium


October 01, 2023

Expandable Text in Jetpack Compose

October 01, 2023 Posted by Piash , No comments

 Expandable text is a user-friendly feature that allows readers to access additional information without cluttering the screen. It’s particularly useful for presenting detailed descriptions, FAQs, or lengthy content while maintaining a clean and organized UI. This feature not only enhances user experience but also keeps users engaged by giving them control over the content they wish to explore further.

Expandable Text in compose

Let’s get started with creating expandable text in your Compose project:

import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.piashcse.compose_museum.ui.theme.LinkColor
import com.piashcse.compose_museum.ui.theme.SecondaryFontColor
import com.piashcse.compose_museum.ui.theme.Teal200
import com.piashcse.compose_museum.utils.AppConstant
import java.util.regex.Pattern

@Composable
fun ExpandableText(
modifier: Modifier = Modifier,
text: String
)
{
var isExpanded by remember { mutableStateOf(false) }
val textLayoutResultState = remember { mutableStateOf<TextLayoutResult?>(null) }
var isClickable by remember { mutableStateOf(false) }
val textLayoutResult = textLayoutResultState.value

//first we match the html tags and enable the links
val textWithLinks = buildAnnotatedString {
val htmlTagPattern = Pattern.compile(
"(?i)<a([^>]+)>(.+?)</a>",
Pattern.CASE_INSENSITIVE or Pattern.MULTILINE or Pattern.DOTALL
)
val matcher = htmlTagPattern.matcher(text)
var matchStart: Int
var matchEnd = 0
var previousMatchStart = 0

while (matcher.find()) {
matchStart = matcher.start(1)
matchEnd = matcher.end()
val beforeMatch = text.substring(
startIndex = previousMatchStart,
endIndex = matchStart - 2
)
val tagMatch = text.substring(
startIndex = text.indexOf(
char = '>',
startIndex = matchStart
) + 1,
endIndex = text.indexOf(
char = '<',
startIndex = matchStart + 1
),
)
append(
beforeMatch
)
// attach a string annotation that stores a URL to the text
val annotation = text.substring(
startIndex = matchStart + 7,//omit <a hreh =
endIndex = text.indexOf(
char = '"',
startIndex = matchStart + 7,
)
)
pushStringAnnotation(tag = "link_tag", annotation = annotation)
withStyle(
SpanStyle(
color = LinkColor,
textDecoration = TextDecoration.Underline
)
) {
append(
tagMatch
)
}
pop() //don't forget to add this line after a pushStringAnnotation
previousMatchStart = matchEnd
}
//append the rest of the string
if (text.length > matchEnd) {
append(
text.substring(
startIndex = matchEnd,
endIndex = text.length
)
)
}
}
//then we create the Show more/less animation effect
var textWithMoreLess by remember { mutableStateOf(textWithLinks) }
LaunchedEffect(textLayoutResult) {
if (textLayoutResult == null) return@LaunchedEffect

when {
isExpanded -> {
textWithMoreLess = buildAnnotatedString {
append(textWithLinks)
pushStringAnnotation(tag = "show_more_tag", annotation = "")
withStyle(SpanStyle(Teal200)) {
append(" See less")
}
pop()
}
}
!isExpanded && textLayoutResult.hasVisualOverflow -> {//Returns true if either vertical overflow or horizontal overflow happens.
val lastCharIndex = textLayoutResult.getLineEnd(AppConstant.MINIMIZED_MAX_LINES - 1)
val showMoreString = "...See more"
val adjustedText = textWithLinks
.substring(startIndex = 0, endIndex = lastCharIndex)
.dropLast(showMoreString.length)
.dropLastWhile { it == ' ' || it == '.' }

textWithMoreLess = buildAnnotatedString {
append(adjustedText)
pushStringAnnotation(tag = "show_more_tag", annotation = "")
withStyle(SpanStyle(Teal200)) {
append(showMoreString)
}
pop()
}

isClickable = true
//We basically need to assign this here so that the Text is only clickable if the state is not expanded,
// but there is visual overflow. Otherwise, it means that the text given to the composable is not exceeding the max lines.
}
}
}

// UriHandler parse and opens URI inside AnnotatedString Item in Browse
val uriHandler = LocalUriHandler.current

//Composable container
SelectionContainer {
ClickableText(
text = textWithMoreLess,
style = TextStyle(
color = SecondaryFontColor,
fontSize = 15.sp
),
onClick = { offset ->
textWithMoreLess.getStringAnnotations(
tag = "link_tag",
start = offset,
end = offset
).firstOrNull()?.let { stringAnnotation ->
uriHandler.openUri(stringAnnotation.item)
}
if (isClickable) {
textWithMoreLess.getStringAnnotations(
tag = "show_more_tag",
start = offset,
end = offset
).firstOrNull()?.let {
isExpanded = !isExpanded
}
}
},
maxLines = if (isExpanded) Int.MAX_VALUE else AppConstant.MINIMIZED_MAX_LINES,
onTextLayout = { textLayoutResultState.value = it },
modifier = modifier
.animateContentSize()
)
}
}

Ref:
Github: https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/components/ExpandableText.kt

Medium: https://piashcse.medium.com/expandable-text-in-jetpack-compose-c565d30104a1

September 09, 2023

Exit alert in the compose app

September 09, 2023 Posted by Piash , No comments

 Alert dialog in the compose app is a little different from what we used to in our XML code in the Android ecosystem. So let's see how it works in our compose app.

Exit alert in compose app.
  1. Here is the composable component we use for our alert dialog
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.piashcse.hilt_mvvm_compose_movie.R
import com.piashcse.hilt_mvvm_compose_movie.navigation.Screen
import com.piashcse.hilt_mvvm_compose_movie.navigation.currentRoute


@Composable
fun ExitAlertDialog(navController: NavController, cancel: (isOpen: Boolean) -> Unit, ok: () -> Unit) {
val openDialog = remember { mutableStateOf(true) }
if (currentRoute(navController = navController) == Screen.Home.route && openDialog.value) {
AlertDialog(
onDismissRequest = {
},
// below line is use to display title of our dialog
// box and we are setting text color to white.
title = {
Text(
text = stringResource(R.string.close_the_app),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
},
text = {
Text(text = stringResource(R.string.do_you_want_to_exit_the_app), fontSize = 16.sp)
},
confirmButton = {
TextButton(
onClick = {
openDialog.value = false
ok()
}) {
Text(
stringResource(R.string.yes),
fontWeight = FontWeight.Bold,
style = TextStyle(color = Color.Black)
)
}
},
dismissButton = {
TextButton(
onClick = {
openDialog.value = false
cancel(false)
}) {
Text(
stringResource(R.string.no),
fontWeight = FontWeight.Bold,
style = TextStyle(color = Color.Black)
)
}
},
)
}
}

3. res/values/strings.xml

<string name="yes">Yes</string>
<string name="do_you_want_to_exit_the_app">Do you want to exit the app?</string>
<string name="close_the_app">Close the app</string>
<string name="no">No</string>

3. Helper method for understanding its in the current screen or not

@Composable
fun currentRoute(navController: NavController): String? {
val navBackStackEntry by navController.currentBackStackEntryAsState()
return navBackStackEntry?.destination?.route?.substringBeforeLast("/")
}

4. Now see the implementation of the alert and its state management

import android.app.Activity
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.padding
import androidx.compose.material.BottomNavigationItem
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.piashcse.compose_museum.components.ExitAlertDialog
import com.piashcse.compose_museum.navigation.Navigation
import com.piashcse.compose_museum.navigation.Screen
import com.piashcse.compose_museum.navigation.currentRoute

@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun MainScreen() {
val navController = rememberNavController()
val scaffoldState = rememberScaffoldState()
val openDialog = remember { mutableStateOf(false) }
val activity = (LocalContext.current as? Activity)

BackHandler(enabled = (currentRoute(navController) == Screen.Home.route)) {
openDialog.value = true
}
Scaffold(scaffoldState = scaffoldState, bottomBar = {
when (currentRoute(navController)) {
Screen.HomeBottomNavScreen.route, Screen.PopularBottomNavScreen.route, Screen.TopRatedBottomNavScreen.route, Screen.UpComingBottomNavScreen.route -> {
BottomNavigationUI(navController)
}
}
}) {
Navigation(
navController = navController, modifier = Modifier.padding(it), Screen.Home.route
)
if (openDialog.value) {
ExitAlertDialog(navController, {
openDialog.value = it
}, {
activity?.finish()
})

}
}
}

Github: https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/MainScreen.kt

Medium: https://piashcse.medium.com/exit-alert-in-the-compose-app-afdf4ed2f5aa

May 27, 2023

Compose Multiplatform Movie App

May 27, 2023 Posted by Piash , No comments

 What is compose multiplatform: Compose Multiplatform is a declarative framework for sharing UIs across multiple platforms with Kotlin. It is based on Jetpack Compose and developed by JetBrains and open-source contributors.

You can choose the platforms across which to share your UIs using Compose Multiplatform:

Movie App (Home, Movie Detail )

Let’s start making a simple movie app step by step

Step-1:

Download compose starter multiplatform template as shown in figure

Step-2

Rename package name as kmm-movie according to the given project structure

Project structure

Step-3

Add given gradle dependency in Project-Kmm-movie build.gradle.kts

plugins {
kotlin("multiplatform").apply(false)
id("com.android.application").apply(false)
id("com.android.library").apply(false)
id("org.jetbrains.compose").apply(false)
}

Add given gradle dependency to Module:androidApp build.gradle.kts

plugins {
kotlin("multiplatform")
id("com.android.application")
id("org.jetbrains.compose")
}

kotlin {
android()
sourceSets {
val androidMain by getting {
dependencies {
implementation(project(":shared"))
}
}
}
}

android {
compileSdk = (findProperty("android.compileSdk") as String).toInt()
namespace = "com.kmm_movie"

sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")

defaultConfig {
applicationId = "com.kmm_movie.KmmMovie"
minSdk = (findProperty("android.minSdk") as String).toInt()
targetSdk = (findProperty("android.targetSdk") as String).toInt()
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
jvmToolchain(11)
}
}

Add given gradle dependency to Module:Shared build.gradle.kts

plugins {
kotlin("multiplatform")
kotlin("native.cocoapods")
kotlin("plugin.serialization")
id("com.android.library")
id("org.jetbrains.compose")
}

kotlin {
android()
iosX64()
iosArm64()
iosSimulatorArm64()

cocoapods {
version = "1.0.0"
summary = "Some description for the Shared Module"
homepage = "Link to the Shared Module homepage"
ios.deploymentTarget = "14.1"
podfile = project.file("../iosApp/Podfile")
framework {
baseName = "shared"
isStatic = true
}
extraSpecAttributes["resources"] = "['src/commonMain/resources/**', 'src/iosMain/resources/**']"
}

sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.animation)
implementation(compose.material)
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.components.resources)
api(compose.materialIconsExtended)

implementation("io.ktor:ktor-client-core:2.3.0")
implementation("io.ktor:ktor-client-logging:2.3.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.0")
implementation("io.ktor:ktor-client-content-negotiation:2.3.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
api("io.github.qdsfdhvh:image-loader:1.4.4")
api("moe.tlaster:precompose:1.4.1")
api("moe.tlaster:precompose-viewmodel:1.4.1")
}
}
val androidMain by getting {
dependencies {
api("androidx.activity:activity-compose:1.7.2")
api("androidx.appcompat:appcompat:1.6.1")
api("androidx.core:core-ktx:1.10.1")
implementation("io.ktor:ktor-client-okhttp:2.3.0")
}
}
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.0")
implementation("io.ktor:ktor-client-ios:2.3.0")
}
}
}
}

android {
compileSdk = (findProperty("android.compileSdk") as String).toInt()
namespace = "com.kmm_movie.common"

sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
sourceSets["main"].res.srcDirs("src/androidMain/res")
sourceSets["main"].resources.srcDirs("src/commonMain/resources")

defaultConfig {
minSdk = (findProperty("android.minSdk") as String).toInt()
targetSdk = (findProperty("android.targetSdk") as String).toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
}

Add given dependency to Project Properties gradle.properties

#Gradle
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"

#Kotlin
kotlin.code.style=official

#MPP
kotlin.mpp.stability.nowarn=true
kotlin.mpp.enableCInteropCommonization=true
kotlin.mpp.androidSourceSetLayoutVersion=2

#Compose
org.jetbrains.compose.experimental.uikit.enabled=true
kotlin.native.cacheKind=none

#Android
android.useAndroidX=true
android.compileSdk=33
android.targetSdk=33
android.minSdk=24

#Versions
kotlin.version=1.8.20
agp.version=7.4.2
compose.version=1.4.0

Add given dependency to settings.gradle.kts

rootProject.name = "Kmm-movie"

include(":androidApp")
include(":shared")

pluginManagement {
repositories {
gradlePluginPortal()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
google()
}

plugins {
val kotlinVersion = extra["kotlin.version"] as String
val agpVersion = extra["agp.version"] as String
val composeVersion = extra["compose.version"] as String

kotlin("jvm").version(kotlinVersion)
kotlin("multiplatform").version(kotlinVersion)
kotlin("android").version(kotlinVersion)

id("com.android.application").version(agpVersion)
id("com.android.library").version(agpVersion)

id("org.jetbrains.compose").version(composeVersion)
kotlin("plugin.serialization").version(kotlinVersion)
}
}

dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}

Step-4

Define ktor API client for HTTP api request inside shared → commonMain →kotlin → data → remote apiClient.kt

package data.remote

import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.takeFrom
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import utils.AppConstant

val client = HttpClient {
defaultRequest {
url {
takeFrom(AppConstant.BASE_URL)
parameters.append("api_key", AppConstant.API_KEY)
}
}
expectSuccess = true
install(HttpTimeout) {
val timeout = 30000L
connectTimeoutMillis = timeout
requestTimeoutMillis = timeout
socketTimeoutMillis = timeout
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.HEADERS
}
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
}

Define API interface for api request inside shared → commonMain →kotlin → data → remote → ApiInterface.kt

package data.remote

import data.model.BaseModel
import data.model.BaseModelV2
import data.model.moviedetail.MovieDetail

interface ApiInterface{
suspend fun nowPlayingMovieList(
page: Int
)
: BaseModel

suspend fun popularMovieList(
page: Int
)
: BaseModelV2

suspend fun topRatedMovieList(
page: Int
)
: BaseModelV2

suspend fun upcomingMovieList(
page: Int
)
: BaseModel

suspend fun movieDetail(
movieId: Int
)
: MovieDetail

suspend fun movieSearch(
searchKey: String
)
: BaseModelV2
}

Add API Implementation inside shared → commonMain → kotlin → data → remote → apiImpl.kt

package data.remote

import data.model.BaseModel
import data.model.BaseModelV2
import data.model.moviedetail.MovieDetail
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.http.encodedPath

class ApiImpl : ApiInterface {
private fun HttpRequestBuilder.nowPlayingMovie(
page: Int
)
{
url {
encodedPath = "3/movie/now_playing"
parameters.append("page", page.toString())
}
}

private fun HttpRequestBuilder.popularMovie(
page: Int
)
{
url {
encodedPath = "3/movie/popular"
parameters.append("page", page.toString())
}
}

private fun HttpRequestBuilder.topRatedMovie(
page: Int
)
{
url {
encodedPath = "3/movie/top_rated"
parameters.append("page", page.toString())
}
}

private fun HttpRequestBuilder.upcomingMovie(
page: Int,
)
{
url {
encodedPath = "3/movie/upcoming"
parameters.append("page", page.toString())
}
}

private fun HttpRequestBuilder.movieDetail(
movieId: Int,
)
{
url {
encodedPath = "3/movie/$movieId"
}
}

private fun HttpRequestBuilder.movieSearch(
searchKey: String,
)
{
url {
encodedPath = "3/search/movie"
parameters.append("query", searchKey)
}
}

override suspend fun nowPlayingMovieList(
page: Int,
)
: BaseModel {
return client.get {
nowPlayingMovie(page)
}.body()
}


override suspend fun popularMovieList(
page: Int,
)
: BaseModelV2 {
return client.get {
popularMovie(page)
}.body()
}


override suspend fun topRatedMovieList(
page: Int,
)
: BaseModelV2 {
return client.get {
topRatedMovie(page)
}.body()
}


override suspend fun upcomingMovieList(
page: Int,
)
: BaseModel {
return client.get {
upcomingMovie(page)
}.body()
}

override suspend fun movieDetail(movieId: Int): MovieDetail {
return client.get {
movieDetail(movieId)
}.body()
}

override suspend fun movieSearch(searchKey: String): BaseModelV2 {
return client.get {
movieSearch(searchKey)
}.body()
}

}

Add repository for api implementation inside shared → commonMain →kotlin → data → repository → movieRepository.kt

package data.repository

import data.remote.ApiImpl
import kotlinx.coroutines.flow.flow
import utils.network.DataState

class MovieRepository {
private val api = ApiImpl()
fun nowPlayingMovie(page: Int) = flow {
emit(DataState.Loading)
try {
val result = api.nowPlayingMovieList(page)
emit(DataState.Success(result.results))
} catch (e: Exception) {
emit(DataState.Error(e))
}
}

fun popularMovie(page: Int) = flow {
emit(DataState.Loading)
try {
val result = api.popularMovieList(page)
emit(DataState.Success(result.results))
} catch (e: Exception) {
emit(DataState.Error(e))
}
}

fun topRatedMovie(page: Int) = flow {
emit(DataState.Loading)
try {
val result = api.topRatedMovieList(page)
emit(DataState.Success(result.results))
} catch (e: Exception) {
emit(DataState.Error(e))
}
}

fun upComingMovie(page: Int) = flow {
emit(DataState.Loading)
try {
val result = api.upcomingMovieList(page)
emit(DataState.Success(result.results))
} catch (e: Exception) {
emit(DataState.Error(e))
}
}

fun movieDetail(movieId: Int) = flow {
emit(DataState.Loading)
try {
val result = api.movieDetail(movieId)
emit(DataState.Success(result))
} catch (e: Exception) {
emit(DataState.Error(e))
}
}

fun searchMovie(searchKey: String) = flow {
emit(DataState.Loading)
try {
val result = api.movieSearch(searchKey)
emit(DataState.Success(result))
} catch (e: Exception) {
emit(DataState.Error(e))
}
}
}

Step-5

Let’s define a navigation graph for screen navigation inside shared → commonMain → kotlin → navigation → NavGraph.kt

package navigation

import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import moe.tlaster.precompose.navigation.NavHost
import moe.tlaster.precompose.navigation.Navigator
import moe.tlaster.precompose.navigation.path
import ui.popular.Popular
import ui.detail.MovieDetail
import ui.home.HomeScreen
import ui.toprated.TopRated
import ui.upcoming.Upcoming

@Composable
fun Navigation(navigator: Navigator) {
NavHost(
navigator = navigator,
initialRoute = NavigationScreen.Home.route,
) {
scene(route = NavigationScreen.Home.route) {
HomeScreen(navigator)
}
scene(route = NavigationScreen.Popular.route) {
Popular(navigator)
}
scene(route = NavigationScreen.TopRated.route) {
TopRated(navigator)
}
scene(route = NavigationScreen.Upcoming.route) {
Upcoming(navigator)
}
scene(route = NavigationScreen.MovieDetail.route.plus(NavigationScreen.MovieDetail.objectPath)) { backStackEntry ->
val id: Int? = backStackEntry.path<Int>(NavigationScreen.MovieDetail.objectName)
id?.let {
MovieDetail(navigator, it)
}
}
}
}

@Composable
fun currentRoute(navigator: Navigator): String? {
return navigator.currentEntry.collectAsState(null).value?.route?.route

}

Define navigation and bottomNavigation for each screen inside shared → commonMain → kotlin → navigation → NavigationScreen.kt

package navigation

import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import utils.AppString

sealed class NavigationScreen(
val route: String,
val title: String = AppString.APP_TITLE,
val navIcon: (@Composable () -> Unit) = {
Icon(
Icons.Filled.Home, contentDescription = "home"
)
},
val objectName: String = "",
val objectPath: String = ""
) {
object Home : NavigationScreen("home_screen")
object Popular : NavigationScreen("popular_screen")
object TopRated : NavigationScreen("top_rated_screen")
object Upcoming : NavigationScreen("upcoming_screen")
object MovieDetail :
NavigationScreen("movie_detail_screen", objectName = "id", objectPath = "/{id}")

object HomeNav : NavigationScreen("home_screen", title = "Home", navIcon = {
Icon(
Icons.Filled.Home,
contentDescription = "search",
modifier = Modifier
.padding(end = 16.dp)
.offset(x = 10.dp)
)
})

object PopularNav : NavigationScreen("popular_screen", title = "Popular", navIcon = {
Icon(
Icons.Filled.Timeline,
contentDescription = "search",
modifier = Modifier
.padding(end = 16.dp)
.offset(x = 10.dp)
)
})

object TopRatedNav : NavigationScreen("top_rated_screen", title = "Top rated", navIcon = {
Icon(
Icons.Filled.Star,
contentDescription = "search",
modifier = Modifier
.padding(end = 16.dp)
.offset(x = 10.dp)
)
})

object UpcomingNav : NavigationScreen("upcoming_screen", title = "Upcoming", navIcon = {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = "search",
modifier = Modifier
.padding(end = 16.dp)
.offset(x = 10.dp)
)
})
}

Step-6

Lets start with UI component inside shared → commonMain → kotlin → ui → component → text → BiograpyText.kt

package ui.component.text

import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import theme.bioGrapyText


@Composable
fun BioGraphyText(text:String) {
Text(
text = text,
style = MaterialTheme.typography.bioGrapyText
)
}

shared → commonMain → kotlin → ui → component → text → SubTitlePrimary.kt

package ui.component.text

import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import theme.subTitlePrimary

@Composable
fun SubtitlePrimary(text: String) {
Text(
text = text,
style = MaterialTheme.typography.subTitlePrimary
)
}

shared → commonMain → kotlin → ui → component → text → SubTitle.kt

package ui.component.text

import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import theme.subTitleSecondary

@Composable
fun SubtitleSecondary(text:String) {
Text(
text = text,
style = MaterialTheme.typography.subTitleSecondary
)
}

shared → commonMain → kotlin → ui → component → AppBarWithArrow.kt

package ui.component

import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.unit.dp
import theme.Purple500

@Composable
fun AppBarWithArrow(
title: String?,
isBackEnable: Boolean = false,
pressOnBack: () -> Unit
)
{
TopAppBar(
elevation = 6.dp,
backgroundColor = Purple500,
modifier = Modifier.height(58.dp)
) {
Row {
Spacer(modifier = Modifier.width(10.dp))
if (isBackEnable) {
Image(
imageVector = Icons.Filled.ArrowBack,
colorFilter = ColorFilter.tint(Color.White),
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterVertically)
.clickable {
pressOnBack()
}
)
}

Spacer(modifier = Modifier.width(12.dp))

Text(
modifier = Modifier
.padding(8.dp)
.align(Alignment.CenterVertically),
text = title ?: "",
style = MaterialTheme.typography.h6,
color = Color.White
)
}
}
}

shared → commonMain → kotlin → ui → component → MovieList.kt

package ui.component

import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.seiko.imageloader.rememberAsyncImagePainter
import data.model.MovieItem
import utils.AppConstant
import utils.cornerRadius

@Composable
internal fun MovieList(listItems: List<MovieItem>, onclick: (id: Int) -> Unit) {
LazyVerticalGrid(columns = GridCells.Fixed(2),
modifier = Modifier.padding(start = 5.dp, end = 5.dp, top = 10.dp),
content = {
items(listItems) {
Column(
modifier = Modifier.padding(
start = 5.dp, end = 5.dp, top = 0.dp, bottom = 10.dp
)
) {
Image(
painter = rememberAsyncImagePainter(
AppConstant.IMAGE_URL.plus(
it.poster_path
)
),
contentDescription = it.poster_path,
modifier = Modifier.size(250.dp).cornerRadius(10).shimmerBackground(
RoundedCornerShape(5.dp)
).clickable {
onclick(it.id)
},
contentScale = ContentScale.Crop,
)
}
}
})
}

shared → commonMain → kotlin → ui → component → ProgressIndicator.kt

package ui.component

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

@Composable
internal fun ProgressIndicator(isVisible: Boolean = true) {
if (isVisible) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}

shared → commonMain → kotlin → ui → component → SearchBar.kt

package ui.component

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.ExperimentalCoroutinesApi
import theme.Blue
import ui.AppViewModel

@ExperimentalCoroutinesApi
@Composable
fun SearchBar(viewModel: AppViewModel, pressOnBack: () -> Unit) {
var text by remember { mutableStateOf("") }
val focusRequester = FocusRequester()
Row(Modifier.background(color = Blue)) {
Spacer(modifier = Modifier.width(10.dp))
Image(
imageVector = Icons.Filled.ArrowBack,
colorFilter = ColorFilter.tint(Color.White),
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterVertically)
.clickable {
pressOnBack()
}
)
Spacer(modifier = Modifier.width(5.dp))
TextField(modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
value = text,
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Blue,
cursorColor = Color.Black,
disabledLabelColor = Blue,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
onValueChange = {
text = it
viewModel.searchApi(it)
},
//shape = RoundedCornerShape(8.dp),
singleLine = true,
trailingIcon = {
if (text.trim().isNotEmpty()) {
Icon(Icons.Filled.Clear,
contentDescription = "clear text",
modifier = Modifier.padding(end = 16.dp).offset(x = 10.dp).clickable {
text = ""
})
} else {
Icon(Icons.Filled.Search,
contentDescription = "search",
modifier = Modifier.padding(end = 16.dp).offset(x = 10.dp).clickable {

})
}
})
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}

}

shared → commonMain → kotlin → ui → component → SearchUI.kt

package ui.component

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.seiko.imageloader.rememberAsyncImagePainter
import data.model.BaseModelV2
import moe.tlaster.precompose.navigation.Navigator
import navigation.NavigationScreen
import theme.DefaultBackgroundColor
import theme.FontColor
import theme.SecondaryFontColor
import utils.AppConstant
import utils.AppString
import utils.cornerRadius
import utils.network.DataState
import utils.roundTo

@Composable
fun SearchUI(
navController: Navigator,
searchData: MutableState<DataState<BaseModelV2>?>,
itemClick: () -> Unit
)
{
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(0.dp, 350.dp) // define max height
.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp))
.background(color = DefaultBackgroundColor)
.padding(top = 8.dp)

) {
searchData.value?.let {
if (it is DataState.Success<BaseModelV2>) {
items(items = it.data.results, itemContent = { item ->
Row(modifier = Modifier
.padding(bottom = 8.dp, start = 8.dp, end = 8.dp)
.clickable {
itemClick.invoke()
navController.navigate(
NavigationScreen.MovieDetail.route.plus(
"/${item.id}"
)
)
}) {
Image(
painter = rememberAsyncImagePainter(
AppConstant.IMAGE_URL.plus(
item.backdrop_path
)
),
contentDescription = item.backdrop_path,
modifier = Modifier
.height(100.dp)
.width(80.dp).cornerRadius(8).shimmerBackground(RoundedCornerShape(5.dp)),
contentScale = ContentScale.Crop,
)
Column {
Text(
text = item.title,
modifier = Modifier.padding(
start = 8.dp,
top = 4.dp
),
fontWeight = FontWeight.SemiBold
)
Text(
text = item.release_date,
color = FontColor,
fontSize = 16.sp,
modifier = Modifier.padding(start = 8.dp)
)
Text(
text = "${AppString.RATING_SEARCH} ${
item.vote_average.roundTo(
1
)
}
"
,
color = SecondaryFontColor,
fontSize = 12.sp,
modifier = Modifier.padding(start = 8.dp)
)
}
}
})
}
}
}
}

shared → commonMain → kotlin → ui → component → ShimmerBackground.kt

package ui.component

import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TileMode

fun Modifier.shimmerBackground(shape: Shape = RectangleShape): Modifier = composed {
val transition = rememberInfiniteTransition()
val translateAnimation by transition.animateFloat(
initialValue = 0f,
targetValue = 400f,
animationSpec = infiniteRepeatable(
tween(durationMillis = 1500, easing = LinearOutSlowInEasing),
RepeatMode.Restart
),
)
val shimmerColors = listOf(
Color.LightGray.copy(alpha = 0.9f),
Color.LightGray.copy(alpha = 0.4f),
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnimation, translateAnimation),
end = Offset(translateAnimation + 100f, translateAnimation + 100f),
tileMode = TileMode.Mirror,
)
return@composed this.then(background(brush, shape))
}

Step-7

Now define all screens for bottom navigation as well as movie detail

shared → commonMain → kotlin → ui → home → HomeScreen.kt

package ui.home

import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import data.model.MovieItem
import moe.tlaster.precompose.navigation.Navigator
import navigation.NavigationScreen
import ui.component.MovieList
import ui.component.ProgressIndicator
import utils.AppString
import utils.network.DataState

@Composable
fun HomeScreen(
navigator: Navigator,
viewModel: NowPlayingViewModel = NowPlayingViewModel()

) {
LaunchedEffect(true) {
viewModel.nowPlayingView(1)
}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
viewModel.nowPlayingResponse.collectAsState().value?.let {
when (it) {
is DataState.Loading -> {
ProgressIndicator()
}

is DataState.Success<List<MovieItem>> -> {
MovieList(it.data) { movieId ->
navigator.navigate(NavigationScreen.MovieDetail.route.plus("/$movieId"))
}
}

is DataState.Error -> {
Text("${AppString.ERROR_TEXT} ${it.exception}")
}
}
}
}
}

shared → commonMain → kotlin → ui → home → NowPlayingViewModel.kt

package ui.home

import data.model.MovieItem
import data.repository.MovieRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import moe.tlaster.precompose.viewmodel.ViewModel
import utils.network.DataState

class NowPlayingViewModel : ViewModel() {
private val viewModelScope = CoroutineScope(Dispatchers.Main)
private val repo = MovieRepository()
val nowPlayingResponse = MutableStateFlow<DataState<List<MovieItem>>?>(DataState.Loading)

fun nowPlayingView(page: Int) {
viewModelScope.launch(Dispatchers.Main) {
repo.nowPlayingMovie(page).collectLatest {
nowPlayingResponse.value = it
}
}
}
}

shared → commonMain → kotlin → ui → popular → Popular.kt

package ui.popular

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import data.model.MovieItem
import moe.tlaster.precompose.navigation.Navigator
import navigation.NavigationScreen
import ui.component.MovieList
import ui.component.ProgressIndicator
import utils.AppString
import utils.network.DataState

@Composable
fun Popular(navigator: Navigator, viewModel: PopularViewModel = PopularViewModel()) {
LaunchedEffect(true) {
viewModel.nowPlayingView(1)
}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
viewModel.popularMovieResponse.collectAsState().value?.let {
when (it) {
is DataState.Loading -> {
ProgressIndicator()
}

is DataState.Success<List<MovieItem>> -> {
MovieList(it.data) { movieId ->
navigator.navigate(NavigationScreen.MovieDetail.route.plus("/$movieId"))
}
}
is DataState.Error ->{
Text("${AppString.ERROR_TEXT} ${it.exception}")
}
}
}
}
}

shared → commonMain → kotlin → ui → popular → PopularViewModel.kt

package ui.popular

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import data.model.MovieItem
import moe.tlaster.precompose.navigation.Navigator
import navigation.NavigationScreen
import ui.component.MovieList
import ui.component.ProgressIndicator
import utils.AppString
import utils.network.DataState

@Composable
fun Popular(navigator: Navigator, viewModel: PopularViewModel = PopularViewModel()) {
LaunchedEffect(true) {
viewModel.nowPlayingView(1)
}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
viewModel.popularMovieResponse.collectAsState().value?.let {
when (it) {
is DataState.Loading -> {
ProgressIndicator()
}

is DataState.Success<List<MovieItem>> -> {
MovieList(it.data) { movieId ->
navigator.navigate(NavigationScreen.MovieDetail.route.plus("/$movieId"))
}
}
is DataState.Error ->{
Text("${AppString.ERROR_TEXT} ${it.exception}")
}
}
}
}
}

shared → commonMain → kotlin → ui → toprated → TopRated.kt

package ui.toprated

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import data.model.MovieItem
import moe.tlaster.precompose.navigation.Navigator
import navigation.NavigationScreen
import ui.component.MovieList
import ui.component.ProgressIndicator
import utils.AppString
import utils.network.DataState

@Composable
fun TopRated(navigator: Navigator, viewModel: TopRatedViewModel = TopRatedViewModel()) {
LaunchedEffect(true) {
viewModel.nowPlayingView(1)
}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
viewModel.topRatedMovieResponse.collectAsState().value?.let {
when (it) {
is DataState.Loading -> {
ProgressIndicator()
}

is DataState.Success<List<MovieItem>> -> {
MovieList(it.data) { movieId ->
navigator.navigate(NavigationScreen.MovieDetail.route.plus("/$movieId"))
}
}

is DataState.Error -> {
Text("${AppString.ERROR_TEXT} ${it.exception}")
}

}
}
}
}

shared → commonMain → kotlin → ui → toprated → TopRatedViewModel.kt

package ui.toprated

import data.model.MovieItem
import data.repository.MovieRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import moe.tlaster.precompose.viewmodel.ViewModel
import utils.network.DataState

class TopRatedViewModel : ViewModel() {
private val viewModelScope = CoroutineScope(Dispatchers.Main)
private val repo = MovieRepository()
val topRatedMovieResponse = MutableStateFlow<DataState<List<MovieItem>>?>(DataState.Loading)

fun nowPlayingView(page: Int) {
viewModelScope.launch(Dispatchers.Main) {
repo.topRatedMovie(page).collectLatest {
topRatedMovieResponse.value = it
}
}
}
}

shared → commonMain → kotlin → ui → upcoming → Upcoming.kt

package ui.upcoming

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import data.model.MovieItem
import moe.tlaster.precompose.navigation.Navigator
import navigation.NavigationScreen
import ui.component.MovieList
import ui.component.ProgressIndicator
import utils.AppString
import utils.network.DataState

@Composable
fun Upcoming(navigator: Navigator, viewModel: UpcomingViewModel = UpcomingViewModel()) {
LaunchedEffect(true) {
viewModel.nowPlayingView(1)
}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
viewModel.upComingMovieResponse.collectAsState().value?.let {
when (it) {
is DataState.Loading -> {
ProgressIndicator()
}

is DataState.Success<List<MovieItem>> -> {
MovieList(it.data) { movieId ->
navigator.navigate(NavigationScreen.MovieDetail.route.plus("/$movieId"))
}
}

is DataState.Error -> {
Text("${AppString.ERROR_TEXT} ${it.exception}")
}
}
}
}
}

shared → commonMain → kotlin → ui → upcoming → UpcomingViewModel.kt

package ui.upcoming

import data.model.MovieItem
import data.repository.MovieRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import moe.tlaster.precompose.viewmodel.ViewModel
import utils.network.DataState

class UpcomingViewModel : ViewModel() {
private val viewModelScope = CoroutineScope(Dispatchers.Main)
private val repo = MovieRepository()
val upComingMovieResponse = MutableStateFlow<DataState<List<MovieItem>>?>(DataState.Loading)

fun nowPlayingView(page: Int) {
viewModelScope.launch(Dispatchers.Main) {
repo.upComingMovie(page).collectLatest {
upComingMovieResponse.value = it
}
}
}
}

shared → commonMain → kotlin → ui → detail → MovieDetail.kt

package ui.detail

import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ui.component.text.SubtitlePrimary
import ui.component.text.SubtitleSecondary
import com.seiko.imageloader.rememberAsyncImagePainter
import data.model.moviedetail.MovieDetail
import moe.tlaster.precompose.navigation.Navigator
import theme.DefaultBackgroundColor
import theme.FontColor
import ui.component.ProgressIndicator
import ui.component.shimmerBackground
import utils.AppConstant
import utils.AppString
import utils.hourMinutes
import utils.network.DataState
import utils.roundTo

@Composable
fun MovieDetail(
navigator: Navigator,
movieId: Int,
movieDetailViewModel: MovieDetailViewModel = MovieDetailViewModel()

) {
LaunchedEffect(true) {
movieDetailViewModel.movieDetail(movieId)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(
DefaultBackgroundColor
)
) {
movieDetailViewModel.movieDetail.collectAsState().value.let {
when (it) {
is DataState.Loading -> {
ProgressIndicator()
}

is DataState.Success<MovieDetail> -> {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Image(
painter = rememberAsyncImagePainter(
AppConstant.IMAGE_URL.plus(
it.data.poster_path
)
),
contentDescription = it.data.poster_path,
modifier = Modifier
.fillMaxWidth()
.height(300.dp).shimmerBackground(
RoundedCornerShape(5.dp)
),
contentScale = ContentScale.Crop,
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 10.dp, end = 10.dp)
) {
Text(
text = it.data.title,
modifier = Modifier.padding(top = 10.dp),
color = FontColor,
fontSize = 30.sp,
fontWeight = FontWeight.W700,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp, top = 10.dp)
) {

Column(Modifier.weight(1f)) {
SubtitlePrimary(
text = it.data.original_language,
)
SubtitleSecondary(
text = AppString.LANGUAGE
)
}
Column(Modifier.weight(1f)) {
SubtitlePrimary(
text = it.data.vote_average.roundTo(1).toString(),
)
SubtitleSecondary(
text = AppString.RATING
)
}
Column(Modifier.weight(1f)) {
SubtitlePrimary(
text = it.data.runtime.hourMinutes()
)
SubtitleSecondary(
text = AppString.DURATION
)
}
Column(Modifier.weight(1f)) {
SubtitlePrimary(
text = it.data.release_date
)
SubtitleSecondary(
text = AppString.RELEASE_DATE
)
}
}
Text(
text = AppString.DESCRIPTION,
color = FontColor,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)
Text(text = it.data.overview)
}
}
}

is DataState.Error -> {
Text("Error :${it.exception}")
}

else -> {

}
}
}
}
}

shared → commonMain → kotlin → ui → detail → MovieDetailViewModel.kt

package ui.detail

import data.model.moviedetail.MovieDetail
import data.repository.MovieRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import moe.tlaster.precompose.viewmodel.ViewModel
import utils.network.DataState

class MovieDetailViewModel : ViewModel() {
private val viewModelScope = CoroutineScope(Dispatchers.Main)
private val repo = MovieRepository()
val movieDetail = MutableStateFlow<DataState<MovieDetail>?>(DataState.Loading)

fun movieDetail(movieId: Int) {
viewModelScope.launch(Dispatchers.Main) {
repo.movieDetail(movieId).collectLatest {
movieDetail.value = it
}
}
}
}

shared → commonMain → kotlin → ui → AppViewModel.kt

package ui

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import data.model.BaseModelV2
import data.repository.MovieRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import moe.tlaster.precompose.viewmodel.ViewModel
import utils.network.DataState

@ExperimentalCoroutinesApi
class AppViewModel: ViewModel() {
private val viewModelScope = CoroutineScope(Dispatchers.Main)
private val repo = MovieRepository()
val searchData: MutableState<DataState<BaseModelV2>?> = mutableStateOf(null)
@ExperimentalCoroutinesApi
@FlowPreview
fun searchApi(searchKey: String) {
viewModelScope.launch {
flowOf(searchKey).debounce(300)
.filter {
it.trim().isEmpty().not()
}
.distinctUntilChanged()
.flatMapLatest {
repo.searchMovie(it)
}.collect {
if (it is DataState.Success){
it.data
}
searchData.value = it
}
}
}
}

Step-8

Now define the main component of the app

shared → commonMain → kotlin → App.kt

import androidx.compose.foundation.layout.Column
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.ExperimentalCoroutinesApi
import moe.tlaster.precompose.navigation.BackHandler
import moe.tlaster.precompose.navigation.NavOptions
import moe.tlaster.precompose.navigation.Navigator
import moe.tlaster.precompose.navigation.rememberNavigator
import navigation.Navigation
import navigation.NavigationScreen
import navigation.currentRoute
import theme.FloatingActionBackground
import ui.AppViewModel
import ui.component.AppBarWithArrow
import ui.component.ProgressIndicator
import ui.component.SearchBar
import ui.component.SearchUI
import utils.AppString
import utils.pagingLoadingState

@OptIn(ExperimentalCoroutinesApi::class)
@Composable
internal fun App(viewModel: AppViewModel = AppViewModel()) {
val navigator = rememberNavigator()
val isAppBarVisible = remember { mutableStateOf(true) }
val searchProgressBar = remember { mutableStateOf(false) }

BackHandler(isAppBarVisible.value.not()) {
isAppBarVisible.value = true
}

MaterialTheme {
Scaffold(topBar = {
if (isAppBarVisible.value.not()) {
SearchBar(viewModel) {
isAppBarVisible.value = true
}

} else {
AppBarWithArrow(
AppString.APP_TITLE, isBackEnable = isBackButtonEnable(navigator)
) {
navigator.goBack()
}
}
}, floatingActionButton = {
when (currentRoute(navigator)) {
NavigationScreen.Home.route, NavigationScreen.Popular.route, NavigationScreen.TopRated.route, NavigationScreen.Upcoming.route -> {
FloatingActionButton(
onClick = {
isAppBarVisible.value = false
}, backgroundColor = FloatingActionBackground
) {
Icon(Icons.Filled.Search, "", tint = Color.White)
}
}
}
}, bottomBar = {
when (currentRoute(navigator)) {
NavigationScreen.Home.route, NavigationScreen.Popular.route, NavigationScreen.TopRated.route, NavigationScreen.Upcoming.route -> {
BottomNavigationUI(navigator)
}
}
}) {
Navigation(navigator)
if (currentRoute(navigator) !== NavigationScreen.MovieDetail.route) {
Column {
if (isAppBarVisible.value.not()) {
SearchUI(navigator, viewModel.searchData) {
isAppBarVisible.value = true
}
ProgressIndicator(searchProgressBar.value)
}
viewModel.searchData.pagingLoadingState {
searchProgressBar.value = it
}
}
}
}
}
}

@Composable
fun BottomNavigationUI(navigator: Navigator) {
BottomNavigation {
val items = listOf(
NavigationScreen.HomeNav,
NavigationScreen.PopularNav,
NavigationScreen.TopRatedNav,
NavigationScreen.UpcomingNav,
)
items.forEach {
BottomNavigationItem(label = { Text(text = it.title) },
selected = it.route == currentRoute(navigator),
icon = it.navIcon,
onClick = {
navigator.navigate(
it.route,
NavOptions(
launchSingleTop = true,
),
)
})
}
}
}

@Composable
fun isBackButtonEnable(navigator: Navigator): Boolean {
return when (currentRoute(navigator)) {
NavigationScreen.Home.route, NavigationScreen.Popular.route, NavigationScreen.TopRated.route, NavigationScreen.Upcoming.route -> {
false
}

else -> {
true
}
}
}

Step-9:

Let’s define utility functions in utils

shared → commonMain → kotlin → utils → network → DataState.kt

package utils.network

/**
* Data state for processing api response Loading, Success and Error
*/

sealed class DataState<out R> {
data class Success<out T>(val data: T) : DataState<T>()
data class Error(val exception: Exception) : DataState<Nothing>()
object Loading : DataState<Nothing>()
}

shared → commonMain → kotlin → utils → AppConstant.kt

package utils

object AppConstant {
const val API_KEY = "59cd6896d8432f9c69aed9b86b9c2931"
const val BASE_URL = "https://api.themoviedb.org/"
const val IMAGE_URL = "https://image.tmdb.org/t/p/w342"
}

shared → commonMain → kotlin → utils → AppString.kt

package utils

object AppString {
const val APP_TITLE = "Movie World"
const val LANGUAGE = "Language"
const val RATING = "Rating"
const val DURATION = "Duration"
const val RELEASE_DATE = "Release Date"
const val DESCRIPTION = "Description"
const val RATING_SEARCH = "Rating :"
const val ERROR_TEXT = "Error :"
}

shared → commonMain → kotlin → utils → CommonExtension.kt

package utils

import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.minutes

fun Int.hourMinutes(): String {
return "${this.minutes.inWholeHours}h ${this % 60}m"
}

fun Int.genderInString(): String {
return when (this) {
1 -> "Female"
2 -> "Male"
else -> ""
}
}

fun Double.roundTo(numFractionDigits: Int): Double {
val factor = 10.0.pow(numFractionDigits.toDouble())
return (this * factor).roundToInt() / factor
}

shared → commonMain → kotlin → utils → UIExtension.kt

package utils

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import utils.network.DataState

fun Modifier.cornerRadius(radius: Int) =
graphicsLayer(shape = RoundedCornerShape(radius.dp), clip = true)

fun <T : Any> MutableState<DataState<T>?>.pagingLoadingState(isLoaded: (pagingState: Boolean) -> Unit) {
when (this.value) {
is DataState.Success<T> -> {
isLoaded(false)
}

is DataState.Loading -> {
isLoaded(true)
}

is DataState.Error -> {
isLoaded(false)
}

else -> {
isLoaded(false)
}
}
}

Step-10

Let’s add theme for the app.

shared → commonMain → kotlin → theme → Color.kt

package theme

import androidx.compose.ui.graphics.Color

val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val FontColor = Color(0xFF212121)
val SecondaryFontColor = Color(0xFF757575)
val DefaultBackgroundColor = Color(0xFFFAFAFA)
val Blue = Color(0xff76a9ff)
val FloatingActionBackground = Color(0xffFBC02D)
val LinkColor = Color(0xff64B5F6)

shared → commonMain → kotlin → theme → Shape.kt

package theme

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp

val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)

fun Modifier.cornerRadius(radius: Int) =
graphicsLayer(shape = RoundedCornerShape(radius.dp), clip = true)

shared → commonMain → kotlin → theme → Theme.kt

package theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable

private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
)

private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200

/* Other default colors to override
background = Color.White,
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.Black,
onSurface = Color.Black,
*/

)

@Composable
fun HiltMVVMComposeMovieTheme(
darkTheme: Boolean = isSystemInDarkTheme()
,
content: @Composable() () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}

MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}

shared → commonMain → kotlin → theme → Theme.kt

package theme

import androidx.compose.material.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Set of Material typography styles to start with
val Typography = Typography(
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
button = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.W500,
fontSize = 14.sp
),
caption = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp
)

)

val Typography.subTitlePrimary: TextStyle
@Composable
get() {
return TextStyle(
fontFamily = FontFamily.Default,
color = FontColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}

val Typography.subTitleSecondary: TextStyle
@Composable
get() {
return TextStyle(
fontFamily = FontFamily.Default,
color = SecondaryFontColor,
fontSize = 10.sp,
)
}

val Typography.bioGrapyText: TextStyle
@Composable
get() {
return TextStyle(
fontFamily = FontFamily.Default,
color = SecondaryFontColor,
fontSize = 14.sp,
)
}

Step-11


Before running don't forget to add data classes inside this package 
shared → commonMain → kotlin → data → model,  from GitHub reference.

Now run the app and app home screen and movie detail should be following the screenshot 

Github ref:
https://github.com/piashcse/kmm-movie
Medium Ref: https://piashcse.medium.com/compose-multiplatform-movie-app-4752cd445e95