MangaKu App Powered by Kotlin Multiplatform, Jetpack Compose, SwiftUI and MVI Pattern!
Module
shared
: data and domain layermangaku-ios
: ios presentation layermangaku-android
: android presentation layer
- Introduction
- Features
- Installation
- Screenshot
- Libraries
- Presentation State-Event
- Expect and Actual
- Project Structure
A few things you can do with MangaKu:
- View Popular Manga
- Easily search for any Manga
- See Manga Detail
- Save your favorite manga
This project have no concern about backward compatibility, and only support the very latest or experimental api's for both android and ios
- Follow the KMP Guide by Jetbrains for getting started building a project with KMP.
- Install Kotlin Multiplatform plugin in Android Studio
- Clone or download the repo
- Rebuild Project
- To run in iOS, Open Xcode from the
mangaku-ios
folder
shared
:
mangaku-ios
:
mangaku-android
:
- Jetpack Compose
- Accompanist
- Koin
- Compose Destinations
- Some Kotlinx & Jetpack Components
I'm using KMMViewModel library to share ViewModel that will be consumed by both Android and iOS with State and Event on each ViewModel (following the MVI Pattern)
State and Event
data class MyMangaState(
val mangas: List<Manga> = listOf(),
val isFavorite: Boolean = false,
val isLoading: Boolean = false,
val isEmpty: Boolean = false,
val errorMessage: String = ""
)
sealed class MyMangaEvent {
data object GetMyMangas: MyMangaEvent()
data class CheckFavorite(val mangaId: String): MyMangaEvent()
data class AddFavorite(val manga: Manga): MyMangaEvent()
data class DeleteFavorite(val mangaId: String): MyMangaEvent()
data object Empty: MyMangaEvent()
}
Reducing State and Event
MyMangaViewModel.kt
fun onTriggerEvent(event: MyMangaEvent) {
when (event) {
is MyMangaEvent.GetMyMangas -> {
getMyManga()
}
is MyMangaEvent.Empty -> {
_state.value = MyMangaState(isEmpty = true)
}
is MyMangaEvent.CheckFavorite -> {
checkFavorite(event.mangaId)
}
is MyMangaEvent.AddFavorite -> {
addMyManga(event.manga)
}
is MyMangaEvent.DeleteFavorite -> {
deleteMyManga(event.mangaId)
}
}
}
private fun checkFavorite(mangaId: String) = viewModelScope.coroutineScope.launch {
myMangaUseCase.getMyMangaById(mangaId).collect { result ->
_state.value = _state.value.copy(isFavorite = result.map { it.id }.contains(mangaId))
}
}
private fun getMyManga() = viewModelScope.coroutineScope.launch {
_state.value = _state.value.copy(isLoading = true)
myMangaUseCase.getMyManga().catch { cause: Throwable ->
_state.value = _state.value.copy(errorMessage = cause.message.orEmpty())
}.collect {
if (it.isEmpty()) _state.value = MyMangaState(isEmpty = true)
else _state.value = MyMangaState(mangas = it)
}
}
Compose UI based on State that triggered from Event
DetailScreen.kt
Button(
elevation = ButtonDefaults.elevation(0.dp, 0.dp),
onClick = {
setShowDialog(true)
if (!viewState.isLoading) {
viewState.manga?.let {
if (favState.isFavorite) mangaViewModel.onTriggerEvent(MyMangaEvent.DeleteFavorite(it.id))
else mangaViewModel.onTriggerEvent(MyMangaEvent.AddFavorite(it))
}
}
}
) {
Icon(
imageVector = if (favState.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null,
tint = Color.Red,
modifier = Modifier.size(25.dp),
)
}
DetailPageView.swift
.navigationBarItems(trailing: Button(action: {
if let data = viewState.manga {
favState.isFavorite ? mangaViewModel.onTriggerEvent(event: MyMangaEvent.DeleteFavorite(mangaId: data.id))
: mangaViewModel.onTriggerEvent(event: MyMangaEvent.AddFavorite(manga: data))
isShowDialog.toggle()
}
}) {
Image(systemName: favState.isFavorite ? "heart.fill" : "heart")
.resizable()
.foregroundColor(.red)
.frame(width: 22, height: 20)
})
in KMP, there is a negative case when there's no support to share code for some feature in both ios and android, and it's expensive to write separately in each module
so the solution is β¨expect
and actual
β¨, we can write expect
inside commonMain
and write "actual" implementation with actual
inside androidMain
and iosMain
and then each module will use expect
example:
commonMain/utils/DateFormatter.kt
expect fun formatDate(dateString: String, format: String): String
androidMain/utils/DateFormatter.kt
SimpleDateFormat
actual fun formatDate(dateString: String, format: String): String {
val date = SimpleDateFormat(Constants.formatFromApi).parse(dateString)
val dateFormatter = SimpleDateFormat(format, Locale.getDefault())
return dateFormatter.format(date ?: Date())
}
iosMain/utils/DateFormatter.kt
NSDateFormatter
actual fun formatDate(dateString: String, format: String): String {
val dateFormatter = NSDateFormatter().apply {
dateFormat = Constants.formatFromApi
}
val formatter = NSDateFormatter().apply {
dateFormat = format
locale = NSLocale(localeIdentifier = "id_ID")
}
return formatter.stringFromDate(dateFormatter.dateFromString(dateString) ?: NSDate())
}
we definitely can use Foundation
the same way we use it in Xcode
If you like this project please support me by ;-)
shared
:
data
mapper
repository
source
local
entity
remote
response
di
domain
model
repository
usecase
browse
detail
mymanga
search
utils
mangaku-android
:
ui
composables
home
composables
favorite
search
detail
di
utils
mangaku-ios
:
Dependency
App
Main
Resources
ReusableView
Extensions
Utils
Features
Browse
Navigator
Views
Search
Detail
MyManga