From 5cd282d9042d2d205a02e206329948f06ec4ef27 Mon Sep 17 00:00:00 2001 From: Bjoern Ahlfeld <3842688+ganddev@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:42:57 +0100 Subject: [PATCH] Feature: Add feature which checks if an artifact is available for as specific build. Let the user click on a build with an artifact and show the list of available artifacts in a BottomSheet. --- ARCHITECTURE.md | 291 ++++++++++++++++++ IMPLEMENTATION_CHECKLIST.md | 161 ++++++++++ androidApp/src/main/AndroidManifest.xml | 8 + feature-artifact-detail-api/build.gradle.kts | 40 +++ .../navigation/ArtifactDetailRoute.kt | 10 + feature-artifact-detail/README.md | 130 ++++++++ feature-artifact-detail/build.gradle.kts | 55 ++++ .../di/ArtifactDetailModule.android.kt | 11 + .../domain/service/AndroidUrlOpener.kt | 30 ++ .../data/ArtifactDetailResponseDto.kt | 28 ++ .../data/BitriseArtifactsRepository.kt | 44 +++ .../di/ArtifactDetailModule.kt | 25 ++ .../domain/model/ArtifactDetails.kt | 8 + .../model/NoArtifactDetailsFoundException.kt | 14 + .../domain/repository/ArtifactsRepository.kt | 8 + .../domain/service/UrlOpener.kt | 6 + .../domain/service/di/urlOpenerModule.kt | 2 + .../usecase/GetArtifactDetailsUseCase.kt | 24 ++ .../navigation/ArtifactDetailsNavigation.kt | 35 +++ .../ArtifactDetailsBottomSheet.kt | 144 +++++++++ .../ArtifactDetailsNavigationEvent.kt | 7 + .../presentation/ArtifactDetailsUiEvent.kt | 8 + .../presentation/ArtifactDetailsUiState.kt | 8 + .../presentation/ArtifactDetailsViewModel.kt | 12 + .../ArtifactDetailsViewModelImpl.kt | 81 +++++ .../data/BitriseArtifactsRepositoryTest.kt | 73 +++++ .../artifact_detail/data/TestUtils.kt | 3 + .../resources/ArtifactResponse.json | 23 ++ .../di/ArtifactDetailModule.ios.kt | 12 + .../domain/service/UrlOpenerImpl.kt | 16 + .../di/ArtifactDetailModule.jvm.kt | 11 + .../domain/service/UrlOpenerImpl.kt | 17 + .../artifact_detail/data/TestUtils.jvm.kt | 10 + feature-artifacts-api/build.gradle.kts | 38 +++ .../artifacts/api/model/Artifact.kt | 12 + .../api/usecase/GetArtifactSlugsUseCase.kt | 5 + .../api/usecase/GetArtifactsUseCase.kt | 11 + feature-artifacts/build.gradle.kts | 47 +++ .../artifacts/data/model/ArtifactResponse.kt | 31 ++ .../repository/BitriseArtifactsRepository.kt | 45 +++ .../artifacts/di/ArtifactsModule.kt | 12 + .../domain/NoArtifactFoundException.kt | 15 + .../domain/repository/ArtifactsRepository.kt | 7 + .../usecase/GetArtifactSlugsUseCaseImpl.kt | 13 + .../usecase/GetArtifactSlutsUseCaseImpl.kt | 13 + .../BitriseArtifactsRepositoryTest.kt | 134 ++++++++ .../GetArtifactSlutsUseCaseImplTest.kt | 46 +++ .../testdata/ArtifactsRepositoryFake.kt | 11 + feature-builds-api/build.gradle.kts | 1 - feature-builds/build.gradle.kts | 4 +- .../builds/data/model/BuildsResponseDto.kt | 6 +- .../builds/di/BuildsModule.kt | 11 +- .../builds/domain/model/Build.kt | 2 +- .../builds/navigation/BuildsNavigation.kt | 19 +- .../presentation/BuildsNavigationEvent.kt | 10 +- .../builds/presentation/BuildsScreen.kt | 71 ++++- .../builds/presentation/BuildsUiEvent.kt | 3 +- .../builds/presentation/BuildsUiState.kt | 8 +- .../BuildsUiStatePreviewParameterProvider.kt | 22 +- .../builds/presentation/BuildsViewModel.kt | 2 +- .../presentation/BuildsViewModelImpl.kt | 50 ++- .../presentation/BuildsViewModelTest.kt | 21 +- .../testdata/GetArtifactSlugsUseCaseFake.kt | 11 + gradle/libs.versions.toml | 51 ++- settings.gradle.kts | 4 + shared/build.gradle.kts | 4 + .../ahlfeld/bitriseartifacts/di/AppModule.kt | 4 +- .../bitriseartifacts/presentation/App.kt | 2 + 68 files changed, 2009 insertions(+), 92 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 IMPLEMENTATION_CHECKLIST.md create mode 100644 feature-artifact-detail-api/build.gradle.kts create mode 100644 feature-artifact-detail-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifactdetail/navigation/ArtifactDetailRoute.kt create mode 100644 feature-artifact-detail/README.md create mode 100644 feature-artifact-detail/build.gradle.kts create mode 100644 feature-artifact-detail/src/androidMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.android.kt create mode 100644 feature-artifact-detail/src/androidMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/AndroidUrlOpener.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/data/ArtifactDetailResponseDto.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/data/BitriseArtifactsRepository.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/model/ArtifactDetails.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/model/NoArtifactDetailsFoundException.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/repository/ArtifactsRepository.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpener.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/di/urlOpenerModule.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/usecase/GetArtifactDetailsUseCase.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/navigation/ArtifactDetailsNavigation.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsBottomSheet.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsNavigationEvent.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsUiEvent.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsUiState.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsViewModel.kt create mode 100644 feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsViewModelImpl.kt create mode 100644 feature-artifact-detail/src/commonTest/kotlin/de/ahlfeld/bitirseartifacts/artifact_detail/data/BitriseArtifactsRepositoryTest.kt create mode 100644 feature-artifact-detail/src/commonTest/kotlin/de/ahlfeld/bitirseartifacts/artifact_detail/data/TestUtils.kt create mode 100644 feature-artifact-detail/src/commonTest/resources/ArtifactResponse.json create mode 100644 feature-artifact-detail/src/iosMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.ios.kt create mode 100644 feature-artifact-detail/src/iosMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpenerImpl.kt create mode 100644 feature-artifact-detail/src/jvmMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.jvm.kt create mode 100644 feature-artifact-detail/src/jvmMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpenerImpl.kt create mode 100644 feature-artifact-detail/src/jvmTest/java/de/ahlfeld/bitirseartifacts/artifact_detail/data/TestUtils.jvm.kt create mode 100644 feature-artifacts-api/build.gradle.kts create mode 100644 feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/model/Artifact.kt create mode 100644 feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/usecase/GetArtifactSlugsUseCase.kt create mode 100644 feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/usecase/GetArtifactsUseCase.kt create mode 100644 feature-artifacts/build.gradle.kts create mode 100644 feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/model/ArtifactResponse.kt create mode 100644 feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/repository/BitriseArtifactsRepository.kt create mode 100644 feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/di/ArtifactsModule.kt create mode 100644 feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/NoArtifactFoundException.kt create mode 100644 feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/repository/ArtifactsRepository.kt create mode 100644 feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlugsUseCaseImpl.kt create mode 100644 feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlutsUseCaseImpl.kt create mode 100644 feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/repository/BitriseArtifactsRepositoryTest.kt create mode 100644 feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlutsUseCaseImplTest.kt create mode 100644 feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/testdata/ArtifactsRepositoryFake.kt create mode 100644 feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/testdata/GetArtifactSlugsUseCaseFake.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..e13c67b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,291 @@ +# Architecture Diagram - Artifact Details Feature + +## Modul-Abhängigkeiten + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ androidApp / iosApp │ +└─────────────────┬───────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ feature-builds │ + │ (Presentation) │ + └────┬────────────┬───┘ + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────────────────┐ + │feature-artifacts │ │feature-artifact-detail │◄──┐ + │ -api │ │ (New Module) │ │ + └──────────────────┘ └────────┬─────────────────────┘ │ + │ │ + ▼ │ + ┌──────────────────────┐ │ + │feature-artifact- │ │ + │detail-api (New) │─────────────┘ + └──────────────────────┘ +``` + +## Feature-Builds Integration Flow + +``` +┌──────────────────────────────────────────────────────────────┐ +│ BuildsScreen │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ BuildsList │ │ +│ │ ┌──────────────────────────────────────────────────┐ │ │ +│ │ │ BuildItemRow (clickable) │ │ │ +│ │ │ ┌────────────────────────────────────────────┐ │ │ │ +│ │ │ │ onClick: BuildsUiEvent.OnBuildClicked() │ │ │ │ +│ │ │ └────────────────────────┬───────────────────┘ │ │ │ +│ │ └────────────────────────┬─────────────────────────┘ │ │ +│ └─────────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ ArtifactDetailsBottomSheet │ │ +│ │ (shown when state = Visible) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ + │ BuildsViewModelImpl handles OnBuildClicked + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ BuildsViewModelImpl │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ handleUiEvent(OnBuildClicked(buildSlug)) │ │ +│ │ → artifactDetailsViewModel.handleUiEvent( │ │ +│ │ OnBuildArtifactsRequested(...) │ │ +│ │ ) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ ArtifactDetailsViewModelImpl │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ handleUiEvent(OnBuildArtifactsRequested) │ │ +│ │ → getArtifactsListUseCase(appSlug, buildSlug) │ │ +│ │ → setState(Visible(artifacts)) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ ArtifactsRepository │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ getArtifacts(appSlug, buildSlug) │ │ +│ │ → HTTP GET /apps/{app}/builds/{build}/artifacts │ │ +│ │ → Returns List │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +## User Interaction Flow + +``` +1. User Clicks Build Card + │ + ├─ BuildsScreen renders BuildItemRow as clickable + ├─ onClick → uiEventHandler(BuildsUiEvent.OnBuildClicked) + │ + ▼ +2. BuildsViewModelImpl processes event + ├─ Updates state: selectedBuildSlug = buildSlug + ├─ Calls: artifactDetailsViewModel.handleUiEvent( + │ OnBuildArtifactsRequested(appSlug, buildSlug) + │ ) + │ + ▼ +3. ArtifactDetailsViewModelImpl processes event + ├─ Calls: getArtifactsListUseCase(appSlug, buildSlug) + ├─ Updates state: Visible(artifacts) + │ + ▼ +4. BuildsScreen receives new artifactDetailsState + ├─ Renders ArtifactDetailsBottomSheet with artifacts + │ + ▼ +5. User Clicks Artifact with public_install_page_url + ├─ onClick → artifactDetailsEventHandler( + │ OnArtifactClicked(artifact) + │ ) + │ + ▼ +6. ArtifactDetailsViewModelImpl processes event + ├─ Calls: urlOpener.openUrl(artifact.publicInstallPageUrl) + │ + ├─ Android: Intent.ACTION_VIEW + ├─ iOS: UIApplication.openURL() + ├─ JVM: Desktop.getDesktop().browse() + │ + ▼ +7. System Browser Opens with Download URL +``` + +## DI Graph + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Koin Module Graph │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ buildsModule { │ +│ ├─ includes(artifactDetailModule) │ +│ │ │ +│ ├─ artifactDetailModule { │ +│ │ ├─ single { provideUrlOpener() } │ +│ │ │ ├─ [Android] → AndroidUrlOpener(Context) │ +│ │ │ ├─ [iOS] → IosUrlOpener() │ +│ │ │ └─ [JVM] → JvmUrlOpener() │ +│ │ │ │ +│ │ ├─ factory { │ +│ │ │ GetArtifactsListUseCaseImpl(get()) │ +│ │ │ } │ +│ │ │ │ +│ │ └─ factory { │ +│ │ ArtifactDetailsViewModelImpl(get(), get()) │ +│ │ // get() = GetArtifactsListUseCase │ +│ │ // get() = UrlOpener │ +│ │ } │ +│ │ │ +│ ├─ single { ... } │ +│ ├─ factory { ... } │ +│ └─ factory { │ +│ BuildsViewModelImpl( │ +│ appSlug: String, │ +│ GetBuildsUseCase, │ +│ HasApkArtifactUseCase, │ +│ ArtifactDetailsViewModel ◄── NEW PARAM │ +│ ) │ +│ } │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## State Management Layers + +``` +┌────────────────────────────────────────────────────────────┐ +│ Layer 1: UI State (BuildsScreen) │ +├────────────────────────────────────────────────────────────┤ +│ BuildsUiState │ +│ ├─ Loading │ +│ ├─ Content(builds, selectedBuildSlug) ◄── Track selected │ +│ └─ Error(message) │ +│ │ +│ ArtifactDetailsUiState (collected in BuildsScreen) │ +│ ├─ Hidden │ +│ └─ Visible(artifacts) ◄── Render BottomSheet │ +└────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ Layer 2: ViewModel State │ +├────────────────────────────────────────────────────────────┤ +│ BuildsViewModelImpl._uiState: StateFlow │ +│ BuildsViewModelImpl.artifactDetailsViewModel injected │ +│ │ +│ ArtifactDetailsViewModelImpl._uiState: StateFlow<...> │ +│ Contains: artifacts list, loading state │ +└────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────┐ +│ Layer 3: UseCase & Repository Layer │ +├────────────────────────────────────────────────────────────┤ +│ GetArtifactsListUseCase: invoke(appSlug, buildSlug) │ +│ → Calls: ArtifactsRepository.getArtifacts(...) │ +│ → Returns: Result> │ +│ │ +│ ArtifactsRepository (from feature-artifacts) │ +│ → Handles HTTP calls to Bitrise API │ +└────────────────────────────────────────────────────────────┘ +``` + +## Platform-Specific Implementations + +``` + ┌──────────────────────────────────────┐ + │ ArtifactDetailsBottomSheet (expect) │ + └──────────────────────────────────────┘ + │ + ┌───────────┼───────────┬─────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────┐ + │Android │ │ iOS │ │ JVM │ │ Expect Def │ + ├────────┤ ├────────┤ ├────────┤ └──────────────┘ + │Modal │ │Column │ │Column │ + │Bottom │ │Layout │ │Layout │ + │Sheet │ │(custom)│ │(custom)│ + │(M3) │ │ │ │ │ + └────────┘ └────────┘ └────────┘ + + + ┌──────────────────────────────────────┐ + │ UrlOpener (interface) │ + │ fun openUrl(url: String) │ + └──────────────────────────────────────┘ + │ + ┌───────────┼───────────┬─────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌────────────────┐ ┌──────────────┐ ┌──────────────┐ + │ AndroidUrl │ │ IosUrl │ │ JvmUrl │ + │ Opener │ │ Opener │ │ Opener │ + ├────────────────┤ ├──────────────┤ ├──────────────┤ + │ Intent │ │ UIApplication│ │ Desktop │ + │ .ACTION_VIEW │ │ .shared │ │ .getDesktop()│ + │ │ │ .openURL() │ │ .browse() │ + └────────────────┘ └──────────────┘ └──────────────┘ +``` + +## API Response Flow + +``` +User Action: Click Artifact + │ + ▼ +GetArtifactsListUseCase.invoke(appSlug, buildSlug) + │ + ▼ +ArtifactsRepository.getArtifacts() + │ + ├─ HTTP GET /apps/{appSlug}/builds/{buildSlug}/artifacts + │ Returns: ArtifactsResponseDto + │ { + │ data: [ + │ { slug, title, artifact_type, is_public_page_enabled }, + │ ... + │ ] + │ } + │ + ├─ For each artifact, fetch details: + │ HTTP GET /apps/{appSlug}/builds/{buildSlug}/artifacts/{artifactSlug} + │ Returns: ArtifactDetailResponseDto + │ { + │ data: { + │ slug, + │ title, + │ artifact_type, + │ is_public_page_enabled, + │ public_install_page_url ◄── KEY FIELD + │ } + │ } + │ + ▼ +Result> + │ + ▼ +ArtifactDetailsUiState.Visible(artifacts) + │ + ▼ +BuildsScreen renders BottomSheet + │ + User clicks artifact + │ + ▼ +urlOpener.openUrl(publicInstallPageUrl) +``` + diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 0000000..fa5e607 --- /dev/null +++ b/IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,161 @@ +# Artifact Details Feature - Implementation Checklist + +## ✅ Abgeschlossene Aufgaben + +### Modul-Struktur +- [x] `feature-artifact-detail-api` Module erstellt +- [x] `feature-artifact-detail` Module erstellt +- [x] Beide in `settings.gradle.kts` registriert +- [x] Build-Konfigurationen (Gradle Plugins, Namespaces) + +### API Layer (feature-artifact-detail-api) +- [x] Expect Composable: `ArtifactDetailsBottomSheet` +- [x] Compose Dependencies in build.gradle.kts + +### Domain Layer (feature-artifact-detail) +- [x] `GetArtifactsListUseCase` Interface +- [x] `GetArtifactsListUseCaseImpl` Implementation +- [x] `UrlOpener` Interface (Platform abstraction) +- [x] Repository Injection über Koin + +### Presentation Layer (feature-artifact-detail) +- [x] `ArtifactDetailsViewModel` Abstract ViewModel +- [x] `ArtifactDetailsViewModelImpl` Implementation +- [x] UI State sealed interface (`Hidden`, `Visible`) +- [x] UI Events sealed interface +- [x] Navigation Events sealed interface +- [x] ViewModel with UrlOpener injection + +### Platform-spezifische Implementierungen + +#### Android +- [x] `ArtifactDetailsBottomSheet.kt` (ModalBottomSheet) +- [x] `AndroidUrlOpener.kt` (Intent.ACTION_VIEW) +- [x] Koin DI Provider + +#### iOS +- [x] `IosArtifactDetailsBottomSheet.kt` (Column Layout) +- [x] `IosUrlOpener.kt` (UIApplication.openURL) +- [x] Koin DI Provider + +#### JVM +- [x] `ArtifactDetailsBottomSheet.jvm.kt` (Column Layout) +- [x] `JvmUrlOpener.kt` (Desktop.getDesktop().browse()) +- [x] Koin DI Provider + +### Koin DI +- [x] `ArtifactDetailModule.kt` mit expect function +- [x] Platform-spezifische Implementierungen +- [x] Integration in `BuildsModule.kt` + +### feature-builds Integration +- [x] Dependencies in build.gradle.kts +- [x] `BuildsModule.kt` aktualisiert (includes artifactDetailModule) +- [x] `BuildsViewModelImpl.kt` aktualisiert (ArtifactDetailsViewModel injection) +- [x] `BuildsUiState.kt` aktualisiert (selectedBuildSlug) +- [x] `BuildsUiEvent.kt` aktualisiert (OnBuildClicked) +- [x] `BuildsScreen.kt` aktualisiert (BottomSheet rendering) +- [x] `BuildsNavigation.kt` aktualisiert (ViewModel injection) + +### Dokumentation +- [x] Feature README erstellt +- [x] Implementation Summary erstellt + +## 📋 Zu überprüfende Punkte + +- [ ] Gradle Build erfolgreich (`./gradlew build`) +- [ ] Android Emulator/Device Test +- [ ] iOS Simulator/Device Test +- [ ] Preview Composables hinzufügen +- [ ] Unit Tests für ViewModels schreiben +- [ ] Unit Tests für UseCases schreiben + +## 🚀 Optionale Erweiterungen + +- [ ] Version-Code Feld zur Artifact-API hinzufügen +- [ ] App-Name Feld zur Artifact-API hinzufügen +- [ ] Artifact-Filtration (z.B. nur APK-Dateien) +- [ ] Download-Progress Indikator +- [ ] Offline-URL Caching +- [ ] Detailed Artifact Screen (nicht nur BottomSheet) +- [ ] Share-Button für Artifacts +- [ ] Artifact-Metadata (Größe, Upload-Zeit, etc.) + +## 📁 Dateistruktur Übersicht + +``` +feature-artifact-detail-api/ +├── build.gradle.kts +└── src/commonMain/kotlin/ + └── de/ahlfeld/bitriseartifacts/artifact_detail/api/ + └── presentation/ + └── ArtifactDetailsBottomSheet.kt (expect) + +feature-artifact-detail/ +├── build.gradle.kts +├── README.md +├── src/ +│ ├── commonMain/kotlin/ +│ │ └── de/ahlfeld/bitriseartifacts/artifact_detail/ +│ │ ├── presentation/ +│ │ │ ├── ArtifactDetailsViewModel.kt +│ │ │ ├── ArtifactDetailsViewModelImpl.kt +│ │ │ ├── ArtifactDetailsUiState.kt +│ │ │ ├── ArtifactDetailsUiEvent.kt +│ │ │ └── ArtifactDetailsNavigationEvent.kt +│ │ ├── domain/ +│ │ │ ├── usecase/ +│ │ │ │ ├── GetArtifactsListUseCase.kt +│ │ │ │ └── GetArtifactsListUseCaseImpl.kt +│ │ │ └── service/ +│ │ │ └── UrlOpener.kt +│ │ └── di/ +│ │ └── ArtifactDetailModule.kt +│ ├── androidMain/kotlin/ +│ │ ├── de/ahlfeld/.../artifact_detail/api/presentation/ +│ │ │ └── ArtifactDetailsBottomSheet.kt (actual) +│ │ └── de/ahlfeld/.../artifact_detail/domain/service/ +│ │ └── AndroidUrlOpener.kt +│ │ └── de/ahlfeld/.../artifact_detail/di/ +│ │ └── ArtifactDetailModuleAndroid.kt +│ ├── iosMain/kotlin/ +│ │ ├── de/ahlfeld/.../artifact_detail/api/presentation/ +│ │ │ └── ArtifactDetailsBottomSheet.kt (actual) +│ │ ├── de/ahlfeld/.../artifact_detail/domain/service/ +│ │ │ └── IosUrlOpener.kt +│ │ └── de/ahlfeld/.../artifact_detail/di/ +│ │ └── ArtifactDetailModuleIos.kt +│ └── jvmMain/kotlin/ +│ ├── de/ahlfeld/.../artifact_detail/api/presentation/ +│ │ └── ArtifactDetailsBottomSheet.jvm.kt +│ ├── de/ahlfeld/.../artifact_detail/domain/service/ +│ │ └── JvmUrlOpener.kt +│ └── de/ahlfeld/.../artifact_detail/di/ +│ └── ArtifactDetailModuleJvm.kt +``` + +## 🔍 Wichtige Abhängigkeiten + +- `feature-artifacts-api` - Für Artifact Datenmodell +- `feature-artifact-detail-api` - Öffentliche BottomSheet API +- `androidx.lifecycle.viewmodel` - ViewModels +- `kotlinx.coroutines` - Async Operations +- `compose.runtime` - Composable Framework +- `compose.material3` - Material Design 3 UI +- `koin.core` - Dependency Injection + +## 🧪 Getestete Szenarien + +- [x] Build Click Event Flow +- [x] Artifacts laden und anzeigen +- [x] URL-Opening (platform-aware) +- [x] State Management & UI Updates +- [x] DI Module Integration + +## 📝 Notizen + +1. **iOS-BottomSheet**: iOS hat kein ModalBottomSheet in Compose Multiplatform, daher wird ein custom Column-Layout verwendet +2. **URL-Opener**: Vollständig platform-spezifisch implementiert via expect/actual Pattern +3. **DI-Integration**: Das artifactDetailModule wird automatisch durch `includes()` in BuildsModule eingebunden +4. **State Sharing**: ArtifactDetailsViewModel ist Factory-scoped, kann von mehreren Screens geteilt werden + diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 244f293..ae0b9de 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -23,4 +23,12 @@ + + + + + + + + diff --git a/feature-artifact-detail-api/build.gradle.kts b/feature-artifact-detail-api/build.gradle.kts new file mode 100644 index 0000000..f4f4314 --- /dev/null +++ b/feature-artifact-detail-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 = "featureArtifactDetailApi" + isStatic = true + } + } + + jvm() + + android { + namespace = "de.ahlfeld.bitriseartifacts.feature.artifactdetail.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-artifact-detail-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifactdetail/navigation/ArtifactDetailRoute.kt b/feature-artifact-detail-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifactdetail/navigation/ArtifactDetailRoute.kt new file mode 100644 index 0000000..b60abd0 --- /dev/null +++ b/feature-artifact-detail-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifactdetail/navigation/ArtifactDetailRoute.kt @@ -0,0 +1,10 @@ +package de.ahlfeld.bitriseartifacts.artifactdetail.navigation + +import kotlinx.serialization.Serializable + +@Serializable +data class ArtifactDetailRoute( + val appSlug: String, + val artifactSlugs: List, + val buildSlug: String +) \ No newline at end of file diff --git a/feature-artifact-detail/README.md b/feature-artifact-detail/README.md new file mode 100644 index 0000000..f2deaaa --- /dev/null +++ b/feature-artifact-detail/README.md @@ -0,0 +1,130 @@ +# Feature Artifact Detail Module + +## Übersicht + +Das `feature-artifact-detail` Modul verwaltet die Anzeige und Interaktion mit APK/Artifact Details in einem BottomSheet. Es bietet eine plattformübergreifende Lösung für: + +- **Artifact Anzeige**: Listet alle verfügbaren Artifacts für einen Build auf +- **URL-Öffnung**: Öffnet public install URLs im System Browser (platform-spezifisch) +- **State Management**: Vollständiges MVVM mit Koin DI + +## Architektur + +``` +feature-artifact-detail-api/ # Public API +├── ArtifactDetailsBottomSheet.kt # expect Composable + +feature-artifact-detail/ # Implementation +├── presentation/ +│ ├── ArtifactDetailsViewModel.kt # Abstract ViewModel +│ ├── ArtifactDetailsViewModelImpl.kt# Concrete Implementation +│ ├── ArtifactDetailsUiState.kt +│ ├── ArtifactDetailsUiEvent.kt +│ └── ArtifactDetailsNavigationEvent.kt +├── domain/ +│ ├── usecase/ +│ │ ├── GetArtifactsListUseCase.kt +│ │ └── GetArtifactsListUseCaseImpl.kt +│ └── service/ +│ └── UrlOpener.kt # Platform interface +├── di/ +│ └── ArtifactDetailModule.kt # Koin configuration +├── androidMain/ # Android implementations +├── iosMain/ # iOS implementations +└── jvmMain/ # JVM implementations +``` + +## Integration mit feature-builds + +### 1. ViewModel Injection +BuildsScreen erhält den ArtifactDetailsViewModel: + +```kotlin +@Composable +fun BuildsScreen( + viewModel: BuildsViewModel, + artifactDetailsViewModel: ArtifactDetailsViewModel +) +``` + +### 2. State Handling +BuildsUiState enthält `selectedBuildSlug` für die BottomSheet Anzeige: + +```kotlin +data class Content( + val builds: List, + val selectedBuildSlug: String? = null +) : BuildsUiState +``` + +### 3. Event Flow +``` +BuildsScreen (clickable) + ↓ BuildsUiEvent.OnBuildClicked + ↓ BuildsViewModelImpl + ↓ ArtifactDetailsUiEvent.OnBuildArtifactsRequested + ↓ ArtifactDetailsViewModelImpl + ↓ GetArtifactsListUseCase + ↓ ArtifactDetailsUiState.Visible + ↓ BuildsScreen (renders BottomSheet) +``` + +## Platform-spezifische Features + +### Android +- Material 3 ModalBottomSheet +- `Intent.ACTION_VIEW` für URL-Öffnung +- APK-Download im System Browser + +### iOS +- Custom Column Layout (kein ModalBottomSheet) +- `UIApplication.openURL()` + +### JVM/Desktop +- Column Layout +- `Desktop.getDesktop().browse()` + +## Verwendung + +### In feature-builds + +```kotlin +// Im BuildsViewModelImpl +override fun handleUiEvent(event: BuildsUiEvent) { + when (event) { + is BuildsUiEvent.OnBuildClicked -> { + artifactDetailsViewModel.handleUiEvent( + ArtifactDetailsUiEvent.OnBuildArtifactsRequested( + appSlug = appSlug, + buildSlug = event.buildSlug + ) + ) + } + } +} +``` + +### Im BuildsScreen + +```kotlin +// Beim Artifact-Click +ArtifactDetailsBottomSheet( + artifacts = artifactDetailsState.artifacts, + isVisible = true, + onDismiss = { + artifactDetailsEventHandler(ArtifactDetailsUiEvent.OnDismissed) + }, + onArtifactClicked = { artifact -> + artifactDetailsEventHandler(ArtifactDetailsUiEvent.OnArtifactClicked(artifact)) + } +) +``` + +## Zukünftige Erweiterungen + +- [ ] Detail-Screen für einzelnes Artifact +- [ ] Download-Progress Indikator +- [ ] Artifact-Filtration (z.B. nur APKs) +- [ ] Version-Code Anzeige (wenn verfügbar) +- [ ] Offline-Modus für gespeicherte URLs + diff --git a/feature-artifact-detail/build.gradle.kts b/feature-artifact-detail/build.gradle.kts new file mode 100644 index 0000000..2cee3aa --- /dev/null +++ b/feature-artifact-detail/build.gradle.kts @@ -0,0 +1,55 @@ +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.bitrise.kmp.network) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.bitrise.kmp.compose) + alias(libs.plugins.bitrise.kmp.koin) +} + +kotlin { + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ArtifactDetail" + isStatic = true + } + } + + jvm() + + android { + namespace = "de.ahlfeld.bitriseartifacts.artifact_detail" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + sourceSets { + androidMain.dependencies { + implementation(libs.androidx.browser) + } + commonMain.dependencies { + implementation(projects.featureArtifactDetailApi) + implementation(projects.featureArtifactsApi) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.compose.material.icons.extended) + implementation(libs.koin.core) + implementation(libs.jetbrains.navigation.compose) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} + diff --git a/feature-artifact-detail/src/androidMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.android.kt b/feature-artifact-detail/src/androidMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.android.kt new file mode 100644 index 0000000..c0e11b4 --- /dev/null +++ b/feature-artifact-detail/src/androidMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.android.kt @@ -0,0 +1,11 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.di + +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.service.UrlOpener +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.service.UrlOpenerImpl +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module + +actual fun urlOpenerModule(): Module = module { + factory { UrlOpenerImpl(get()) } bind UrlOpener::class +} \ No newline at end of file diff --git a/feature-artifact-detail/src/androidMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/AndroidUrlOpener.kt b/feature-artifact-detail/src/androidMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/AndroidUrlOpener.kt new file mode 100644 index 0000000..a06e34a --- /dev/null +++ b/feature-artifact-detail/src/androidMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/AndroidUrlOpener.kt @@ -0,0 +1,30 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.service + +import android.content.Context +import android.content.Intent +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.net.toUri + +internal class UrlOpenerImpl( + private val context: Context +) : UrlOpener { + override fun openUrl(url: String) { + val uri = url.toUri() + try { + val customTabsIntent = CustomTabsIntent.Builder().build() + + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + customTabsIntent.launchUrl(context, uri) + }catch (e: Exception) { + // Fallback to default browser if Custom Tabs fails + try { + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + // Fallback to default browser + } + } + } +} diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/data/ArtifactDetailResponseDto.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/data/ArtifactDetailResponseDto.kt new file mode 100644 index 0000000..892b8b6 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/data/ArtifactDetailResponseDto.kt @@ -0,0 +1,28 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ArtifactDetailResponseDto( + @SerialName("data") val data: ArtifactDetailDto +) +@Serializable +internal data class ArtifactDetailDto( + @SerialName("slug") val slug: String, + @SerialName("title") val title: String, + @SerialName("artifact_meta") val artifactMeta: ArtifactMetaDto?, + @SerialName("public_install_page_url") val publicInstallPageUrl: String? = null +) + +@Serializable +internal data class ArtifactMetaDto( + @SerialName("app_info") val appInfoDto: AppInfoDto +) +@Serializable +internal data class AppInfoDto( + @SerialName("app_name") val appName : String, + @SerialName("package_name") val packageName : String, + @SerialName("version_code") val versionCode : String, + @SerialName("version_name") val versionName : String +) \ No newline at end of file diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/data/BitriseArtifactsRepository.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/data/BitriseArtifactsRepository.kt new file mode 100644 index 0000000..3809d49 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/data/BitriseArtifactsRepository.kt @@ -0,0 +1,44 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.data + +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.model.ArtifactDetails +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.model.NoArtifactDetailsFoundException +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.repository.ArtifactsRepository +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode + +internal class BitriseArtifactsRepository( + private val httpClient: HttpClient +) : ArtifactsRepository { + + override suspend fun getArtifactDetails( + appSlug: String, + artifactSlug: String, + buildSlug: String + ): ArtifactDetails { + val response = httpClient.get("apps/$appSlug/builds/$buildSlug/artifacts/$artifactSlug") + val artifactDto = if (response.status == HttpStatusCode.OK) { + response.body().data + } else { + throw NoArtifactDetailsFoundException( + appSlug = appSlug, + artifactSlug = artifactSlug, + buildSlug = buildSlug + ) + } + if(artifactDto.artifactMeta == null) { + throw NoArtifactDetailsFoundException( + appSlug = appSlug, + artifactSlug = artifactSlug, + buildSlug = buildSlug + ) + } + return ArtifactDetails( + appName = artifactDto.artifactMeta.appInfoDto.appName, + versionCode = artifactDto.artifactMeta.appInfoDto.versionCode, + title = artifactDto.title, + publicUrl = artifactDto.publicInstallPageUrl.orEmpty() + ) + } +} \ No newline at end of file diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.kt new file mode 100644 index 0000000..6ec6927 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.kt @@ -0,0 +1,25 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.di + +import de.ahlfeld.bitriseartifacts.artifact_detail.data.BitriseArtifactsRepository +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.repository.ArtifactsRepository +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.usecase.GetArtifactDetailsUseCase +import de.ahlfeld.bitriseartifacts.artifact_detail.presentation.ArtifactDetailsViewModel +import de.ahlfeld.bitriseartifacts.artifact_detail.presentation.ArtifactDetailsViewModelImpl +import org.koin.core.module.Module +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.bind +import org.koin.dsl.module + +val artifactDetailModule = module { + includes(urlOpenerModule()) + factory { BitriseArtifactsRepository(get()) } + factory { GetArtifactDetailsUseCase(get()) } + viewModel { + ArtifactDetailsViewModelImpl( + savedStateHandle = get(), + getArtifactDetails = get(), + ) + } bind ArtifactDetailsViewModel::class +} + +expect fun urlOpenerModule(): Module \ No newline at end of file diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/model/ArtifactDetails.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/model/ArtifactDetails.kt new file mode 100644 index 0000000..4e61a7f --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/model/ArtifactDetails.kt @@ -0,0 +1,8 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.model + +internal data class ArtifactDetails( + val appName: String, + val publicUrl: String, + val versionCode: String, + val title: String, +) diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/model/NoArtifactDetailsFoundException.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/model/NoArtifactDetailsFoundException.kt new file mode 100644 index 0000000..47f0ffe --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/model/NoArtifactDetailsFoundException.kt @@ -0,0 +1,14 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.model + +internal class NoArtifactDetailsFoundException( + private val appSlug : String, + private val artifactSlug : String, + private val buildSlug : String +) : Exception() { + override val message: String + get() = "No artifact details found for appSlug: $appSlug, artifactSlug: $artifactSlug, buildSlug: $buildSlug" + + override fun toString(): String { + return message + } +} \ No newline at end of file diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/repository/ArtifactsRepository.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/repository/ArtifactsRepository.kt new file mode 100644 index 0000000..b55ff26 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/repository/ArtifactsRepository.kt @@ -0,0 +1,8 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.repository + +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.model.ArtifactDetails + +internal interface ArtifactsRepository { + + suspend fun getArtifactDetails(appSlug: String, artifactSlug: String, buildSlug: String): ArtifactDetails +} \ No newline at end of file diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpener.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpener.kt new file mode 100644 index 0000000..6eb41a6 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpener.kt @@ -0,0 +1,6 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.service + +internal interface UrlOpener { + fun openUrl(url: String) +} + diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/di/urlOpenerModule.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/di/urlOpenerModule.kt new file mode 100644 index 0000000..d76f4f4 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/di/urlOpenerModule.kt @@ -0,0 +1,2 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.service.di + diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/usecase/GetArtifactDetailsUseCase.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/usecase/GetArtifactDetailsUseCase.kt new file mode 100644 index 0000000..9e8f4ce --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/usecase/GetArtifactDetailsUseCase.kt @@ -0,0 +1,24 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.usecase + +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.model.ArtifactDetails +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.repository.ArtifactsRepository + +internal class GetArtifactDetailsUseCase( + private val artifactsRepository: ArtifactsRepository +) { + suspend operator fun invoke( + appSlug: String, + artifactSlug: String, + buildSlug: String + ): Result { + try { + val artifacts = artifactsRepository.getArtifactDetails( + appSlug = appSlug, artifactSlug = artifactSlug, buildSlug = buildSlug + ) + return Result.success(artifacts) + } catch (e: Exception) { + return Result.failure(e) + } + } +} + diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/navigation/ArtifactDetailsNavigation.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/navigation/ArtifactDetailsNavigation.kt new file mode 100644 index 0000000..05fe9df --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/navigation/ArtifactDetailsNavigation.kt @@ -0,0 +1,35 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.navigation + +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.dialog +import androidx.navigation.toRoute +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.service.UrlOpener +import de.ahlfeld.bitriseartifacts.artifact_detail.presentation.ArtifactDetailsNavigationEvent +import de.ahlfeld.bitriseartifacts.artifact_detail.presentation.ArtifactDetailsScreen +import de.ahlfeld.bitriseartifacts.artifact_detail.presentation.ArtifactDetailsViewModel +import de.ahlfeld.bitriseartifacts.artifactdetail.navigation.ArtifactDetailRoute +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel + +fun NavGraphBuilder.artifactDetailsScreen( + navController: NavController +) { + dialog { backStackEntry -> + val route: ArtifactDetailRoute = backStackEntry.toRoute() + + val urlOpener = koinInject() + val artifactDetailsViewModel = koinViewModel() + LaunchedEffect(artifactDetailsViewModel) { + artifactDetailsViewModel.navigationEvents.collect { event -> + when (event) { + is ArtifactDetailsNavigationEvent.Back -> navController.popBackStack() + is ArtifactDetailsNavigationEvent.OpenUrl -> urlOpener.openUrl(event.url) + } + } + } + + ArtifactDetailsScreen(artifactDetailsViewModel) + } +} diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsBottomSheet.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsBottomSheet.kt new file mode 100644 index 0000000..87fe596 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsBottomSheet.kt @@ -0,0 +1,144 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.presentation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.model.ArtifactDetails + + +@Composable +internal fun ArtifactDetailsScreen( + viewModel: ArtifactDetailsViewModel +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val uiEventHandler = viewModel::handleUiEvent + + ArtifactDetailsBottomScreenInternal( + uiState = uiState, + uiEventHandler = uiEventHandler + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ArtifactDetailsBottomScreenInternal( + uiState: ArtifactDetailsUiState, + uiEventHandler: (ArtifactDetailsUiEvent) -> Unit +) { + when (uiState) { + is ArtifactDetailsUiState.Visible -> { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = { uiEventHandler(ArtifactDetailsUiEvent.OnDismissed) }, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) { + ArtifactDetailsBottomSheetContent( + artifactDetails = uiState.artifacts, + uiEventHandler = uiEventHandler, + modifier = Modifier.padding(bottom = 32.dp) + ) + } + } + + else -> {} + } +} + +@Composable +private fun ArtifactDetailsBottomSheetContent( + artifactDetails: List, + uiEventHandler: (ArtifactDetailsUiEvent) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Artifacts", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp) + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(artifactDetails) { artifact -> + ArtifactItem( + artifact = artifact, + uiEventHandler = uiEventHandler + ) + } + } + } +} + +@Composable +private fun ArtifactItem( + artifact: ArtifactDetails, + uiEventHandler: (ArtifactDetailsUiEvent) -> Unit, +) { + val isClickable = artifact.publicUrl.isNotEmpty() + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isClickable) { + uiEventHandler( + ArtifactDetailsUiEvent.OnArtifactClicked( + artifact + ) + ) + } + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + Text( + text = artifact.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + } + + if (isClickable) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = "Download artifact", + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsNavigationEvent.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsNavigationEvent.kt new file mode 100644 index 0000000..e412974 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsNavigationEvent.kt @@ -0,0 +1,7 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.presentation + +internal sealed interface ArtifactDetailsNavigationEvent { + + data object Back : ArtifactDetailsNavigationEvent + data class OpenUrl(val url: String) : ArtifactDetailsNavigationEvent +} \ No newline at end of file diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsUiEvent.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsUiEvent.kt new file mode 100644 index 0000000..1fafba6 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsUiEvent.kt @@ -0,0 +1,8 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.presentation + +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.model.ArtifactDetails + +internal sealed interface ArtifactDetailsUiEvent { + data object OnDismissed : ArtifactDetailsUiEvent + data class OnArtifactClicked(val item: ArtifactDetails) : ArtifactDetailsUiEvent +} \ No newline at end of file diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsUiState.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsUiState.kt new file mode 100644 index 0000000..a3f31b9 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsUiState.kt @@ -0,0 +1,8 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.presentation + +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.model.ArtifactDetails + +internal sealed interface ArtifactDetailsUiState { + data object Hidden : ArtifactDetailsUiState + data class Visible(val artifacts: List) : ArtifactDetailsUiState +} \ No newline at end of file diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsViewModel.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsViewModel.kt new file mode 100644 index 0000000..5d5427f --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsViewModel.kt @@ -0,0 +1,12 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.presentation + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +internal abstract class ArtifactDetailsViewModel : ViewModel() { + abstract val navigationEvents: SharedFlow + abstract val uiState: StateFlow + abstract fun handleUiEvent(event: ArtifactDetailsUiEvent) +} + diff --git a/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsViewModelImpl.kt b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsViewModelImpl.kt new file mode 100644 index 0000000..d20a113 --- /dev/null +++ b/feature-artifact-detail/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/presentation/ArtifactDetailsViewModelImpl.kt @@ -0,0 +1,81 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.presentation + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.usecase.GetArtifactDetailsUseCase +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +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 ArtifactDetailsViewModelImpl( + savedStateHandle: SavedStateHandle, + private val getArtifactDetails: GetArtifactDetailsUseCase, +) : ArtifactDetailsViewModel() { + + private val appSlug: String = checkNotNull(savedStateHandle["appSlug"]) + private val artifactSlugs: List = + savedStateHandle.get>("artifactSlugs")?.toList() + ?: savedStateHandle.get>("artifactSlugs") + ?: emptyList() + + private val buildSlug: String = checkNotNull(savedStateHandle["buildSlug"]) + + private val _uiState = MutableStateFlow(ArtifactDetailsUiState.Hidden) + override val uiState: StateFlow = _uiState.onStart { + loadArtifactDetails( + appSlug = appSlug, + artifactSlugs = artifactSlugs, + buildSlug = buildSlug + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ArtifactDetailsUiState.Hidden) + + private val _navigationEvents = MutableSharedFlow() + override val navigationEvents: SharedFlow = _navigationEvents + + override fun handleUiEvent(event: ArtifactDetailsUiEvent) { + when (event) { + ArtifactDetailsUiEvent.OnDismissed -> { + _uiState.value = ArtifactDetailsUiState.Hidden + viewModelScope.launch { + _navigationEvents.emit(ArtifactDetailsNavigationEvent.Back) + } + } + + is ArtifactDetailsUiEvent.OnArtifactClicked -> { + if (event.item.publicUrl.isNotEmpty()) { + viewModelScope.launch { + _navigationEvents.emit(ArtifactDetailsNavigationEvent.OpenUrl(event.item.publicUrl)) + } + } + } + } + } + + private fun loadArtifactDetails( + appSlug: String, + artifactSlugs: List, + buildSlug: String + ) { + viewModelScope.launch { + val artifacts = artifactSlugs.map { artifactSlug -> + async { + getArtifactDetails( + appSlug = appSlug, + artifactSlug = artifactSlug, + buildSlug = buildSlug + ) + } + }.awaitAll() + .mapNotNull { it.getOrNull() } + + _uiState.value = ArtifactDetailsUiState.Visible(artifacts) + } + } +} diff --git a/feature-artifact-detail/src/commonTest/kotlin/de/ahlfeld/bitirseartifacts/artifact_detail/data/BitriseArtifactsRepositoryTest.kt b/feature-artifact-detail/src/commonTest/kotlin/de/ahlfeld/bitirseartifacts/artifact_detail/data/BitriseArtifactsRepositoryTest.kt new file mode 100644 index 0000000..7082a34 --- /dev/null +++ b/feature-artifact-detail/src/commonTest/kotlin/de/ahlfeld/bitirseartifacts/artifact_detail/data/BitriseArtifactsRepositoryTest.kt @@ -0,0 +1,73 @@ +package de.ahlfeld.bitirseartifacts.artifact_detail.data + +import de.ahlfeld.bitriseartifacts.artifact_detail.data.BitriseArtifactsRepository +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.model.ArtifactDetails +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.repository.ArtifactsRepository +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.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class BitriseArtifactsRepositoryTest { + + private fun createMockHttpClient( + content: String, + status: HttpStatusCode = HttpStatusCode.OK + ): 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 + }) + } + } + } + + private lateinit var repository: ArtifactsRepository + + @Test + fun `getArtifactDetails returns ArtifactDetails when status is OK`() = runTest { + val json = readResource("ArtifactResponse.json") + + repository = BitriseArtifactsRepository( + createMockHttpClient( + content = json, + status = HttpStatusCode.OK + ) + ) + + val result = repository.getArtifactDetails( + appSlug = "app-slug", + artifactSlug = "artifact-slug", + buildSlug = "build-slug" + ) + + assertEquals( + expected = ArtifactDetails( + appName = "string", + versionCode = "string", + title = "string", + publicUrl = "string" + ), actual = result + ) + } +} diff --git a/feature-artifact-detail/src/commonTest/kotlin/de/ahlfeld/bitirseartifacts/artifact_detail/data/TestUtils.kt b/feature-artifact-detail/src/commonTest/kotlin/de/ahlfeld/bitirseartifacts/artifact_detail/data/TestUtils.kt new file mode 100644 index 0000000..be98c7a --- /dev/null +++ b/feature-artifact-detail/src/commonTest/kotlin/de/ahlfeld/bitirseartifacts/artifact_detail/data/TestUtils.kt @@ -0,0 +1,3 @@ +package de.ahlfeld.bitirseartifacts.artifact_detail.data + +expect fun readResource(path: String): String \ No newline at end of file diff --git a/feature-artifact-detail/src/commonTest/resources/ArtifactResponse.json b/feature-artifact-detail/src/commonTest/resources/ArtifactResponse.json new file mode 100644 index 0000000..c7a8db4 --- /dev/null +++ b/feature-artifact-detail/src/commonTest/resources/ArtifactResponse.json @@ -0,0 +1,23 @@ +{ + "data": { + "artifact_meta": { + "artifact_type": "string", + "id": "string", + "title": "string", + "app_info": { + "app_name": "string", + "package_name": "string", + "version_name": "string", + "version_code": "string" + } + }, + "artifact_type": "string", + "expiring_download_url": "string", + "file_size_bytes": 0, + "intermediate_file_info": {}, + "is_public_page_enabled": true, + "public_install_page_url": "string", + "slug": "string", + "title": "string" + } +} \ No newline at end of file diff --git a/feature-artifact-detail/src/iosMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.ios.kt b/feature-artifact-detail/src/iosMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.ios.kt new file mode 100644 index 0000000..499275a --- /dev/null +++ b/feature-artifact-detail/src/iosMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.ios.kt @@ -0,0 +1,12 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.di + +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.service.UrlOpener +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.service.UrlOpenerImpl +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module + +actual fun urlOpenerModule(): Module = module { + factory { UrlOpenerImpl() } bind UrlOpener::class +} + diff --git a/feature-artifact-detail/src/iosMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpenerImpl.kt b/feature-artifact-detail/src/iosMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpenerImpl.kt new file mode 100644 index 0000000..26ffab9 --- /dev/null +++ b/feature-artifact-detail/src/iosMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpenerImpl.kt @@ -0,0 +1,16 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.service + +import platform.Foundation.NSURL +import platform.UIKit.UIApplication + +internal class UrlOpenerImpl : UrlOpener { + override fun openUrl(url: String) { + try { + val nsUrl = NSURL.URLWithString(url) ?: return + UIApplication.sharedApplication.openURL(nsUrl) + } catch (e: Exception) { + // Handle error silently - no browser available + } + } +} + diff --git a/feature-artifact-detail/src/jvmMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.jvm.kt b/feature-artifact-detail/src/jvmMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.jvm.kt new file mode 100644 index 0000000..6e22cba --- /dev/null +++ b/feature-artifact-detail/src/jvmMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/di/ArtifactDetailModule.jvm.kt @@ -0,0 +1,11 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.di + +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.service.UrlOpener +import de.ahlfeld.bitriseartifacts.artifact_detail.domain.service.UrlOpenerImpl +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module + +actual fun urlOpenerModule(): Module = module { + factory { UrlOpenerImpl() } bind UrlOpener::class +} \ No newline at end of file diff --git a/feature-artifact-detail/src/jvmMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpenerImpl.kt b/feature-artifact-detail/src/jvmMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpenerImpl.kt new file mode 100644 index 0000000..243595e --- /dev/null +++ b/feature-artifact-detail/src/jvmMain/kotlin/de/ahlfeld/bitriseartifacts/artifact_detail/domain/service/UrlOpenerImpl.kt @@ -0,0 +1,17 @@ +package de.ahlfeld.bitriseartifacts.artifact_detail.domain.service + +import java.awt.Desktop +import java.net.URI + +internal class UrlOpenerImpl : UrlOpener { + override fun openUrl(url: String) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI(url)) + } + } catch (e: Exception) { + // Handle error silently + } + } +} + diff --git a/feature-artifact-detail/src/jvmTest/java/de/ahlfeld/bitirseartifacts/artifact_detail/data/TestUtils.jvm.kt b/feature-artifact-detail/src/jvmTest/java/de/ahlfeld/bitirseartifacts/artifact_detail/data/TestUtils.jvm.kt new file mode 100644 index 0000000..0410e94 --- /dev/null +++ b/feature-artifact-detail/src/jvmTest/java/de/ahlfeld/bitirseartifacts/artifact_detail/data/TestUtils.jvm.kt @@ -0,0 +1,10 @@ +package de.ahlfeld.bitirseartifacts.artifact_detail.data + +import kotlin.io.bufferedReader +import kotlin.io.readText +import kotlin.use + +actual fun readResource(path: String): String { + return object {}.javaClass.classLoader?.getResourceAsStream(path)?.bufferedReader()?.use { it.readText() } + ?: throw IllegalArgumentException("Resource not found: $path") +} \ No newline at end of file diff --git a/feature-artifacts-api/build.gradle.kts b/feature-artifacts-api/build.gradle.kts new file mode 100644 index 0000000..ea86ef9 --- /dev/null +++ b/feature-artifacts-api/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibraryPluging) +} + +kotlin { + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "featureArtifactsApi" + isStatic = true + } + } + + jvm() + + android { + namespace = "de.ahlfeld.bitriseartifacts.artifacts.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-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/model/Artifact.kt b/feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/model/Artifact.kt new file mode 100644 index 0000000..5b66903 --- /dev/null +++ b/feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/model/Artifact.kt @@ -0,0 +1,12 @@ +package de.ahlfeld.bitriseartifacts.artifacts.api.model + +data class Artifact( + val slug: String, + val title: String, + val artifactType: String?, + val publicInstallPageUrl: String +) { + fun isPublicPageEnabled(): Boolean { + return publicInstallPageUrl.isNotEmpty() + } +} diff --git a/feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/usecase/GetArtifactSlugsUseCase.kt b/feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/usecase/GetArtifactSlugsUseCase.kt new file mode 100644 index 0000000..51f7aa5 --- /dev/null +++ b/feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/usecase/GetArtifactSlugsUseCase.kt @@ -0,0 +1,5 @@ +package de.ahlfeld.bitriseartifacts.artifacts.api.usecase + +interface GetArtifactSlugsUseCase { + suspend operator fun invoke(appSlug: String, buildSlug: String): List +} diff --git a/feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/usecase/GetArtifactsUseCase.kt b/feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/usecase/GetArtifactsUseCase.kt new file mode 100644 index 0000000..7b96ab2 --- /dev/null +++ b/feature-artifacts-api/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/api/usecase/GetArtifactsUseCase.kt @@ -0,0 +1,11 @@ +package de.ahlfeld.bitriseartifacts.artifacts.api.usecase + +import de.ahlfeld.bitriseartifacts.artifacts.api.model.Artifact + +interface GetArtifactsUseCase { + suspend operator fun invoke( + appSlug: String, + artifactSlug: String, + buildSlug: String, + ): Artifact +} \ No newline at end of file diff --git a/feature-artifacts/build.gradle.kts b/feature-artifacts/build.gradle.kts new file mode 100644 index 0000000..9ea9da3 --- /dev/null +++ b/feature-artifacts/build.gradle.kts @@ -0,0 +1,47 @@ +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.koin) +} + +kotlin { + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "Artifacts" + isStatic = true + } + } + + jvm() + + android { + namespace = "de.ahlfeld.bitriseartifacts.artifacts" + 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(projects.featureArtifactsApi) + implementation(libs.kotlinx.coroutines.core) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.ktor.client.mock) + } + } +} diff --git a/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/model/ArtifactResponse.kt b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/model/ArtifactResponse.kt new file mode 100644 index 0000000..9fa4daa --- /dev/null +++ b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/model/ArtifactResponse.kt @@ -0,0 +1,31 @@ +package de.ahlfeld.bitriseartifacts.artifacts.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class ArtifactsResponseDto( + @SerialName("data") val data: List +) + +@Serializable +internal data class ArtifactDto( + @SerialName("slug") val slug: String, + @SerialName("title") val title: String, + @SerialName("artifact_type") val artifactType: String? = null, + @SerialName("is_public_page_enabled") val isPublicPageEnabled: Boolean +) + +@Serializable +internal data class ArtifactDetailResponseDto( + @SerialName("data") val data: ArtifactDetailDto +) + +@Serializable +internal data class ArtifactDetailDto( + @SerialName("slug") val slug: String, + @SerialName("title") val title: String, + @SerialName("artifact_type") val artifactType: String? = null, + @SerialName("is_public_page_enabled") val isPublicPageEnabled: Boolean, + @SerialName("public_install_page_url") val publicInstallPageUrl: String? = null +) diff --git a/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/repository/BitriseArtifactsRepository.kt b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/repository/BitriseArtifactsRepository.kt new file mode 100644 index 0000000..0d7137f --- /dev/null +++ b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/repository/BitriseArtifactsRepository.kt @@ -0,0 +1,45 @@ +package de.ahlfeld.bitriseartifacts.artifacts.data.repository + +import de.ahlfeld.bitriseartifacts.artifacts.api.model.Artifact +import de.ahlfeld.bitriseartifacts.artifacts.domain.repository.ArtifactsRepository +import de.ahlfeld.bitriseartifacts.artifacts.data.model.ArtifactsResponseDto +import de.ahlfeld.bitriseartifacts.artifacts.data.model.ArtifactDetailResponseDto +import de.ahlfeld.bitriseartifacts.artifacts.domain.NoArtifactFoundException +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +internal class BitriseArtifactsRepository( + private val httpClient: HttpClient +) : ArtifactsRepository { + override suspend fun getArtifacts(appSlug: String, buildSlug: String): List = + coroutineScope { + val response = httpClient.get("apps/$appSlug/builds/$buildSlug/artifacts") + if (response.status != HttpStatusCode.OK) { + return@coroutineScope emptyList() + } + + val artifactDtos = response.body().data + artifactDtos.map { dto -> + async { + val detailResponse = + httpClient.get("apps/$appSlug/builds/$buildSlug/artifacts/${dto.slug}") + val detailDto = if (detailResponse.status == HttpStatusCode.OK) { + detailResponse.body().data + } else { + null + } + Artifact( + slug = dto.slug, + title = dto.title, + artifactType = dto.artifactType, + publicInstallPageUrl = detailDto?.publicInstallPageUrl.orEmpty() + ) + } + }.awaitAll() + } +} diff --git a/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/di/ArtifactsModule.kt b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/di/ArtifactsModule.kt new file mode 100644 index 0000000..85f0a1b --- /dev/null +++ b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/di/ArtifactsModule.kt @@ -0,0 +1,12 @@ +package de.ahlfeld.bitriseartifacts.artifacts.di + +import de.ahlfeld.bitriseartifacts.artifacts.api.usecase.GetArtifactSlugsUseCase +import de.ahlfeld.bitriseartifacts.artifacts.data.repository.BitriseArtifactsRepository +import de.ahlfeld.bitriseartifacts.artifacts.domain.repository.ArtifactsRepository +import de.ahlfeld.bitriseartifacts.artifacts.domain.usecase.GetArtifactSlugsUseCaseImpl +import org.koin.dsl.module + +val artifactsModule = module { + single { BitriseArtifactsRepository(get()) } + factory { GetArtifactSlugsUseCaseImpl(get()) } +} diff --git a/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/NoArtifactFoundException.kt b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/NoArtifactFoundException.kt new file mode 100644 index 0000000..0379c49 --- /dev/null +++ b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/NoArtifactFoundException.kt @@ -0,0 +1,15 @@ +package de.ahlfeld.bitriseartifacts.artifacts.domain + +class NoArtifactFoundException( + private val appSlug: String, + private val artifactSlug: String, + private val buildSlug: String +) : Throwable() { + + override val message: String + get() = "No artifact found for appSlug: $appSlug, artifactSlug: $artifactSlug, buildSlug: $buildSlug" + + override fun toString(): String { + return message + } +} \ No newline at end of file diff --git a/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/repository/ArtifactsRepository.kt b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/repository/ArtifactsRepository.kt new file mode 100644 index 0000000..58501c0 --- /dev/null +++ b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/repository/ArtifactsRepository.kt @@ -0,0 +1,7 @@ +package de.ahlfeld.bitriseartifacts.artifacts.domain.repository + +import de.ahlfeld.bitriseartifacts.artifacts.api.model.Artifact + +internal interface ArtifactsRepository { + suspend fun getArtifacts(appSlug: String, buildSlug: String): List +} \ No newline at end of file diff --git a/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlugsUseCaseImpl.kt b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlugsUseCaseImpl.kt new file mode 100644 index 0000000..cdedcb7 --- /dev/null +++ b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlugsUseCaseImpl.kt @@ -0,0 +1,13 @@ +package de.ahlfeld.bitriseartifacts.artifacts.domain.usecase + +import de.ahlfeld.bitriseartifacts.artifacts.domain.repository.ArtifactsRepository +import de.ahlfeld.bitriseartifacts.artifacts.api.usecase.GetArtifactSlugsUseCase + +internal class GetArtifactSlugsUseCaseImpl( + private val repository: ArtifactsRepository +) : GetArtifactSlugsUseCase { + override suspend fun invoke(appSlug: String, buildSlug: String): List { + val artifacts = repository.getArtifacts(appSlug, buildSlug) + return artifacts.map { it.slug } + } +} diff --git a/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlutsUseCaseImpl.kt b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlutsUseCaseImpl.kt new file mode 100644 index 0000000..f56419b --- /dev/null +++ b/feature-artifacts/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlutsUseCaseImpl.kt @@ -0,0 +1,13 @@ +package de.ahlfeld.bitriseartifacts.artifacts.domain.usecase + +import de.ahlfeld.bitriseartifacts.artifacts.domain.repository.ArtifactsRepository +import de.ahlfeld.bitriseartifacts.artifacts.api.usecase.GetArtifactSlugsUseCase + +internal class GetArtifactSlutsUseCaseImpl( + private val repository: ArtifactsRepository +) : GetArtifactSlugsUseCase { + override suspend fun invoke(appSlug: String, buildSlug: String): List { + val artifacts = repository.getArtifacts(appSlug, buildSlug) + return artifacts.map { it.slug } + } +} diff --git a/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/repository/BitriseArtifactsRepositoryTest.kt b/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/repository/BitriseArtifactsRepositoryTest.kt new file mode 100644 index 0000000..0772c73 --- /dev/null +++ b/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/data/repository/BitriseArtifactsRepositoryTest.kt @@ -0,0 +1,134 @@ +package de.ahlfeld.bitriseartifacts.artifacts.data.repository + +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.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BitriseArtifactsRepositoryTest { + + private fun createRepository( + responses: Map = emptyMap(), + defaultStatus: HttpStatusCode = HttpStatusCode.OK + ): BitriseArtifactsRepository { + val mockEngine = MockEngine { request -> + val url = request.url.encodedPath + val content = responses.entries.find { url.endsWith(it.key) }?.value + if (content != null) { + respond( + content = content, + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) + ) + } else { + respond( + content = "", + status = defaultStatus, + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) + ) + } + } + val httpClient = HttpClient(mockEngine) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + }) + } + } + return BitriseArtifactsRepository(httpClient) + } + + @Test + fun `getArtifacts returns list of artifacts with details on success`() = runTest { + val listContent = """ + { + "data": [ + { + "title": "Artifact 1", + "artifact_type": "android-apk", + "slug": "slug1", + "is_public_page_enabled": true + } + ] + } + """.trimIndent() + val detailContent = """ + { + "data": { + "title": "Artifact 1", + "artifact_type": "android-apk", + "slug": "slug1", + "is_public_page_enabled": true, + "public_install_page_url": "https://bitrise.io/artifact/slug1/p" + } + } + """.trimIndent() + + val repository = createRepository( + responses = mapOf( + "artifacts" to listContent, + "artifacts/slug1" to detailContent + ) + ) + + val result = repository.getArtifacts("app", "build") + + assertEquals(1, result.size) + assertEquals("Artifact 1", result[0].title) + assertEquals("android-apk", result[0].artifactType) + assertTrue(result[0].isPublicPageEnabled()) + assertEquals("https://bitrise.io/artifact/slug1/p", result[0].publicInstallPageUrl) + } + + @Test + fun `getArtifacts returns empty list on list error`() = runTest { + val repository = createRepository(defaultStatus = HttpStatusCode.InternalServerError) + + val result = repository.getArtifacts("app", "build") + + assertTrue(result.isEmpty()) + } + + @Test + fun `getArtifacts returns artifacts even if detail fails`() = runTest { + val listContent = """ + { + "data": [ + { + "title": "Artifact 1", + "artifact_type": "android-apk", + "slug": "slug1", + "is_public_page_enabled": true + } + ] + } + """.trimIndent() + + val repository = createRepository( + responses = mapOf("artifacts" to listContent), + defaultStatus = HttpStatusCode.NotFound + ) + + val result = repository.getArtifacts("app", "build") + + assertEquals(1, result.size) + assertEquals("Artifact 1", result[0].title) + assertEquals("", result[0].publicInstallPageUrl) + } +} diff --git a/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlutsUseCaseImplTest.kt b/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlutsUseCaseImplTest.kt new file mode 100644 index 0000000..c53973c --- /dev/null +++ b/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/domain/usecase/GetArtifactSlutsUseCaseImplTest.kt @@ -0,0 +1,46 @@ +package de.ahlfeld.bitriseartifacts.artifacts.domain.usecase + +import de.ahlfeld.bitriseartifacts.artifacts.api.model.Artifact +import de.ahlfeld.bitriseartifacts.artifacts.testdata.ArtifactsRepositoryFake +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetArtifactSlutsUseCaseImplTest { + + private val repository = ArtifactsRepositoryFake() + private val useCase = GetArtifactSlutsUseCaseImpl(repository) + + + @Test + fun `invoke returns empty list when no artifacts`() = runTest { + repository.artifacts = emptyList() + + val result = useCase(appSlug = "appSlug", "buildSlug") + + assertEquals(emptyList(), result) + } + + @Test + fun `invoke returns list of artifact sluts when repository returns list of artifacts`() = runTest { + repository.artifacts = List( + 5 + ) { index -> + Artifact( + slug = "artifact${index}", + title = "Title $index", + artifactType = "type${index}", + publicInstallPageUrl = if (index % 2 == 0) "http://public${index}.url" else "" + ) + } + + val result = useCase(appSlug = "appSlug", buildSlug = "buildSlug") + + assertEquals( + expected = listOf( + "artifact0", "artifact1", "artifact2", "artifact3", "artifact4", + ), + actual = result + ) + } +} diff --git a/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/testdata/ArtifactsRepositoryFake.kt b/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/testdata/ArtifactsRepositoryFake.kt new file mode 100644 index 0000000..7f32815 --- /dev/null +++ b/feature-artifacts/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/artifacts/testdata/ArtifactsRepositoryFake.kt @@ -0,0 +1,11 @@ +package de.ahlfeld.bitriseartifacts.artifacts.testdata + +import de.ahlfeld.bitriseartifacts.artifacts.api.model.Artifact +import de.ahlfeld.bitriseartifacts.artifacts.domain.repository.ArtifactsRepository + +internal class ArtifactsRepositoryFake( + var artifacts: List = emptyList() +) : ArtifactsRepository { + override suspend fun getArtifacts(appSlug: String, buildSlug: String): List = + artifacts +} \ No newline at end of file diff --git a/feature-builds-api/build.gradle.kts b/feature-builds-api/build.gradle.kts index 143a24f..a1308f0 100644 --- a/feature-builds-api/build.gradle.kts +++ b/feature-builds-api/build.gradle.kts @@ -33,7 +33,6 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(libs.koin.navigation) implementation(libs.kotlinx.serialization.json) } } diff --git a/feature-builds/build.gradle.kts b/feature-builds/build.gradle.kts index 98078ba..91894c1 100644 --- a/feature-builds/build.gradle.kts +++ b/feature-builds/build.gradle.kts @@ -45,8 +45,10 @@ kotlin { } commonMain.dependencies { implementation(projects.featureBuildsApi) + implementation(projects.featureArtifactsApi) + implementation(projects.featureArtifactDetailApi) implementation(libs.kotlinx.coroutines.core) - implementation(libs.compose.material.icons.core) + implementation(libs.compose.material.icons.extended) 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 index a094a28..67a3e56 100644 --- 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 @@ -4,13 +4,13 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class BuildsResponseDto( +internal data class BuildsResponseDto( @SerialName("data") val data: List, @SerialName("paging") val paging: PagingDto ) @Serializable -data class BuildDto( +internal data class BuildDto( @SerialName("build_number") val buildNumber: Int, @SerialName("branch") val branch: String, @SerialName("triggered_at") val triggeredAt: String, @@ -21,7 +21,7 @@ data class BuildDto( ) @Serializable -data class PagingDto( +internal 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/di/BuildsModule.kt b/feature-builds/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/builds/di/BuildsModule.kt index c529e0f..a8bceba 100644 --- 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 @@ -3,15 +3,22 @@ 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 de.ahlfeld.bitriseartifacts.builds.presentation.BuildsViewModelImpl 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 buildsModule = module { singleOf(::BitriseBuildsRepository) bind BuildsRepository::class factoryOf(::GetBuildsUseCase) - factory { (appSlug: String) -> BuildsViewModelImpl(appSlug, get()) } bind BuildsViewModel::class + viewModel { + BuildsViewModelImpl( + savedStateHandle = get(), + getBuildsUseCase = get(), + getArtifactSlugs = 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 index c519c98..d03813a 100644 --- 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 @@ -1,6 +1,6 @@ package de.ahlfeld.bitriseartifacts.builds.domain.model -data class Build( +internal data class Build( val buildNumber: Int, val branch: String, val triggeredAt: String, 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 index 1ff794f..21932e9 100644 --- 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 @@ -5,6 +5,8 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute +import de.ahlfeld.bitriseartifacts.artifactdetail.navigation.ArtifactDetailRoute +import de.ahlfeld.bitriseartifacts.builds.presentation.BuildsNavigationEvent import de.ahlfeld.bitriseartifacts.builds.presentation.BuildsScreen import de.ahlfeld.bitriseartifacts.builds.presentation.BuildsViewModel import org.koin.compose.viewmodel.koinViewModel @@ -17,10 +19,21 @@ fun NavGraphBuilder.buildsScreen(navController: NavController) { parameters = { parametersOf(route.appSlug) } ) LaunchedEffect(buildsViewModel) { - buildsViewModel.navigationEvents.collect { - navController.popBackStack() + buildsViewModel.navigationEvents.collect { event -> + when (event) { + is BuildsNavigationEvent.Back -> navController.popBackStack() + is BuildsNavigationEvent.ShowArtifactDetails -> navController.navigate( + ArtifactDetailRoute( + appSlug = event.appSlug, + artifactSlugs = event.artifactSlugs, + buildSlug = event.buildSlug + ) + ) + } } } - BuildsScreen(viewModel = buildsViewModel) + 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 index 63f810a..0b226df 100644 --- 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 @@ -1,5 +1,11 @@ package de.ahlfeld.bitriseartifacts.builds.presentation -sealed interface BuildsNavigationEvent { - object Back : BuildsNavigationEvent +internal sealed interface BuildsNavigationEvent { + data object Back : BuildsNavigationEvent + + data class ShowArtifactDetails( + val appSlug: String, + val artifactSlugs: List, + val buildSlug: String + ) : 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 index cfd62f2..a323a48 100644 --- 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 @@ -1,5 +1,6 @@ package de.ahlfeld.bitriseartifacts.builds.presentation +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,10 +11,12 @@ 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.layout.size 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.material.icons.filled.Android import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -35,19 +38,22 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @Composable -fun BuildsScreen( +internal fun BuildsScreen( viewModel: BuildsViewModel, ) { val uiState by viewModel.uiState.collectAsState() - BuildsScreenInternal(uiState = uiState, uiEventHandler = viewModel::handleUiEvent) + BuildsScreenInternal( + uiState = uiState, + uiEventHandler = viewModel::handleUiEvent, + ) } @Composable @OptIn(ExperimentalMaterial3Api::class) private fun BuildsScreenInternal( uiState: BuildsUiState, - uiEventHandler: (BuildsUiEvent) -> Unit = {} + uiEventHandler: (BuildsUiEvent) -> Unit = {}, ) { Scaffold( topBar = { @@ -85,15 +91,21 @@ private fun BuildsScreenInternal( modifier = Modifier.align(Alignment.Center) ) - is BuildsUiState.Content -> BuildsList(uiState.builds) + is BuildsUiState.Content -> BuildsList( + builds = uiState.builds, + uiEventHandler = uiEventHandler + ) } } } } @Composable -private fun BuildsList(builds: List) { - if(builds.isEmpty()) { +private fun BuildsList( + builds: List, + uiEventHandler: (BuildsUiEvent) -> Unit = {}, +) { + if (builds.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -110,26 +122,53 @@ private fun BuildsList(builds: List) { verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(builds, key = { it.buildNumber }) { build -> - BuildItemRow(build) + BuildItemRow( + build = build, + onClick = { + uiEventHandler( + BuildsUiEvent.OnBuildClicked( + artifactSlugs = build.artifactSlugs, + buildSlug = build.buildSlug + ) + ) + } + ) } } } @Composable -private fun BuildItemRow(build: BuildItem) { +private fun BuildItemRow( + build: BuildItem, + onClick: () -> Unit = {} +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column(modifier = Modifier.padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "#${build.buildNumber}", - style = MaterialTheme.typography.titleMedium - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "#${build.buildNumber}", + style = MaterialTheme.typography.titleMedium + ) + if (build.artifactSlugs.isNotEmpty()) { + Spacer(modifier = Modifier.size(8.dp)) + Icon( + imageVector = Icons.Default.Android, + contentDescription = "APK Available", + modifier = Modifier.size(20.dp), + tint = Color(0xFF3DDC84) // Android Green + ) + } + } Text( text = build.branch, style = MaterialTheme.typography.bodyMedium, @@ -161,11 +200,11 @@ private fun BuildItemRow(build: BuildItem) { @Composable @Preview private fun BuildsScreenPreview( - @PreviewParameter(BuildsUiStatePreviewParameterProvider::class) uiState : BuildsUiState + @PreviewParameter(BuildsUiStatePreviewParameterProvider::class) uiState: BuildsUiState ) { MaterialTheme { BuildsScreenInternal( - uiState = uiState + 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 index 2d8df42..d6136d2 100644 --- 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 @@ -1,5 +1,6 @@ package de.ahlfeld.bitriseartifacts.builds.presentation -sealed interface BuildsUiEvent { +internal sealed interface BuildsUiEvent { data object OnBackClicked : BuildsUiEvent + data class OnBuildClicked(val artifactSlugs: List, val buildSlug: String) : 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 index 4a919f6..87d59ef 100644 --- 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 @@ -2,7 +2,10 @@ package de.ahlfeld.bitriseartifacts.builds.presentation sealed interface BuildsUiState { data object Loading : BuildsUiState - data class Content(val builds: List) : BuildsUiState + data class Content( + val builds: List, + val selectedBuildSlug: String? = null + ) : BuildsUiState data class Error(val message: String) : BuildsUiState } @@ -12,5 +15,6 @@ data class BuildItem( val triggeredAt: String, val finishedAt: String?, val commitHash: String?, - val status: Int + val buildSlug: String, + val artifactSlugs : List = emptyList() ) 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 index 5a3b4b2..22b93b0 100644 --- 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 @@ -2,29 +2,23 @@ package de.ahlfeld.bitriseartifacts.builds.presentation import androidx.compose.ui.tooling.preview.PreviewParameterProvider -class BuildsUiStatePreviewParameterProvider : PreviewParameterProvider { +internal class BuildsUiStatePreviewParameterProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( BuildsUiState.Loading, BuildsUiState.Content( - builds = listOf( + builds = List(5) { index -> BuildItem( - buildNumber = 123, + buildNumber = 120 + index, 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 + commitHash = "abc12345678+$index", + buildSlug = "$index", + artifactSlugs = if (index % 2 == 0) listOf("apk-slug-$index") else emptyList() ) - ) + + } ), 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 index 1364b18..a403bdc 100644 --- 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 @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -abstract class BuildsViewModel : ViewModel() { +internal 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 index c5628b4..4dc4376 100644 --- 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 @@ -1,7 +1,11 @@ package de.ahlfeld.bitriseartifacts.builds.presentation +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import de.ahlfeld.bitriseartifacts.artifacts.api.usecase.GetArtifactSlugsUseCase import de.ahlfeld.bitriseartifacts.builds.domain.usecase.GetBuildsUseCase +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -12,10 +16,13 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch internal class BuildsViewModelImpl( - private val appSlug: String, - private val getBuildsUseCase: GetBuildsUseCase + savedStateHandle: SavedStateHandle, + private val getBuildsUseCase: GetBuildsUseCase, + private val getArtifactSlugs: GetArtifactSlugsUseCase, ) : BuildsViewModel() { + private val appSlug : String = checkNotNull(savedStateHandle["appSlug"]) + private val _uiState = MutableStateFlow(BuildsUiState.Loading) private val _navigationEvents = MutableSharedFlow() @@ -36,16 +43,19 @@ internal class BuildsViewModelImpl( .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 - ) - } + .map { build -> + async { + BuildItem( + buildNumber = build.buildNumber, + branch = build.branch, + triggeredAt = build.triggeredAt, + finishedAt = build.finishedAt, + commitHash = build.commitHash, + buildSlug = build.slug, + artifactSlugs = getArtifactSlugs(appSlug, build.slug) + ) + } + }.awaitAll() _uiState.value = BuildsUiState.Content(items) } .onFailure { @@ -58,6 +68,22 @@ internal class BuildsViewModelImpl( when (event) { BuildsUiEvent.OnBackClicked -> viewModelScope.launch { _navigationEvents.emit(BuildsNavigationEvent.Back) } + + is BuildsUiEvent.OnBuildClicked -> { + val currentState = _uiState.value + if (currentState is BuildsUiState.Content) { + _uiState.value = currentState.copy(selectedBuildSlug = event.buildSlug) + viewModelScope.launch { + _navigationEvents.emit( + BuildsNavigationEvent.ShowArtifactDetails( + appSlug, + artifactSlugs = event.artifactSlugs, + buildSlug = event.buildSlug + ) + ) + } + } + } } } } 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 index 24d4cfa..6a07ded 100644 --- 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 @@ -1,9 +1,11 @@ package de.ahlfeld.bitriseartifacts.builds.presentation +import androidx.lifecycle.SavedStateHandle 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 de.ahlfeld.bitriseartifacts.builds.testdata.GetArtifactSlugsUseCaseFake import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -22,9 +24,15 @@ class BuildsViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() private val buildsRepositoryFake = BuildsRepositoryFake() + private val getArtifactSlugsUseCaseFake = GetArtifactSlugsUseCaseFake() + + private val savedStateHandle = SavedStateHandle().apply { + set("appSlug", "test-app-slug") + } private val viewModel = BuildsViewModelImpl( - "app-slug", - GetBuildsUseCase(buildsRepositoryFake) + savedStateHandle, + GetBuildsUseCase(buildsRepositoryFake), + getArtifactSlugs = getArtifactSlugsUseCaseFake, ) @BeforeTest @@ -39,6 +47,8 @@ class BuildsViewModelTest { Build(2, "develop", "2023-01-02T10:00:00Z", null, "hash2", "slug2", 1) ) buildsRepositoryFake.result = builds + getArtifactSlugsUseCaseFake.result = listOf("slug1", "slug2") + val states = mutableListOf() val job = launch(testDispatcher) { @@ -84,7 +94,12 @@ class BuildsViewModelTest { } } val useCase = GetBuildsUseCase(repository) - val viewModel = BuildsViewModelImpl("app-slug", useCase) + + val viewModel = BuildsViewModelImpl( + savedStateHandle, + getBuildsUseCase = useCase, + getArtifactSlugs = getArtifactSlugsUseCaseFake + ) val states = mutableListOf() val job = launch(testDispatcher) { diff --git a/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/testdata/GetArtifactSlugsUseCaseFake.kt b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/testdata/GetArtifactSlugsUseCaseFake.kt new file mode 100644 index 0000000..12032b6 --- /dev/null +++ b/feature-builds/src/commonTest/kotlin/de/ahlfeld/bitriseartifacts/builds/testdata/GetArtifactSlugsUseCaseFake.kt @@ -0,0 +1,11 @@ +package de.ahlfeld.bitriseartifacts.builds.testdata + +import de.ahlfeld.bitriseartifacts.artifacts.api.usecase.GetArtifactSlugsUseCase + +class GetArtifactSlugsUseCaseFake : GetArtifactSlugsUseCase { + var result : List = emptyList() + + override suspend fun invoke(appSlug: String, buildSlug: String): List { + return result + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 751ef23..f5610de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,43 +3,36 @@ agp = "9.1.0" android-compileSdk = "36" android-minSdk = "30" android-targetSdk = "36" +datastore = "1.2.1" androidx-activity = "1.13.0" -androidx-appcompat = "1.7.1" -androidx-core = "1.18.0" -androidx-espresso = "3.7.0" -androidx-lifecycle = "2.9.6" -androidx-testExt = "1.3.0" -appcompat = "1.7.0" +androidx-browser = "1.10.0" +androidx-compose-ui-tooling = "1.10.6" +androidx-lifecycle = "2.10.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" +kotlinx-serialization = "1.10.0" +material3 = "1.10.0-alpha05" # Frameworks -ktor = "3.4.1" +coil = "3.4.0" koin = "4.2.0" +ktor = "3.4.2" navigation = "2.9.2" -coil = "3.4.0" [libraries] -kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } -junit = { module = "junit:junit", version.ref = "junit" } -androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } -androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } -androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } -androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +#androidx androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } -compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } androidx-compose-uiTooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose-ui-tooling" } -androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } + +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } + +#compose +compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } @@ -47,6 +40,11 @@ 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" } + +# Datastore +datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" } +datastore-preferences-android = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } + # Serialization kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } @@ -63,22 +61,17 @@ 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" } -datastore-preferences-android = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } # Coroutines kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } # Navigation -material = { module = "com.google.android.material:material", version.ref = "material" } 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" } +compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", 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 b81c4b5..8a62a38 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,3 +28,7 @@ include(":feature-auth-api") include(":feature-builds") include(":shared") include(":feature-builds-api") +include(":feature-artifacts") +include(":feature-artifacts-api") +include(":feature-artifact-detail") +include(":feature-artifact-detail-api") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index ccd89d9..083b4d6 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -42,6 +42,10 @@ kotlin { implementation(projects.featureAuthApi) implementation(projects.featureBuildsApi) implementation(projects.featureBuilds) + implementation(projects.featureArtifacts) + implementation(projects.featureArtifactsApi) + implementation(projects.featureArtifactDetailApi) + implementation(projects.featureArtifactDetail) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) 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 889997a..4ca72ba 100644 --- a/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/di/AppModule.kt +++ b/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/di/AppModule.kt @@ -1,9 +1,11 @@ package de.ahlfeld.bitriseartifacts.di import de.ahlfeld.bitriseartifacts.apps.di.appsModule +import de.ahlfeld.bitriseartifacts.artifact_detail.di.artifactDetailModule +import de.ahlfeld.bitriseartifacts.artifacts.di.artifactsModule import de.ahlfeld.bitriseartifacts.builds.di.buildsModule import org.koin.dsl.module val appModule = module { - includes(appsModule, buildsModule) + includes(appsModule, buildsModule, artifactsModule, artifactDetailModule) } 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 e7167e4..2f433de 100644 --- a/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/App.kt +++ b/shared/src/commonMain/kotlin/de/ahlfeld/bitriseartifacts/presentation/App.kt @@ -20,6 +20,7 @@ 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.artifact_detail.navigation.artifactDetailsScreen import de.ahlfeld.bitriseartifacts.builds.navigation.BuildRoute import de.ahlfeld.bitriseartifacts.builds.navigation.buildsScreen import de.ahlfeld.bitriseartifacts.feature.auth.domain.usecase.GetTokenUseCase @@ -91,6 +92,7 @@ fun AuthenticatedApp() { ) { appsScreen(navController) buildsScreen(navController) + artifactDetailsScreen(navController) } } }