Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions feature-apps-api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKmpLibraryPluging)
alias(libs.plugins.kotlinSerialization)
}

kotlin {
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "featureAppsApi"
isStatic = true
}
}

jvm()

android {
namespace = "de.ahlfeld.bitriseartifacts.feature.apps.api"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()

@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.ahlfeld.bitriseartifacts.apps.navigation

import kotlinx.serialization.Serializable

@Serializable
data object AppsListRoute
3 changes: 3 additions & 0 deletions feature-apps/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,13 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
}
commonMain.dependencies {
implementation(projects.featureAppsApi)
implementation(projects.featureAuthApi)
implementation(projects.featureBuildsApi)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor)
implementation(libs.jetbrains.navigation.compose)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package de.ahlfeld.bitriseartifacts.apps.di

import androidx.lifecycle.SavedStateHandle
import de.ahlfeld.bitriseartifacts.apps.data.repository.BitriseAppsRepository
import de.ahlfeld.bitriseartifacts.apps.domain.repository.AppsRepository
import de.ahlfeld.bitriseartifacts.apps.domain.usecase.GetAppsUseCase
import de.ahlfeld.bitriseartifacts.apps.presentation.AppsViewModel
import de.ahlfeld.bitriseartifacts.apps.presentation.AppsViewModelImpl
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.bind
import org.koin.dsl.module

val appsModule = module {
singleOf(::BitriseAppsRepository) bind AppsRepository::class
factoryOf(::GetAppsUseCase)
viewModel { (handle : SavedStateHandle) ->
AppsViewModelImpl(get(), handle)
} bind AppsViewModel::class
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package de.ahlfeld.bitriseartifacts.apps.navigation

import androidx.compose.runtime.LaunchedEffect
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import de.ahlfeld.bitriseartifacts.apps.presentation.AppsScreen
import de.ahlfeld.bitriseartifacts.apps.presentation.AppsUiEvent
import de.ahlfeld.bitriseartifacts.apps.presentation.AppsViewModel
import de.ahlfeld.bitriseartifacts.builds.navigation.BuildRoute
import org.koin.compose.viewmodel.koinViewModel

fun NavGraphBuilder.appsScreen(navController: NavController) {
composable<AppsListRoute> {
val appsViewModel = koinViewModel<AppsViewModel>()
LaunchedEffect(appsViewModel) {
appsViewModel.navigationEvents.collect { event ->
if (event is AppsUiEvent.OnAppItemClicked) {
navController.navigate(
BuildRoute(event.item.slug, event.item.title)
)
}
}
}
AppsScreen(viewModel = appsViewModel)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package de.ahlfeld.bitriseartifacts.apps.presentation

data class AppItem(
val slug: String,
val avatarUrl: String,
val ownerName: String,
val title: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ private fun AppsScreenInternal(
when (uiState) {
is Content -> AppsList(
apps = uiState.apps,
selectedAppSlug = uiState.selectedAppSlug,
uiEventHandler = uiEventHandler
)

Expand All @@ -72,6 +73,7 @@ private fun AppsScreenInternal(
@Composable
private fun AppsList(
apps: List<AppItem>,
selectedAppSlug: String? = null,
uiEventHandler: (AppsUiEvent) -> Unit
) {
LazyColumn(
Expand All @@ -81,9 +83,10 @@ private fun AppsList(
color = Color.White
),
) {
items(apps, key = { app -> app.title + app.ownerName }) { app ->
items(apps, key = { app -> app.slug }) { app ->
AppItem(
app = app,
isSelected = app.slug == selectedAppSlug,
uiEventHandler = uiEventHandler
)
}
Expand All @@ -93,16 +96,18 @@ private fun AppsList(
@Composable
private fun AppItem(
app: AppItem,
isSelected: Boolean,
uiEventHandler: (AppsUiEvent) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(16.dp)
.background(if (isSelected) Color.LightGray.copy(alpha = 0.3f) else Color.Transparent)
.clickable {
uiEventHandler(AppsUiEvent.OnAppItemClicked(item = app))
},
}
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
Expand Down Expand Up @@ -131,4 +136,4 @@ private fun AppItem(
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package de.ahlfeld.bitriseartifacts.apps.presentation

sealed interface AppsUiState {
data object Loading : AppsUiState
data class Content(val apps: List<AppItem>) : AppsUiState
data class Content(
val apps: List<AppItem>,
val selectedAppSlug: String? = null
) : AppsUiState

data class Error(val message: String) : AppsUiState
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ class AppsUiStateProvider : PreviewParameterProvider<AppsUiState> {
AppsUiState.Content(
apps = listOf(
AppItem(
slug = "slug-1",
avatarUrl = "https://example.com/avatar1.png",
ownerName = "Owner Name 1",
title = "App Title 1"
),
AppItem(
slug = "slug-2",
avatarUrl = "https://example.com/avatar2.png",
ownerName = "Owner Name 2",
title = "App Title 2"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package de.ahlfeld.bitriseartifacts.apps.presentation

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow

abstract class AppsViewModel : ViewModel() {
abstract val uiState : StateFlow<AppsUiState>
abstract val uiState: StateFlow<AppsUiState>
abstract val navigationEvents: SharedFlow<AppsUiEvent>

abstract fun handleUiEvent(event : AppsUiEvent)
}
abstract fun handleUiEvent(event: AppsUiEvent)
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,73 @@
package de.ahlfeld.bitriseartifacts.apps.presentation

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import de.ahlfeld.bitriseartifacts.apps.domain.model.App
import de.ahlfeld.bitriseartifacts.apps.domain.usecase.GetAppsUseCase
import de.ahlfeld.bitriseartifacts.extensions.stateInWhileSubscribed
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch

private const val KEY_SELECTED_APP_SLUG = "selected_app_slug"

class AppsViewModelImpl(
val getAppsUseCase: GetAppsUseCase
private val getAppsUseCase: GetAppsUseCase,
private val saveStateHandle: SavedStateHandle
) : AppsViewModel() {
override val uiState: StateFlow<AppsUiState> = flow {
getAppsUseCase().onSuccess { apps ->
emit(AppsUiState.Content(apps.map { app ->
AppItem(
avatarUrl = app.avatarUrl ?: "",
ownerName = app.ownerName,
title = app.title
)
}))
}.onFailure {
emit(AppsUiState.Error(it.message ?: "Unknown error"))
}

private val _selectedAppSlug = saveStateHandle.getStateFlow(KEY_SELECTED_APP_SLUG, "")

override val uiState: StateFlow<AppsUiState> = combine(
flow {
emit(getAppsUseCase())
},
_selectedAppSlug
) { result, selectedSlug ->
createUiState(result, selectedSlug)
}.onStart {
emit(AppsUiState.Loading)
}.stateInWhileSubscribed(
scope = viewModelScope,
initialValue = AppsUiState.Loading
)

private val _navigationEvents = MutableSharedFlow<AppsUiEvent>()

override val navigationEvents: SharedFlow<AppsUiEvent> = _navigationEvents.asSharedFlow()
override fun handleUiEvent(event: AppsUiEvent) {
// Handle events
when (event) {
is AppsUiEvent.OnAppItemClicked -> {
saveStateHandle[KEY_SELECTED_APP_SLUG] = event.item.slug
viewModelScope.launch {
_navigationEvents.emit(event)
}
}
}
}

private fun createUiState(
result: Result<List<App>>,
selectedSlug: String?
): AppsUiState = if (result.isSuccess) {
val apps = result.getOrThrow()
AppsUiState.Content(
apps = apps.map { app ->
AppItem(
slug = app.slug,
avatarUrl = app.avatarUrl ?: "",
ownerName = app.ownerName,
title = app.title
)
},
selectedAppSlug = selectedSlug
)
} else {
AppsUiState.Error(result.exceptionOrNull()?.message ?: "Unknown error")
}
}
Loading
Loading