From cac74b098d19b0741367dc49808dab954b374b01 Mon Sep 17 00:00:00 2001 From: Bjoern Ahlfeld <3842688+ganddev@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:18:36 +0100 Subject: [PATCH] Feature: Add build list. --- feature-apps-api/build.gradle.kts | 39 ++++ .../apps/navigation/AppsListRoute.kt | 6 + feature-apps/build.gradle.kts | 3 + .../bitriseartifacts/apps/di/appsModule.kt | 21 +++ .../apps/navigation/AppsNavigation.kt | 27 +++ .../apps/presentation/AppItem.kt | 1 + .../apps/presentation/AppsScreen.kt | 13 +- .../apps/presentation/AppsUiState.kt | 6 +- .../apps/presentation/AppsUiStateProvider.kt | 2 + .../apps/presentation/AppsViewModel.kt | 8 +- .../apps/presentation/AppsViewModelImpl.kt | 66 +++++-- .../apps/presentation/AppsViewModelTest.kt | 119 ++++++++++-- .../apps/testdata/AppsRepositoryFake.kt | 14 ++ feature-builds-api/.gitignore | 1 + feature-builds-api/build.gradle.kts | 40 ++++ .../builds/navigation/BuildRoute.kt | 6 + feature-builds/build.gradle.kts | 53 ++++++ .../builds/data/model/BuildsResponseDto.kt | 28 +++ .../repository/BitriseBuildsRepository.kt | 34 ++++ .../builds/di/BuildsModule.kt | 17 ++ .../builds/domain/model/Build.kt | 11 ++ .../domain/repository/BuildsRepository.kt | 7 + .../builds/domain/usecase/GetBuildsUseCase.kt | 10 + .../builds/navigation/BuildsNavigation.kt | 26 +++ .../presentation/BuildsNavigationEvent.kt | 5 + .../builds/presentation/BuildsScreen.kt | 171 ++++++++++++++++++ .../builds/presentation/BuildsUiEvent.kt | 5 + .../builds/presentation/BuildsUiState.kt | 16 ++ .../BuildsUiStatePreviewParameterProvider.kt | 31 ++++ .../builds/presentation/BuildsViewModel.kt | 11 ++ .../presentation/BuildsViewModelImpl.kt | 63 +++++++ .../repository/BitriseBuildsRepositoryTest.kt | 82 +++++++++ .../domain/usecase/GetBuildsUseCaseTest.kt | 43 +++++ .../presentation/BuildsViewModelTest.kt | 138 ++++++++++++++ .../builds/testdata/BuildsRepositoryFake.kt | 19 ++ gradle/libs.versions.toml | 13 +- settings.gradle.kts | 21 +-- shared/build.gradle.kts | 14 +- .../ahlfeld/bitriseartifacts/di/AppModule.kt | 14 +- .../bitriseartifacts/presentation/App.kt | 71 +++++++- .../presentation/navigation/Routes.kt | 15 ++ 41 files changed, 1210 insertions(+), 80 deletions(-) create mode 100644 feature-apps-api/build.gradle.kts create mode 100644 feature-apps-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/navigation/AppsListRoute.kt create mode 100644 feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/di/appsModule.kt create mode 100644 feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/navigation/AppsNavigation.kt create mode 100644 feature-apps/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/apps/testdata/AppsRepositoryFake.kt create mode 100644 feature-builds-api/.gitignore create mode 100644 feature-builds-api/build.gradle.kts create mode 100644 feature-builds-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/navigation/BuildRoute.kt create mode 100644 feature-builds/build.gradle.kts create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/data/model/BuildsResponseDto.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/data/repository/BitriseBuildsRepository.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/di/BuildsModule.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/model/Build.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/repository/BuildsRepository.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/usecase/GetBuildsUseCase.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/navigation/BuildsNavigation.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsNavigationEvent.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsScreen.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiEvent.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiState.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiStatePreviewParameterProvider.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModel.kt create mode 100644 feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModelImpl.kt create mode 100644 feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/data/repository/BitriseBuildsRepositoryTest.kt create mode 100644 feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/usecase/GetBuildsUseCaseTest.kt create mode 100644 feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModelTest.kt create mode 100644 feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/testdata/BuildsRepositoryFake.kt create mode 100644 shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/navigation/Routes.kt diff --git a/feature-apps-api/build.gradle.kts b/feature-apps-api/build.gradle.kts new file mode 100644 index 0000000..890f5de --- /dev/null +++ b/feature-apps-api/build.gradle.kts @@ -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) + } + } +} diff --git a/feature-apps-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/navigation/AppsListRoute.kt b/feature-apps-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/navigation/AppsListRoute.kt new file mode 100644 index 0000000..5712b73 --- /dev/null +++ b/feature-apps-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/navigation/AppsListRoute.kt @@ -0,0 +1,6 @@ +package de.ahlfeld.bitriseartifacts.apps.navigation + +import kotlinx.serialization.Serializable + +@Serializable +data object AppsListRoute diff --git a/feature-apps/build.gradle.kts b/feature-apps/build.gradle.kts index ba76ed3..e1b19d8 100644 --- a/feature-apps/build.gradle.kts +++ b/feature-apps/build.gradle.kts @@ -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) } } } diff --git a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/di/appsModule.kt b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/di/appsModule.kt new file mode 100644 index 0000000..c032f2e --- /dev/null +++ b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/di/appsModule.kt @@ -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 +} \ No newline at end of file diff --git a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/navigation/AppsNavigation.kt b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/navigation/AppsNavigation.kt new file mode 100644 index 0000000..aaaa80f --- /dev/null +++ b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/navigation/AppsNavigation.kt @@ -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 { + val appsViewModel = koinViewModel() + LaunchedEffect(appsViewModel) { + appsViewModel.navigationEvents.collect { event -> + if (event is AppsUiEvent.OnAppItemClicked) { + navController.navigate( + BuildRoute(event.item.slug, event.item.title) + ) + } + } + } + AppsScreen(viewModel = appsViewModel) + } +} diff --git a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppItem.kt b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppItem.kt index 67f5107..50e4dfb 100644 --- a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppItem.kt +++ b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppItem.kt @@ -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, diff --git a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsScreen.kt b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsScreen.kt index d191df3..fdbc0cd 100644 --- a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsScreen.kt +++ b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsScreen.kt @@ -53,6 +53,7 @@ private fun AppsScreenInternal( when (uiState) { is Content -> AppsList( apps = uiState.apps, + selectedAppSlug = uiState.selectedAppSlug, uiEventHandler = uiEventHandler ) @@ -72,6 +73,7 @@ private fun AppsScreenInternal( @Composable private fun AppsList( apps: List, + selectedAppSlug: String? = null, uiEventHandler: (AppsUiEvent) -> Unit ) { LazyColumn( @@ -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 ) } @@ -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( @@ -131,4 +136,4 @@ private fun AppItem( ) } } -} +} \ No newline at end of file diff --git a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsUiState.kt b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsUiState.kt index 93a2bcb..f35d227 100644 --- a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsUiState.kt +++ b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsUiState.kt @@ -2,6 +2,10 @@ package de.ahlfeld.bitriseartifacts.apps.presentation sealed interface AppsUiState { data object Loading : AppsUiState - data class Content(val apps: List) : AppsUiState + data class Content( + val apps: List, + val selectedAppSlug: String? = null + ) : AppsUiState + data class Error(val message: String) : AppsUiState } \ No newline at end of file diff --git a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsUiStateProvider.kt b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsUiStateProvider.kt index 6e7a219..0e1fa9e 100644 --- a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsUiStateProvider.kt +++ b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsUiStateProvider.kt @@ -14,11 +14,13 @@ class AppsUiStateProvider : PreviewParameterProvider { 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" diff --git a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModel.kt b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModel.kt index 7647000..35f33aa 100644 --- a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModel.kt +++ b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModel.kt @@ -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 + abstract val uiState: StateFlow + abstract val navigationEvents: SharedFlow - abstract fun handleUiEvent(event : AppsUiEvent) -} \ No newline at end of file + abstract fun handleUiEvent(event: AppsUiEvent) +} diff --git a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModelImpl.kt b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModelImpl.kt index 34e64b6..e7d39d6 100644 --- a/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModelImpl.kt +++ b/feature-apps/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModelImpl.kt @@ -1,27 +1,35 @@ 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 = 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 = combine( + flow { + emit(getAppsUseCase()) + }, + _selectedAppSlug + ) { result, selectedSlug -> + createUiState(result, selectedSlug) }.onStart { emit(AppsUiState.Loading) }.stateInWhileSubscribed( @@ -29,7 +37,37 @@ class AppsViewModelImpl( initialValue = AppsUiState.Loading ) + private val _navigationEvents = MutableSharedFlow() + + override val navigationEvents: SharedFlow = _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>, + 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") } } diff --git a/feature-apps/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModelTest.kt b/feature-apps/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModelTest.kt index 4c107d1..5ea1e65 100644 --- a/feature-apps/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModelTest.kt +++ b/feature-apps/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/apps/presentation/AppsViewModelTest.kt @@ -1,7 +1,9 @@ package de.ahlfeld.bitriseartifacts.apps.presentation +import androidx.lifecycle.SavedStateHandle import de.ahlfeld.bitriseartifacts.apps.domain.model.App import de.ahlfeld.bitriseartifacts.apps.domain.usecase.GetAppsUseCase +import de.ahlfeld.bitriseartifacts.apps.testdata.AppsRepositoryFake import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -18,7 +20,12 @@ class AppsViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() - @OptIn(ExperimentalCoroutinesApi::class) + private val appsRepositoryFake = AppsRepositoryFake() + private val viewModel = AppsViewModelImpl( + getAppsUseCase = GetAppsUseCase(appsRepositoryFake), + saveStateHandle = SavedStateHandle() + ) + @BeforeTest fun setup() { Dispatchers.setMain(testDispatcher) @@ -26,9 +33,9 @@ class AppsViewModelTest { @Test fun `initial state is Loading`() = runTest { - val useCase = GetAppsUseCase(FakeAppsRepository()) + val useCase = GetAppsUseCase(appsRepositoryFake) - val viewModel = AppsViewModelImpl(useCase) + val viewModel = AppsViewModelImpl(getAppsUseCase = useCase, SavedStateHandle()) assertEquals(AppsUiState.Loading, viewModel.uiState.value) } @@ -38,8 +45,7 @@ class AppsViewModelTest { val apps = listOf( App("slug", "title", "owner", "avatar") ) - val useCase = GetAppsUseCase(FakeAppsRepository(apps)) - val viewModel = AppsViewModelImpl(useCase) + appsRepositoryFake.result = apps var actualUiState: AppsUiState? = null val job = launch { @@ -50,22 +56,105 @@ class AppsViewModelTest { advanceUntilIdle() - // Wait for the flow to emit the Content state - // Since we use UnconfinedTestDispatcher and stateIn, it should be immediate if the flow completes - assertEquals( AppsUiState.Content( apps = listOf( - element = AppItem("avatar", "owner", "title") - ) + AppItem("slug", "avatar", "owner", "title") + ), + selectedAppSlug = "" ), actualUiState ) job.cancel() } -} -private class FakeAppsRepository(private val apps: List = emptyList()) : - de.ahlfeld.bitriseartifacts.apps.domain.repository.AppsRepository { - override suspend fun getApps(): List = apps -} + @Test + fun `loadApps failure updates state to Error`() = runTest { + appsRepositoryFake.exception = Exception("Network error") + + var actualUiState: AppsUiState? = null + val job = launch { + viewModel.uiState.collect { + actualUiState = it + } + } + + advanceUntilIdle() + + assertEquals( + AppsUiState.Error("Network error"), + actualUiState + ) + + job.cancel() + } + + @Test + fun `initial state with saved selected slug`() = runTest { + val apps = listOf(App("slug", "title", "owner", "avatar")) + appsRepositoryFake.result = apps + val savedStateHandle = SavedStateHandle(mapOf("selected_app_slug" to "slug")) + val viewModelWithSavedState = AppsViewModelImpl( + getAppsUseCase = GetAppsUseCase(appsRepositoryFake), + saveStateHandle = savedStateHandle + ) + + var actualUiState: AppsUiState? = null + val job = launch { + viewModelWithSavedState.uiState.collect { + actualUiState = it + } + } + + advanceUntilIdle() + + assertEquals( + AppsUiState.Content( + apps = listOf(AppItem("slug", "avatar", "owner", "title")), + selectedAppSlug = "slug" + ), + actualUiState + ) + + job.cancel() + } + + @Test + fun `handle OnAppItemClicked updates state and emits navigation event`() = runTest { + val apps = listOf(App("slug", "title", "owner", "avatar")) + appsRepositoryFake.result = apps + val item = AppItem("slug", "avatar", "owner", "title") + + var lastNavigationEvent: AppsUiEvent? = null + val navJob = launch { + viewModel.navigationEvents.collect { + lastNavigationEvent = it + } + } + + var lastUiState: AppsUiState? = null + val stateJob = launch { + viewModel.uiState.collect { + lastUiState = it + } + } + + advanceUntilIdle() + + viewModel.handleUiEvent(AppsUiEvent.OnAppItemClicked(item)) + + advanceUntilIdle() + + assertEquals(AppsUiEvent.OnAppItemClicked(item), lastNavigationEvent) + assertEquals( + AppsUiState.Content( + apps = listOf(item), + selectedAppSlug = "slug" + ), + lastUiState + ) + + navJob.cancel() + stateJob.cancel() + } +} \ No newline at end of file diff --git a/feature-apps/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/apps/testdata/AppsRepositoryFake.kt b/feature-apps/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/apps/testdata/AppsRepositoryFake.kt new file mode 100644 index 0000000..6e60194 --- /dev/null +++ b/feature-apps/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/apps/testdata/AppsRepositoryFake.kt @@ -0,0 +1,14 @@ +package de.ahlfeld.bitriseartifacts.apps.testdata + +import de.ahlfeld.bitriseartifacts.apps.domain.model.App +import de.ahlfeld.bitriseartifacts.apps.domain.repository.AppsRepository + +internal class AppsRepositoryFake : AppsRepository { + var result: List? = null + var exception: Throwable? = null + + override suspend fun getApps(): List { + exception?.let { throw it } + return result ?: emptyList() + } +} \ No newline at end of file diff --git a/feature-builds-api/.gitignore b/feature-builds-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-builds-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-builds-api/build.gradle.kts b/feature-builds-api/build.gradle.kts new file mode 100644 index 0000000..143a24f --- /dev/null +++ b/feature-builds-api/build.gradle.kts @@ -0,0 +1,40 @@ +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 = "featureAuthApi" + isStatic = true + } + } + + jvm() + + android { + namespace = "de.ahlfeld.bitriseartifacts.feature.builds.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.koin.navigation) + implementation(libs.kotlinx.serialization.json) + } + } +} diff --git a/feature-builds-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/navigation/BuildRoute.kt b/feature-builds-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/navigation/BuildRoute.kt new file mode 100644 index 0000000..c066705 --- /dev/null +++ b/feature-builds-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/navigation/BuildRoute.kt @@ -0,0 +1,6 @@ +package de.ahlfeld.bitriseartifacts.builds.navigation + +import kotlinx.serialization.Serializable + +@Serializable +data class BuildRoute(val appSlug: String, val appName: String) \ No newline at end of file diff --git a/feature-builds/build.gradle.kts b/feature-builds/build.gradle.kts new file mode 100644 index 0000000..98078ba --- /dev/null +++ b/feature-builds/build.gradle.kts @@ -0,0 +1,53 @@ +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) + alias(libs.plugins.bitrise.kmp.network) + alias(libs.plugins.bitrise.kmp.compose) + alias(libs.plugins.bitrise.kmp.koin) +} + +kotlin { + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "Builds" + isStatic = true + } + } + + jvm() + + android { + namespace = "de.ahlfeld.bitriseartifacts.builds" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + + androidResources { + enable = true + } + } + + sourceSets { + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + commonMain.dependencies { + implementation(projects.featureBuildsApi) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.compose.material.icons.core) + implementation(libs.jetbrains.navigation.compose) + } + } +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/data/model/BuildsResponseDto.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/data/model/BuildsResponseDto.kt new file mode 100644 index 0000000..a094a28 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/data/model/BuildsResponseDto.kt @@ -0,0 +1,28 @@ +package de.ahlfeld.bitriseartifacts.builds.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BuildsResponseDto( + @SerialName("data") val data: List, + @SerialName("paging") val paging: PagingDto +) + +@Serializable +data class BuildDto( + @SerialName("build_number") val buildNumber: Int, + @SerialName("branch") val branch: String, + @SerialName("triggered_at") val triggeredAt: String, + @SerialName("finished_at") val finishedAt: String? = null, + @SerialName("commit_hash") val commitHash: String? = null, + @SerialName("slug") val slug: String, + @SerialName("status") val status: Int +) + +@Serializable +data class PagingDto( + @SerialName("total_item_count") val totalItemCount: Int, + @SerialName("page_item_limit") val pageItemLimit: Int, + @SerialName("next") val next: String? = null +) diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/data/repository/BitriseBuildsRepository.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/data/repository/BitriseBuildsRepository.kt new file mode 100644 index 0000000..bd36878 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/data/repository/BitriseBuildsRepository.kt @@ -0,0 +1,34 @@ +package de.ahlfeld.bitriseartifacts.builds.data.repository + +import de.ahlfeld.bitriseartifacts.builds.data.model.BuildsResponseDto +import de.ahlfeld.bitriseartifacts.builds.domain.model.Build +import de.ahlfeld.bitriseartifacts.builds.domain.repository.BuildsRepository +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode + +internal class BitriseBuildsRepository( + private val httpClient: HttpClient +) : BuildsRepository { + override suspend fun getBuilds(appSlug: String): List { + val response = httpClient.get("apps/$appSlug/builds") + return when (response.status) { + HttpStatusCode.OK -> { + response.body().data.map { dto -> + Build( + buildNumber = dto.buildNumber, + branch = dto.branch, + triggeredAt = dto.triggeredAt, + finishedAt = dto.finishedAt, + commitHash = dto.commitHash, + slug = dto.slug, + status = dto.status + ) + } + } + + else -> emptyList() + } + } +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/di/BuildsModule.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/di/BuildsModule.kt new file mode 100644 index 0000000..c529e0f --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/di/BuildsModule.kt @@ -0,0 +1,17 @@ +package de.ahlfeld.bitriseartifacts.builds.di + +import de.ahlfeld.bitriseartifacts.builds.data.repository.BitriseBuildsRepository +import de.ahlfeld.bitriseartifacts.builds.domain.repository.BuildsRepository +import de.ahlfeld.bitriseartifacts.builds.domain.usecase.GetBuildsUseCase +import de.ahlfeld.bitriseartifacts.builds.presentation.BuildsViewModelImpl +import de.ahlfeld.bitriseartifacts.builds.presentation.BuildsViewModel +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +val buildsModule = module { + singleOf(::BitriseBuildsRepository) bind BuildsRepository::class + factoryOf(::GetBuildsUseCase) + factory { (appSlug: String) -> BuildsViewModelImpl(appSlug, get()) } bind BuildsViewModel::class +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/model/Build.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/model/Build.kt new file mode 100644 index 0000000..c519c98 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/model/Build.kt @@ -0,0 +1,11 @@ +package de.ahlfeld.bitriseartifacts.builds.domain.model + +data class Build( + val buildNumber: Int, + val branch: String, + val triggeredAt: String, + val finishedAt: String?, + val commitHash: String?, + val slug: String, + val status: Int +) diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/repository/BuildsRepository.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/repository/BuildsRepository.kt new file mode 100644 index 0000000..9c0f270 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/repository/BuildsRepository.kt @@ -0,0 +1,7 @@ +package de.ahlfeld.bitriseartifacts.builds.domain.repository + +import de.ahlfeld.bitriseartifacts.builds.domain.model.Build + +internal interface BuildsRepository { + suspend fun getBuilds(appSlug: String): List +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/usecase/GetBuildsUseCase.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/usecase/GetBuildsUseCase.kt new file mode 100644 index 0000000..b6ee732 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/usecase/GetBuildsUseCase.kt @@ -0,0 +1,10 @@ +package de.ahlfeld.bitriseartifacts.builds.domain.usecase + +import de.ahlfeld.bitriseartifacts.builds.domain.model.Build +import de.ahlfeld.bitriseartifacts.builds.domain.repository.BuildsRepository + +internal class GetBuildsUseCase(private val repository: BuildsRepository) { + suspend operator fun invoke(appSlug: String): Result> = runCatching { + repository.getBuilds(appSlug) + } +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/navigation/BuildsNavigation.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/navigation/BuildsNavigation.kt new file mode 100644 index 0000000..1ff794f --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/navigation/BuildsNavigation.kt @@ -0,0 +1,26 @@ +package de.ahlfeld.bitriseartifacts.builds.navigation + +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import de.ahlfeld.bitriseartifacts.builds.presentation.BuildsScreen +import de.ahlfeld.bitriseartifacts.builds.presentation.BuildsViewModel +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.buildsScreen(navController: NavController) { + composable { backStackEntry -> + val route: BuildRoute = backStackEntry.toRoute() + val buildsViewModel = koinViewModel( + parameters = { parametersOf(route.appSlug) } + ) + LaunchedEffect(buildsViewModel) { + buildsViewModel.navigationEvents.collect { + navController.popBackStack() + } + } + BuildsScreen(viewModel = buildsViewModel) + } +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsNavigationEvent.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsNavigationEvent.kt new file mode 100644 index 0000000..63f810a --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsNavigationEvent.kt @@ -0,0 +1,5 @@ +package de.ahlfeld.bitriseartifacts.builds.presentation + +sealed interface BuildsNavigationEvent { + object Back : BuildsNavigationEvent +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsScreen.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsScreen.kt new file mode 100644 index 0000000..cfd62f2 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsScreen.kt @@ -0,0 +1,171 @@ +package de.ahlfeld.bitriseartifacts.builds.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp + +@Composable +fun BuildsScreen( + viewModel: BuildsViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + + BuildsScreenInternal(uiState = uiState, uiEventHandler = viewModel::handleUiEvent) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun BuildsScreenInternal( + uiState: BuildsUiState, + uiEventHandler: (BuildsUiEvent) -> Unit = {} +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Builds") }, + navigationIcon = { + IconButton(onClick = { + uiEventHandler(BuildsUiEvent.OnBackClicked) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when (uiState) { + is BuildsUiState.Loading -> CircularProgressIndicator( + modifier = Modifier.align( + Alignment.Center + ) + ) + + is BuildsUiState.Error -> Text( + text = uiState.message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.Center) + ) + + is BuildsUiState.Content -> BuildsList(uiState.builds) + } + } + } +} + +@Composable +private fun BuildsList(builds: List) { + if(builds.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Select an app to see builds", + style = MaterialTheme.typography.titleLarge + ) + } + } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(builds, key = { it.buildNumber }) { build -> + BuildItemRow(build) + } + } +} + +@Composable +private fun BuildItemRow(build: BuildItem) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "#${build.buildNumber}", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = build.branch, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Triggered: ${build.triggeredAt}", + style = MaterialTheme.typography.bodySmall + ) + build.finishedAt?.let { + Text( + text = "Finished: $it", + style = MaterialTheme.typography.bodySmall + ) + } + build.commitHash?.let { + Text( + text = "Commit: ${it.take(7)}", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + } + } +} + +@Composable +@Preview +private fun BuildsScreenPreview( + @PreviewParameter(BuildsUiStatePreviewParameterProvider::class) uiState : BuildsUiState +) { + MaterialTheme { + BuildsScreenInternal( + uiState = uiState + ) + } +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiEvent.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiEvent.kt new file mode 100644 index 0000000..2d8df42 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiEvent.kt @@ -0,0 +1,5 @@ +package de.ahlfeld.bitriseartifacts.builds.presentation + +sealed interface BuildsUiEvent { + data object OnBackClicked : BuildsUiEvent +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiState.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiState.kt new file mode 100644 index 0000000..4a919f6 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiState.kt @@ -0,0 +1,16 @@ +package de.ahlfeld.bitriseartifacts.builds.presentation + +sealed interface BuildsUiState { + data object Loading : BuildsUiState + data class Content(val builds: List) : BuildsUiState + data class Error(val message: String) : BuildsUiState +} + +data class BuildItem( + val buildNumber: Int, + val branch: String, + val triggeredAt: String, + val finishedAt: String?, + val commitHash: String?, + val status: Int +) diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiStatePreviewParameterProvider.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000..5a3b4b2 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsUiStatePreviewParameterProvider.kt @@ -0,0 +1,31 @@ +package de.ahlfeld.bitriseartifacts.builds.presentation + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class BuildsUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + BuildsUiState.Loading, + BuildsUiState.Content( + builds = listOf( + BuildItem( + buildNumber = 123, + branch = "main", + triggeredAt = "2023-10-27T10:00:00Z", + finishedAt = "2023-10-27T10:15:00Z", + commitHash = "abc123456789", + status = 1 + ), + BuildItem( + buildNumber = 122, + branch = "feature/navigation", + triggeredAt = "2023-10-26T14:00:00Z", + finishedAt = null, + commitHash = "def987654321", + status = 0 + ) + ) + ), + BuildsUiState.Error("Failed to load builds") + ) +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModel.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModel.kt new file mode 100644 index 0000000..1364b18 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModel.kt @@ -0,0 +1,11 @@ +package de.ahlfeld.bitriseartifacts.builds.presentation + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +abstract class BuildsViewModel : ViewModel() { + abstract val navigationEvents : SharedFlow + abstract val uiState: StateFlow + abstract fun handleUiEvent(event: BuildsUiEvent) +} diff --git a/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModelImpl.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModelImpl.kt new file mode 100644 index 0000000..c5628b4 --- /dev/null +++ b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModelImpl.kt @@ -0,0 +1,63 @@ +package de.ahlfeld.bitriseartifacts.builds.presentation + +import androidx.lifecycle.viewModelScope +import de.ahlfeld.bitriseartifacts.builds.domain.usecase.GetBuildsUseCase +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +internal class BuildsViewModelImpl( + private val appSlug: String, + private val getBuildsUseCase: GetBuildsUseCase +) : BuildsViewModel() { + + private val _uiState = MutableStateFlow(BuildsUiState.Loading) + + private val _navigationEvents = MutableSharedFlow() + override val navigationEvents: SharedFlow = _navigationEvents + override val uiState: StateFlow = _uiState.onStart { + loadBuilds() + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = BuildsUiState.Loading + ) + + + private fun loadBuilds() { + viewModelScope.launch { + _uiState.value = BuildsUiState.Loading + getBuildsUseCase(appSlug) + .onSuccess { builds -> + val items = builds + .sortedByDescending { it.triggeredAt } + .map { + BuildItem( + buildNumber = it.buildNumber, + branch = it.branch, + triggeredAt = it.triggeredAt, + finishedAt = it.finishedAt, + commitHash = it.commitHash, + status = it.status + ) + } + _uiState.value = BuildsUiState.Content(items) + } + .onFailure { + _uiState.value = BuildsUiState.Error(it.message ?: "Unknown error") + } + } + } + + override fun handleUiEvent(event: BuildsUiEvent) { + when (event) { + BuildsUiEvent.OnBackClicked -> + viewModelScope.launch { _navigationEvents.emit(BuildsNavigationEvent.Back) } + } + } +} diff --git a/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/data/repository/BitriseBuildsRepositoryTest.kt b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/data/repository/BitriseBuildsRepositoryTest.kt new file mode 100644 index 0000000..a8033f6 --- /dev/null +++ b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/data/repository/BitriseBuildsRepositoryTest.kt @@ -0,0 +1,82 @@ +package de.ahlfeld.bitriseartifacts.builds.data.repository + +import de.ahlfeld.bitriseartifacts.builds.data.model.BuildDto +import de.ahlfeld.bitriseartifacts.builds.data.model.BuildsResponseDto +import de.ahlfeld.bitriseartifacts.builds.data.model.PagingDto +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BitriseBuildsRepositoryTest { + + private fun createMockHttpClient( + status: HttpStatusCode, + content: String = "" + ): HttpClient { + val mockEngine = MockEngine { + respond( + content = content, + status = status, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + return HttpClient(mockEngine) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + }) + } + } + } + + @Test + fun `getBuilds returns list of builds when status is OK`() = runTest { + val responseDto = BuildsResponseDto( + data = listOf( + BuildDto( + buildNumber = 1, + branch = "main", + triggeredAt = "2023-01-01T10:00:00Z", + slug = "slug1", + status = 1 + ) + ), + paging = PagingDto(1, 50) + ) + val httpClient = createMockHttpClient( + status = HttpStatusCode.OK, + content = Json.encodeToString(responseDto) + ) + val repository = BitriseBuildsRepository(httpClient) + + val builds = repository.getBuilds("app-slug") + + assertEquals(1, builds.size) + assertEquals(1, builds[0].buildNumber) + assertEquals("main", builds[0].branch) + } + + @Test + fun `getBuilds returns empty list when status is not OK`() = runTest { + val httpClient = createMockHttpClient( + status = HttpStatusCode.InternalServerError + ) + val repository = BitriseBuildsRepository(httpClient) + + val builds = repository.getBuilds("app-slug") + + assertTrue(builds.isEmpty()) + } +} diff --git a/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/usecase/GetBuildsUseCaseTest.kt b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/usecase/GetBuildsUseCaseTest.kt new file mode 100644 index 0000000..5996b07 --- /dev/null +++ b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/domain/usecase/GetBuildsUseCaseTest.kt @@ -0,0 +1,43 @@ +package de.ahlfeld.bitriseartifacts.builds.domain.usecase + +import de.ahlfeld.bitriseartifacts.builds.domain.model.Build +import de.ahlfeld.bitriseartifacts.builds.domain.repository.BuildsRepository +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GetBuildsUseCaseTest { + + @Test + fun `invoke returns success when repository returns builds`() = runTest { + val builds = listOf( + Build(1, "main", "2023-01-01T10:00:00Z", null, "hash1", "slug1", 1) + ) + val repository = object : BuildsRepository { + override suspend fun getBuilds(appSlug: String): List = builds + } + val useCase = GetBuildsUseCase(repository) + + val result = useCase("app-slug") + + assertTrue(result.isSuccess) + assertEquals(builds, result.getOrNull()) + } + + @Test + fun `invoke returns failure when repository throws exception`() = runTest { + val exception = Exception("Network error") + val repository = object : BuildsRepository { + override suspend fun getBuilds(appSlug: String): List { + throw exception + } + } + val useCase = GetBuildsUseCase(repository) + + val result = useCase("app-slug") + + assertTrue(result.isFailure) + assertEquals(exception, result.exceptionOrNull()) + } +} diff --git a/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModelTest.kt b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModelTest.kt new file mode 100644 index 0000000..24d4cfa --- /dev/null +++ b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/presentation/BuildsViewModelTest.kt @@ -0,0 +1,138 @@ +package de.ahlfeld.bitriseartifacts.builds.presentation + +import de.ahlfeld.bitriseartifacts.builds.domain.model.Build +import de.ahlfeld.bitriseartifacts.builds.domain.repository.BuildsRepository +import de.ahlfeld.bitriseartifacts.builds.domain.usecase.GetBuildsUseCase +import de.ahlfeld.bitriseartifacts.builds.testdata.BuildsRepositoryFake +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class BuildsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val buildsRepositoryFake = BuildsRepositoryFake() + private val viewModel = BuildsViewModelImpl( + "app-slug", + GetBuildsUseCase(buildsRepositoryFake) + ) + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @Test + fun `loadBuilds success updates state to Content and sorts by date desc`() = runTest { + val builds = listOf( + Build(1, "main", "2023-01-01T10:00:00Z", null, "hash1", "slug1", 1), + Build(2, "develop", "2023-01-02T10:00:00Z", null, "hash2", "slug2", 1) + ) + buildsRepositoryFake.result = builds + + val states = mutableListOf() + val job = launch(testDispatcher) { + viewModel.uiState.collect { states.add(it) } + } + + runCurrent() + + assertTrue(states.isNotEmpty(), "States should not be empty") + val state = states.last() + assertTrue(state is BuildsUiState.Content, "Expected Content state but was $state") + assertEquals(2, state.builds.size) + // Check order: Most recent first (build 2 has later date) + assertEquals(2, state.builds[0].buildNumber) + assertEquals(1, state.builds[1].buildNumber) + job.cancel() + } + + @Test + fun `loadBuilds success with empty list updates state to Content with empty list`() = runTest { + val builds = emptyList() + buildsRepositoryFake.result = builds + + val states = mutableListOf() + val job = launch(testDispatcher) { + viewModel.uiState.collect { states.add(it) } + } + + runCurrent() + + assertTrue(states.isNotEmpty(), "States should not be empty") + val state = states.last() + assertTrue(state is BuildsUiState.Content, "Expected Content state but was $state") + assertTrue(state.builds.isEmpty()) + job.cancel() + } + + @Test + fun `loadBuilds failure updates state to Error`() = runTest { + val repository = object : BuildsRepository { + override suspend fun getBuilds(appSlug: String): List { + throw Exception("Network error") + } + } + val useCase = GetBuildsUseCase(repository) + val viewModel = BuildsViewModelImpl("app-slug", useCase) + + val states = mutableListOf() + val job = launch(testDispatcher) { + viewModel.uiState.collect { states.add(it) } + } + + runCurrent() + + assertTrue(states.isNotEmpty(), "States should not be empty") + val state = states.last() + assertTrue(state is BuildsUiState.Error, "Expected Error state but was $state") + assertEquals("Network error", state.message) + job.cancel() + } + + @Test + fun `loadBuilds failure with null message updates state to Error with default message`() = + runTest { + buildsRepositoryFake.exception = Exception() + + val states = mutableListOf() + val job = launch(testDispatcher) { + viewModel.uiState.collect { states.add(it) } + } + + runCurrent() + + assertTrue(states.isNotEmpty(), "States should not be empty") + val state = states.last() + assertTrue(state is BuildsUiState.Error, "Expected Error state but was $state") + assertEquals("Unknown error", state.message) + job.cancel() + } + + @Test + fun `handleUiEvent OnBackClicked emits Back navigation event`() = runTest { + buildsRepositoryFake.result = emptyList() + val events = mutableListOf() + val job = launch(testDispatcher) { + viewModel.navigationEvents.collect { events.add(it) } + } + + viewModel.handleUiEvent(BuildsUiEvent.OnBackClicked) + runCurrent() + + assertEquals(1, events.size) + assertEquals(BuildsNavigationEvent.Back, events[0]) + job.cancel() + } +} + diff --git a/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/testdata/BuildsRepositoryFake.kt b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/testdata/BuildsRepositoryFake.kt new file mode 100644 index 0000000..47ee940 --- /dev/null +++ b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/testdata/BuildsRepositoryFake.kt @@ -0,0 +1,19 @@ +package de.ahlfeld.bitriseartifacts.builds.testdata + +import de.ahlfeld.bitriseartifacts.builds.domain.model.Build +import de.ahlfeld.bitriseartifacts.builds.domain.repository.BuildsRepository + +internal class BuildsRepositoryFake : + BuildsRepository { + var result: List? = null + + var exception : Exception? = null + + override suspend fun getBuilds(appSlug: String): List { + exception?.let { + throw it + } + return result!! + + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 672a0b4..751ef23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,18 +11,20 @@ androidx-lifecycle = "2.9.6" androidx-testExt = "1.3.0" appcompat = "1.7.0" composeMultiplatform = "1.10.3" +composeIcons = "1.7.3" junit = "4.13.2" kotlin = "2.3.20" material = "1.13.0" material3 = "1.10.0-alpha05" kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.7.3" datastore = "1.2.1" androidx-compose-ui-tooling = "1.10.5" # Frameworks ktor = "3.4.1" koin = "4.2.0" -navigation3 = "2.9.7" # Part of Jetpack Navigation +navigation = "2.9.2" coil = "3.4.0" [libraries] @@ -45,6 +47,9 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +# Serialization +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + # Ktor ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } @@ -58,6 +63,7 @@ koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" koin-compose = { group = "io.insert-koin", name = "koin-compose", version.ref = "koin" } koin-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } +koin-navigation = { group = "io.insert-koin", name = "koin-compose-navigation3", version.ref = "koin" } # Datastore datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" } @@ -69,7 +75,10 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t # Navigation material = { module = "com.google.android.material:material", version.ref = "material" } -navigation3 = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation3" } +jetbrains-navigation-compose = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "navigation" } + +# Icons +compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "composeIcons" } # Coil coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 81051ef..b81c4b5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,33 +5,26 @@ includeBuild("build-logic") pluginManagement { repositories { - google { - mavenContent { - includeGroupAndSubgroups("androidx") - includeGroupAndSubgroups("com.android") - includeGroupAndSubgroups("com.google") - } - } + google() mavenCentral() gradlePluginPortal() + maven("https://maven.pkg.jetbrains.com/public") } } dependencyResolutionManagement { repositories { - google { - mavenContent { - includeGroupAndSubgroups("androidx") - includeGroupAndSubgroups("com.android") - includeGroupAndSubgroups("com.google") - } - } + google() mavenCentral() + maven("https://maven.pkg.jetbrains.com/public") } } include(":androidApp") include(":feature-apps") +include(":feature-apps-api") include(":feature-auth") include(":feature-auth-api") +include(":feature-builds") include(":shared") +include(":feature-builds-api") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a65e610..ccd89d9 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -6,6 +6,8 @@ plugins { alias(libs.plugins.androidKmpLibraryPluging) alias(libs.plugins.bitrise.kmp.network) alias(libs.plugins.bitrise.kmp.compose) + alias(libs.plugins.bitrise.kmp.koin) + alias(libs.plugins.kotlinSerialization) } kotlin { @@ -35,17 +37,15 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.featureApps) + implementation(projects.featureAppsApi) implementation(projects.featureAuth) implementation(projects.featureAuthApi) + implementation(projects.featureBuildsApi) + implementation(projects.featureBuilds) - implementation(libs.compose.runtime) - implementation(libs.compose.foundation) - implementation(libs.compose.material3) - implementation(libs.compose.ui) - implementation(libs.koin.core) - implementation(libs.koin.compose) - implementation(libs.koin.viewmodel) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.jetbrains.navigation.compose) } } } diff --git a/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/di/AppModule.kt b/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/di/AppModule.kt index a1510f4..889997a 100644 --- a/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/di/AppModule.kt +++ b/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/di/AppModule.kt @@ -1,17 +1,9 @@ package de.ahlfeld.bitriseartifacts.di -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.dsl.bind +import de.ahlfeld.bitriseartifacts.apps.di.appsModule +import de.ahlfeld.bitriseartifacts.builds.di.buildsModule import org.koin.dsl.module val appModule = module { - singleOf(::BitriseAppsRepository) bind AppsRepository::class - factoryOf(::GetAppsUseCase) - factoryOf(::AppsViewModelImpl) bind AppsViewModel::class + includes(appsModule, buildsModule) } diff --git a/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/App.kt b/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/App.kt index 55c1c27..e7167e4 100644 --- a/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/App.kt +++ b/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/App.kt @@ -1,25 +1,33 @@ package de.ahlfeld.bitriseartifacts.presentation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import de.ahlfeld.bitriseartifacts.apps.navigation.AppsListRoute +import de.ahlfeld.bitriseartifacts.apps.navigation.appsScreen 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 de.ahlfeld.bitriseartifacts.builds.navigation.buildsScreen import de.ahlfeld.bitriseartifacts.feature.auth.domain.usecase.GetTokenUseCase import de.ahlfeld.bitriseartifacts.feature.auth.presentation.AuthScreen import de.ahlfeld.bitriseartifacts.feature.auth.presentation.AuthViewModel -import org.jetbrains.compose.resources.ExperimentalResourceApi import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel -@OptIn(ExperimentalResourceApi::class) @Composable fun App() { MaterialTheme { @@ -33,12 +41,57 @@ fun App() { if (token.isNullOrBlank()) { val authViewModel = koinViewModel() - AuthScreen( - viewModel = authViewModel, - ) + AuthScreen(viewModel = authViewModel) } else { - val viewModel = koinViewModel() - AppsScreen(viewModel = viewModel) + AuthenticatedApp() + } + } + } +} + +@Composable +fun AuthenticatedApp() { + val navController = rememberNavController() + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val isExpanded = maxWidth > 600.dp + + Row(modifier = Modifier.fillMaxSize()) { + if (isExpanded) { + Box(modifier = Modifier.weight(0.4f)) { + val appsViewModel = koinViewModel() + LaunchedEffect(appsViewModel) { + appsViewModel.navigationEvents.collect { event -> + if (event is AppsUiEvent.OnAppItemClicked) { + navController.navigate( + BuildRoute( + appSlug = event.item.slug, + appName = event.item.title + ) + ) { + popUpTo(AppsListRoute) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + } + } + AppsScreen( + viewModel = appsViewModel, + ) + } + } + + Box(modifier = Modifier.weight(if (isExpanded) 0.6f else 1f)) { + NavHost( + navController = navController, + startDestination = AppsListRoute + ) { + appsScreen(navController) + buildsScreen(navController) + } } } } diff --git a/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/navigation/Routes.kt b/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/navigation/Routes.kt new file mode 100644 index 0000000..3a62ae5 --- /dev/null +++ b/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/navigation/Routes.kt @@ -0,0 +1,15 @@ +package de.ahlfeld.bitriseartifacts.presentation.navigation + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface AppRoute { + @Serializable + data object Auth : AppRoute + + @Serializable + data object AppsList : AppRoute + + @Serializable + data class BuildsList(val appSlug: String, val appName: String) : AppRoute +}