├── feature ├── base │ ├── consumer-rules.pro │ ├── src │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ ├── res │ │ │ ├── values │ │ │ │ ├── ids.xml │ │ │ │ ├── strings.xml │ │ │ │ ├── styles.xml │ │ │ │ └── color_palete.xml │ │ │ └── drawable │ │ │ │ ├── ic_search.xml │ │ │ │ ├── image_placeholder_3.xml │ │ │ │ ├── image_placeholder_2.xml │ │ │ │ └── image_placeholder_1.xml │ │ │ └── kotlin │ │ │ └── com │ │ │ └── igorwojda │ │ │ └── showcase │ │ │ └── feature │ │ │ └── base │ │ │ ├── presentation │ │ │ ├── viewmodel │ │ │ │ ├── BaseState.kt │ │ │ │ ├── BaseAction.kt │ │ │ │ ├── BaseViewModel.kt │ │ │ │ └── StateTimeTravelDebugger.kt │ │ │ └── compose │ │ │ │ └── composable │ │ │ │ ├── UnderConstructionAnim.kt │ │ │ │ ├── TextTitleLarge.kt │ │ │ │ ├── TextTitleMedium.kt │ │ │ │ ├── ErrorAnim.kt │ │ │ │ ├── Loading.kt │ │ │ │ ├── PlaceholderImage.kt │ │ │ │ └── Lottie.kt │ │ │ ├── domain │ │ │ └── result │ │ │ │ ├── ResultExt.kt │ │ │ │ └── Result.kt │ │ │ ├── common │ │ │ ├── res │ │ │ │ └── Dimen.kt │ │ │ └── delegate │ │ │ │ └── Observer.kt │ │ │ ├── data │ │ │ └── retrofit │ │ │ │ ├── ApiResultCallAdapter.kt │ │ │ │ ├── ApiResult.kt │ │ │ │ ├── ApiResultAdapterFactory.kt │ │ │ │ └── ApiResultCall.kt │ │ │ └── util │ │ │ └── TimberLogTags.kt │ ├── build.gradle.kts │ └── proguard-rules.pro ├── album │ ├── src │ │ ├── main │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── igorwojda │ │ │ │ │ └── showcase │ │ │ │ │ └── feature │ │ │ │ │ └── album │ │ │ │ │ ├── domain │ │ │ │ │ ├── model │ │ │ │ │ │ ├── Tag.kt │ │ │ │ │ │ ├── Track.kt │ │ │ │ │ │ ├── Image.kt │ │ │ │ │ │ └── Album.kt │ │ │ │ │ ├── enum │ │ │ │ │ │ └── ImageSize.kt │ │ │ │ │ ├── DomainModule.kt │ │ │ │ │ ├── repository │ │ │ │ │ │ └── AlbumRepository.kt │ │ │ │ │ └── usecase │ │ │ │ │ │ ├── GetAlbumUseCase.kt │ │ │ │ │ │ └── GetAlbumListUseCase.kt │ │ │ │ │ ├── data │ │ │ │ │ ├── datasource │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ ├── model │ │ │ │ │ │ │ │ ├── TagListApiModel.kt │ │ │ │ │ │ │ │ ├── AlbumListApiModel.kt │ │ │ │ │ │ │ │ ├── TrackListApiModel.kt │ │ │ │ │ │ │ │ ├── SearchAlbumResultsApiModel.kt │ │ │ │ │ │ │ │ ├── TagApiModel.kt │ │ │ │ │ │ │ │ ├── ImageApiModel.kt │ │ │ │ │ │ │ │ ├── TrackApiModel.kt │ │ │ │ │ │ │ │ ├── ImageSizeApiModel.kt │ │ │ │ │ │ │ │ └── AlbumApiModel.kt │ │ │ │ │ │ │ ├── response │ │ │ │ │ │ │ │ ├── GetAlbumInfoResponse.kt │ │ │ │ │ │ │ │ └── SearchAlbumResponse.kt │ │ │ │ │ │ │ └── service │ │ │ │ │ │ │ │ └── AlbumRetrofitService.kt │ │ │ │ │ │ └── database │ │ │ │ │ │ │ ├── model │ │ │ │ │ │ │ ├── TagRoomModel.kt │ │ │ │ │ │ │ ├── ImageRoomModel.kt │ │ │ │ │ │ │ ├── TrackRoomModel.kt │ │ │ │ │ │ │ ├── ImageSizeRoomModel.kt │ │ │ │ │ │ │ └── AlbumRoomModel.kt │ │ │ │ │ │ │ ├── AlbumDatabase.kt │ │ │ │ │ │ │ └── AlbumDao.kt │ │ │ │ │ ├── mapper │ │ │ │ │ │ ├── TagMapper.kt │ │ │ │ │ │ ├── TrackMapper.kt │ │ │ │ │ │ ├── ImageMapper.kt │ │ │ │ │ │ ├── ImageSizeMapper.kt │ │ │ │ │ │ └── AlbumMapper.kt │ │ │ │ │ ├── DataModule.kt │ │ │ │ │ └── repository │ │ │ │ │ │ └── AlbumRepositoryImpl.kt │ │ │ │ │ ├── AlbumKoinModule.kt │ │ │ │ │ └── presentation │ │ │ │ │ ├── util │ │ │ │ │ └── TimeUtil.kt │ │ │ │ │ ├── screen │ │ │ │ │ ├── albumlist │ │ │ │ │ │ ├── AlbumListUiState.kt │ │ │ │ │ │ ├── AlbumListAction.kt │ │ │ │ │ │ ├── AlbumListViewModel.kt │ │ │ │ │ │ └── AlbumListScreen.kt │ │ │ │ │ └── albumdetail │ │ │ │ │ │ ├── AlbumDetailUiState.kt │ │ │ │ │ │ ├── AlbumDetailAction.kt │ │ │ │ │ │ └── AlbumDetailViewModel.kt │ │ │ │ │ ├── PresentationModule.kt │ │ │ │ │ └── composable │ │ │ │ │ └── SearchBarComposable.kt │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── strings.xml │ │ └── test │ │ │ └── kotlin │ │ │ └── com │ │ │ └── igorwojda │ │ │ └── showcase │ │ │ └── feature │ │ │ └── album │ │ │ ├── data │ │ │ ├── datasource │ │ │ │ └── api │ │ │ │ │ └── model │ │ │ │ │ ├── ImageSizeApiModelTest.kt │ │ │ │ │ ├── ImageApiModelTest.kt │ │ │ │ │ └── AlbumApiModelTest.kt │ │ │ ├── mapper │ │ │ │ ├── TagMapperTest.kt │ │ │ │ ├── TrackMapperTest.kt │ │ │ │ ├── ImageSizeMapperTest.kt │ │ │ │ └── ImageMapperTest.kt │ │ │ └── DataFixtures.kt │ │ │ ├── domain │ │ │ ├── model │ │ │ │ └── AlbumTest.kt │ │ │ ├── DomainFixtures.kt │ │ │ └── usecase │ │ │ │ ├── GetAlbumUseCaseTest.kt │ │ │ │ └── GetAlbumListUseCaseTest.kt │ │ │ └── presentation │ │ │ └── screen │ │ │ ├── albumlist │ │ │ └── AlbumListViewModelTest.kt │ │ │ └── albumdetail │ │ │ └── AlbumDetailViewModelTest.kt │ ├── build.gradle.kts │ └── proguard-rules.pro ├── favourite │ ├── src │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin │ │ │ └── com │ │ │ └── igorwojda │ │ │ └── showcase │ │ │ └── feature │ │ │ └── favourite │ │ │ ├── data │ │ │ └── DataModule.kt │ │ │ ├── domain │ │ │ └── DomainModule.kt │ │ │ ├── presentation │ │ │ ├── PresentationModule.kt │ │ │ └── screen │ │ │ │ └── favourite │ │ │ │ └── FavouriteScreen.kt │ │ │ └── FavouriteKoinModule.kt │ ├── build.gradle.kts │ └── proguard-rules.pro └── settings │ ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── igorwojda │ │ │ │ └── showcase │ │ │ │ └── feature │ │ │ │ └── settings │ │ │ │ ├── data │ │ │ │ └── DataModule.kt │ │ │ │ ├── domain │ │ │ │ └── DomainModule.kt │ │ │ │ ├── presentation │ │ │ │ ├── screen │ │ │ │ │ ├── settings │ │ │ │ │ │ ├── SettingsAction.kt │ │ │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ │ │ ├── SettingsUiState.kt │ │ │ │ │ │ └── SettingsScreen.kt │ │ │ │ │ └── aboutlibraries │ │ │ │ │ │ ├── AboutLibrariesAction.kt │ │ │ │ │ │ ├── AboutLibrariesViewModel.kt │ │ │ │ │ │ ├── AboutLibrariesUiState.kt │ │ │ │ │ │ └── AboutLibrariesScreen.kt │ │ │ │ └── PresentationModule.kt │ │ │ │ └── SettingsKoinModule.kt │ │ └── res │ │ │ └── values │ │ │ └── strings.xml │ └── test │ │ └── kotlin │ │ └── com │ │ └── igorwojda │ │ └── showcase │ │ └── feature │ │ └── settings │ │ └── presentation │ │ └── screen │ │ ├── settings │ │ └── SettingsViewModelTest.kt │ │ └── aboutlibraries │ │ └── AboutLibrariesViewModelTest.kt │ ├── build.gradle.kts │ └── proguard-rules.pro ├── library └── test-utils │ ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── com │ │ └── igorwojda │ │ └── showcase │ │ └── library │ │ └── testutils │ │ ├── CoroutinesTestDispatcherExtension.kt │ │ └── InstantTaskExecutorExtension.kt │ ├── build.gradle.kts │ └── proguard-rules.pro ├── misc └── image │ ├── avatar.png │ ├── app_data_flow.png │ ├── logs_action.png │ ├── logs_network.png │ ├── module_layers.png │ ├── logs_navigation.png │ ├── screen_settings.png │ ├── feature_structure.png │ ├── screen_album_list.png │ ├── screen_favorites.png │ ├── module_dependencies.png │ ├── screen_album_detail.png │ ├── application_icon_label.png │ ├── application_themed_icon.png │ ├── module_layers_details.png │ └── screen_open_source_libraries.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── app ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── strings.xml │ │ │ │ └── styles.xml │ │ │ ├── mipmap-anydpi │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── mipmap-anydpi-v33 │ │ │ │ ├── ic_launcher_round.xml │ │ │ │ └── ic_launcher.xml │ │ │ ├── drawable │ │ │ │ ├── ic_launcher_foreground_themed.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ ├── ic_music_library.xml │ │ │ │ ├── ic_favorite.xml │ │ │ │ └── ic_settings.xml │ │ │ └── xml │ │ │ │ └── data_extraction_rules.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── igorwojda │ │ │ │ └── showcase │ │ │ │ └── app │ │ │ │ ├── presentation │ │ │ │ ├── NavigationRoute.kt │ │ │ │ ├── MainShowcaseActivity.kt │ │ │ │ ├── util │ │ │ │ │ └── NavigationDestinationLogger.kt │ │ │ │ ├── BottomNavigationBar.kt │ │ │ │ └── MainShowcaseScreen.kt │ │ │ │ ├── data │ │ │ │ └── api │ │ │ │ │ ├── AuthenticationInterceptor.kt │ │ │ │ │ └── UserAgentInterceptor.kt │ │ │ │ ├── ShowcaseApplication.kt │ │ │ │ └── AppKoinModule.kt │ │ └── AndroidManifest.xml │ └── debug │ │ ├── AndroidManifest.xml │ │ └── res │ │ └── xml │ │ └── network_security_config.xml ├── proguard-rules.pro └── build.gradle.kts ├── .editorconfig ├── konsist-test ├── build.gradle.kts └── src │ └── test │ └── kotlin │ └── com │ └── igorwojda │ └── showcase │ └── konsisttest │ ├── TestKonsistTest.kt │ ├── AndroidKonsistTest.kt │ ├── ModuleKonsistTest.kt │ ├── CleanArchitectureKonsistTest.kt │ ├── GeneralKonsistTest.kt │ ├── ViewModelKonsistTest.kt │ └── UseCaseKonsistTest.kt ├── .github ├── workflows │ ├── auto-approve.yml │ ├── claude.yml │ └── claude-code-review.yml └── stale.yml ├── .idea └── icon.svg ├── .gitignore ├── settings.gradle.kts ├── renovate.json ├── CONTRIBUTING.md ├── DeveloperReadme.md ├── gradle.properties ├── gradlew.bat └── CODE_OF_CONDUCT.md /feature/base/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /library/test-utils/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /feature/album/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /feature/base/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /feature/favourite/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /feature/settings/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /misc/image/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/avatar.png -------------------------------------------------------------------------------- /misc/image/app_data_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/app_data_flow.png -------------------------------------------------------------------------------- /misc/image/logs_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/logs_action.png -------------------------------------------------------------------------------- /misc/image/logs_network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/logs_network.png -------------------------------------------------------------------------------- /misc/image/module_layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/module_layers.png -------------------------------------------------------------------------------- /misc/image/logs_navigation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/logs_navigation.png -------------------------------------------------------------------------------- /misc/image/screen_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/screen_settings.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /misc/image/feature_structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/feature_structure.png -------------------------------------------------------------------------------- /misc/image/screen_album_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/screen_album_list.png -------------------------------------------------------------------------------- /misc/image/screen_favorites.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/screen_favorites.png -------------------------------------------------------------------------------- /misc/image/module_dependencies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/module_dependencies.png -------------------------------------------------------------------------------- /misc/image/screen_album_detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/screen_album_detail.png -------------------------------------------------------------------------------- /misc/image/application_icon_label.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/application_icon_label.png -------------------------------------------------------------------------------- /misc/image/application_themed_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/application_themed_icon.png -------------------------------------------------------------------------------- /misc/image/module_layers_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/module_layers_details.png -------------------------------------------------------------------------------- /misc/image/screen_open_source_libraries.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/misc/image/screen_open_source_libraries.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorwojda/android-showcase/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | -------------------------------------------------------------------------------- /feature/base/src/main/res/values/ids.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /feature/base/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.igorwojda.showcase.convention.feature") 3 | } 4 | 5 | android { 6 | namespace = "com.igorwojda.showcase.feature.base" 7 | } 8 | -------------------------------------------------------------------------------- /feature/album/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.igorwojda.showcase.convention.feature") 3 | } 4 | 5 | android { 6 | namespace = "com.igorwojda.showcase.feature.album" 7 | } 8 | -------------------------------------------------------------------------------- /feature/favourite/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.igorwojda.showcase.convention.feature") 3 | } 4 | 5 | android { 6 | namespace = "com.igorwojda.showcase.feature.favourite" 7 | } 8 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/viewmodel/BaseState.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.viewmodel 2 | 3 | interface BaseState 4 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Tag.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.model 2 | 3 | internal data class Tag( 4 | val name: String, 5 | ) 6 | -------------------------------------------------------------------------------- /feature/base/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Under construction 3 | Data not found 4 | 5 | -------------------------------------------------------------------------------- /feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/data/DataModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.favourite.data 2 | 3 | import org.koin.dsl.module 4 | 5 | internal val dataModule = module { } 6 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/data/DataModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.data 2 | 3 | import org.koin.dsl.module 4 | 5 | internal val dataModule = module { } 6 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/domain/DomainModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.domain 2 | 3 | import org.koin.dsl.module 4 | 5 | internal val domainModule = module { } 6 | -------------------------------------------------------------------------------- /feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/domain/DomainModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.favourite.domain 2 | 3 | import org.koin.dsl.module 4 | 5 | internal val domainModule = module { } 6 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Track.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.model 2 | 3 | internal data class Track( 4 | val name: String, 5 | val duration: Int? = null, 6 | ) 7 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/viewmodel/BaseAction.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.viewmodel 2 | 3 | interface BaseAction { 4 | fun reduce(state: State): State 5 | } 6 | -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #FFFFFF 5 | #3DDC84 6 | -------------------------------------------------------------------------------- /feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/presentation/PresentationModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.favourite.presentation 2 | 3 | import org.koin.dsl.module 4 | 5 | internal val presentationModule = module { } 6 | -------------------------------------------------------------------------------- /feature/base/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/enum/ImageSize.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.enum 2 | 3 | internal enum class ImageSize { 4 | SMALL, 5 | MEDIUM, 6 | LARGE, 7 | EXTRA_LARGE, 8 | MEGA, 9 | } 10 | -------------------------------------------------------------------------------- /feature/settings/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.igorwojda.showcase.convention.feature") 3 | } 4 | 5 | android { 6 | namespace = "com.igorwojda.showcase.feature.settings" 7 | } 8 | 9 | dependencies { 10 | implementation(libs.aboutlibraries.compose) 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{kt,kts}] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | max_line_length = 140 7 | ktlint_function_naming_ignore_when_annotated_with=Composable 8 | 9 | # Detekt orders imports correctly, while ktlint does not 10 | ktlint_standard_import-ordering = disabled 11 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Image.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.model 2 | 3 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 4 | 5 | internal data class Image( 6 | val url: String, 7 | val size: ImageSize, 8 | ) 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Showcase 3 | 4 | Albums 5 | Favorites 6 | Settings 7 | 8 | -------------------------------------------------------------------------------- /feature/base/src/main/res/values/color_palete.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #2B2E30 4 | #1A1E21 5 | #3D3D3D 6 | #FB3430 7 | #FFFFFF 8 | 9 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsAction.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.settings 2 | 3 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseAction 4 | 5 | internal sealed class SettingsAction : BaseAction 6 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/domain/result/ResultExt.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.domain.result 2 | 3 | inline fun Result.mapSuccess(crossinline onResult: Result.Success.() -> Result): Result { 4 | if (this is Result.Success) { 5 | return onResult(this) 6 | } 7 | return this 8 | } 9 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesAction.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries 2 | 3 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseAction 4 | 5 | internal sealed class AboutLibrariesAction : BaseAction 6 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/domain/result/Result.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.domain.result 2 | 3 | sealed interface Result { 4 | data class Success( 5 | val value: T, 6 | ) : Result 7 | 8 | data class Failure( 9 | val throwable: Throwable? = null, 10 | ) : Result 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.settings 2 | 3 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseViewModel 4 | 5 | internal class SettingsViewModel : BaseViewModel(SettingsUiState.Content) 6 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TagListApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | internal data class TagListApiModel( 8 | @SerialName("tag") val tag: List, 9 | ) 10 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumListApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | internal data class AlbumListApiModel( 8 | @SerialName("album") val album: List, 9 | ) 10 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TrackListApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | internal data class TrackListApiModel( 8 | @SerialName("track") val track: List, 9 | ) 10 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/common/res/Dimen.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.common.res 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | object Dimen { 6 | val spaceS = 4.dp 7 | val spaceM = 8.dp 8 | val spaceL = 16.dp 9 | val spaceXL = 32.dp 10 | val spaceXXL = 64.dp 11 | val screenContentPadding = spaceL 12 | val imageSize = 100.dp 13 | } 14 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries 2 | 3 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseViewModel 4 | 5 | internal class AboutLibrariesViewModel : BaseViewModel(AboutLibrariesUiState.Content) 6 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/SearchAlbumResultsApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | internal data class SearchAlbumResultsApiModel( 8 | @SerialName("albummatches") val albumMatches: AlbumListApiModel, 9 | ) 10 | -------------------------------------------------------------------------------- /konsist-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.igorwojda.showcase.convention.test.library") 3 | } 4 | 5 | android { 6 | namespace = "com.igorwojda.showcase.konsist.test" 7 | } 8 | 9 | dependencies { 10 | implementation(projects.feature.base) 11 | 12 | testImplementation(projects.library.testUtils) 13 | testImplementation(libs.bundles.test) 14 | testImplementation(libs.konsist) 15 | testImplementation(libs.viewmodel.ktx) 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsUiState.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.settings 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseState 5 | 6 | @Immutable 7 | internal sealed interface SettingsUiState : BaseState { 8 | @Immutable 9 | data object Content : SettingsUiState 10 | } 11 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/AlbumKoinModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album 2 | 3 | import com.igorwojda.showcase.feature.album.data.dataModule 4 | import com.igorwojda.showcase.feature.album.domain.domainModule 5 | import com.igorwojda.showcase.feature.album.presentation.presentationModule 6 | 7 | val featureAlbumModules = 8 | listOf( 9 | presentationModule, 10 | domainModule, 11 | dataModule, 12 | ) 13 | -------------------------------------------------------------------------------- /.github/workflows/auto-approve.yml: -------------------------------------------------------------------------------- 1 | name: Auto Approve 2 | 3 | on: pull_request_target 4 | 5 | jobs: 6 | auto-approve: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | pull-requests: write 10 | if: | 11 | github.event.pull_request.head.repo.full_name == github.repository && 12 | github.actor == github.event.pull_request.user.login && 13 | contains(fromJson('["renovate[bot]", "igorwojda"]'), github.actor) 14 | steps: 15 | - uses: hmarr/auto-approve-action@v4 16 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/response/GetAlbumInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.response 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.AlbumApiModel 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | internal data class GetAlbumInfoResponse( 9 | @SerialName("album") val album: AlbumApiModel, 10 | ) 11 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/TagRoomModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.database.model 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Tag 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | internal data class TagRoomModel( 8 | val name: String, 9 | ) 10 | 11 | internal fun TagRoomModel.toDomainModel() = 12 | Tag( 13 | name = this.name, 14 | ) 15 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesUiState.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseState 5 | 6 | @Immutable 7 | internal sealed interface AboutLibrariesUiState : BaseState { 8 | @Immutable 9 | data object Content : AboutLibrariesUiState 10 | } 11 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/SettingsKoinModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings 2 | 3 | import com.igorwojda.showcase.feature.settings.data.dataModule 4 | import com.igorwojda.showcase.feature.settings.domain.domainModule 5 | import com.igorwojda.showcase.feature.settings.presentation.presentationModule 6 | 7 | val featureSettingsModules = 8 | listOf( 9 | presentationModule, 10 | domainModule, 11 | dataModule, 12 | ) 13 | -------------------------------------------------------------------------------- /feature/album/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Search Album 4 | Albums 5 | Search albums… 6 | 7 | Album Cover 8 | Tracks 9 | Back 10 | 11 | -------------------------------------------------------------------------------- /feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/FavouriteKoinModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.favourite 2 | 3 | import com.igorwojda.showcase.feature.favourite.data.dataModule 4 | import com.igorwojda.showcase.feature.favourite.domain.domainModule 5 | import com.igorwojda.showcase.feature.favourite.presentation.presentationModule 6 | 7 | val featureFavouriteModules = 8 | listOf( 9 | presentationModule, 10 | domainModule, 11 | dataModule, 12 | ) 13 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/response/SearchAlbumResponse.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.response 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.SearchAlbumResultsApiModel 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | internal data class SearchAlbumResponse( 9 | @SerialName("results") val results: SearchAlbumResultsApiModel, 10 | ) 11 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/AlbumDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel 6 | 7 | @Database(entities = [AlbumRoomModel::class], version = 1, exportSchema = false) 8 | internal abstract class AlbumDatabase : RoomDatabase() { 9 | abstract fun albums(): AlbumDao 10 | } 11 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/DomainModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain 2 | 3 | import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumListUseCase 4 | import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumUseCase 5 | import org.koin.core.module.dsl.singleOf 6 | import org.koin.dsl.module 7 | 8 | internal val domainModule = 9 | module { 10 | 11 | singleOf(::GetAlbumListUseCase) 12 | 13 | singleOf(::GetAlbumUseCase) 14 | } 15 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/data/retrofit/ApiResultCallAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.data.retrofit 2 | 3 | import retrofit2.Call 4 | import retrofit2.CallAdapter 5 | import java.lang.reflect.Type 6 | 7 | internal class ApiResultCallAdapter( 8 | private val successType: Type, 9 | ) : CallAdapter>> { 10 | override fun responseType(): Type = successType 11 | 12 | override fun adapt(call: Call): Call> = ApiResultCall(call) 13 | } 14 | -------------------------------------------------------------------------------- /.idea/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/ImageRoomModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.database.model 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Image 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | internal data class ImageRoomModel( 8 | val url: String, 9 | val size: ImageSizeRoomModel, 10 | ) 11 | 12 | internal fun ImageRoomModel.toDomainModel() = this.size.toDomainModel()?.let { Image(this.url, it) } 13 | -------------------------------------------------------------------------------- /feature/base/src/main/res/drawable/ic_search.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/repository/AlbumRepository.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.repository 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Album 4 | import com.igorwojda.showcase.feature.base.domain.result.Result 5 | 6 | internal interface AlbumRepository { 7 | suspend fun getAlbumInfo( 8 | artistName: String, 9 | albumName: String, 10 | mbId: String?, 11 | ): Result 12 | 13 | suspend fun searchAlbum(phrase: String?): Result> 14 | } 15 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/TrackRoomModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.database.model 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Track 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | internal data class TrackRoomModel( 8 | val name: String, 9 | val duration: Int? = null, 10 | ) 11 | 12 | internal fun TrackRoomModel.toDomainModel() = 13 | Track( 14 | name = this.name, 15 | duration = this.duration, 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground_themed.xml: -------------------------------------------------------------------------------- 1 | 6 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/PresentationModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation 2 | 3 | import com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries.AboutLibrariesViewModel 4 | import com.igorwojda.showcase.feature.settings.presentation.screen.settings.SettingsViewModel 5 | import org.koin.core.module.dsl.viewModelOf 6 | import org.koin.dsl.module 7 | 8 | internal val presentationModule = 9 | module { 10 | viewModelOf(::SettingsViewModel) 11 | viewModelOf(::AboutLibrariesViewModel) 12 | } 13 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/UnderConstructionAnim.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.compose.composable 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.tooling.preview.Preview 5 | import com.igorwojda.showcase.feature.base.R 6 | 7 | @Composable 8 | fun UnderConstructionAnim() { 9 | LabeledAnimation(R.string.common_under_construction, R.raw.lottie_building_screen) 10 | } 11 | 12 | @Preview 13 | @Composable 14 | private fun UnderConstructionAnimPreview() { 15 | UnderConstructionAnim() 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/util/TimeUtil.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.util 2 | 3 | object TimeUtil { 4 | /** 5 | * provides a String representation of the given time. 6 | * @return `seconds` in mm:ss format 7 | */ 8 | internal fun formatTime(seconds: Int): String { 9 | val secondsInMinute = 60 10 | val secondsInHour = 3600 11 | 12 | @Suppress("detekt.ImplicitDefaultLocale") 13 | return String.format("%02d:%02d", seconds % secondsInHour / secondsInMinute, seconds % secondsInMinute) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /library/test-utils/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.igorwojda.showcase.convention.library") 3 | } 4 | 5 | android { 6 | namespace = "com.igorwojda.showcase.library.testutils" 7 | } 8 | 9 | dependencies { 10 | // implementation configuration is used here (instead of testImplementation) because this module is added as 11 | // testImplementation dependency inside other modules. Using implementation allows to write tests for test utilities. 12 | implementation(libs.kotlin.reflect) 13 | implementation(libs.bundles.test) 14 | implementation(libs.bundles.compose) 15 | 16 | runtimeOnly(libs.junit.jupiter.engine) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/debug/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | ws.audioscrobbler.com 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/common/delegate/Observer.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.common.delegate 2 | 3 | import kotlin.properties.ObservableProperty 4 | import kotlin.properties.ReadWriteProperty 5 | import kotlin.reflect.KProperty 6 | 7 | inline fun observer( 8 | initialValue: T, 9 | crossinline onChange: (newValue: T) -> Unit, 10 | ): ReadWriteProperty = 11 | object : ObservableProperty(initialValue) { 12 | override fun afterChange( 13 | property: KProperty<*>, 14 | oldValue: T, 15 | newValue: T, 16 | ) = onChange(newValue) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_music_library.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.usecase 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Album 4 | import com.igorwojda.showcase.feature.album.domain.repository.AlbumRepository 5 | import com.igorwojda.showcase.feature.base.domain.result.Result 6 | 7 | internal class GetAlbumUseCase( 8 | private val albumRepository: AlbumRepository, 9 | ) { 10 | suspend operator fun invoke( 11 | artistName: String, 12 | albumName: String, 13 | mbId: String?, 14 | ): Result = albumRepository.getAlbumInfo(artistName, albumName, mbId) 15 | } 16 | -------------------------------------------------------------------------------- /konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/TestKonsistTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.konsisttest 2 | 3 | import com.lemonappdev.konsist.api.Konsist 4 | import com.lemonappdev.konsist.api.ext.list.functions 5 | import com.lemonappdev.konsist.api.verify.assertFalse 6 | import org.junit.jupiter.api.Test 7 | 8 | // Check test coding rules. 9 | class TestKonsistTest { 10 | @Test 11 | fun `don't use JUnit4 Test annotation`() { 12 | Konsist 13 | .scopeFromProject() 14 | .classes() 15 | .functions() 16 | .assertFalse { it.hasAnnotationsWithAllNames("org.junit.Test") } // should be only org.junit.jupiter.api.Test 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /feature/settings/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Settings 4 | About 5 | Open Source Licenses 6 | View licenses of third-party libraries 7 | Licenses 8 | Navigate 9 | 10 | Open Source Licenses 11 | Back 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/presentation/NavigationRoute.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app.presentation 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | sealed interface NavigationRoute { 6 | @Serializable 7 | data object AlbumList : NavigationRoute 8 | 9 | @Serializable 10 | data class AlbumDetail( 11 | val albumName: String, 12 | val artistName: String, 13 | val albumMbId: String?, 14 | ) : NavigationRoute 15 | 16 | @Serializable 17 | data object Favourites : NavigationRoute 18 | 19 | @Serializable 20 | data object Settings : NavigationRoute 21 | 22 | @Serializable 23 | data object AboutLibraries : NavigationRoute 24 | } 25 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListUiState.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumlist 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.igorwojda.showcase.feature.album.domain.model.Album 5 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseState 6 | 7 | @Immutable 8 | internal sealed interface AlbumListUiState : BaseState { 9 | @Immutable 10 | data class Content( 11 | val albums: List, 12 | ) : AlbumListUiState 13 | 14 | @Immutable 15 | data object Loading : AlbumListUiState 16 | 17 | @Immutable 18 | data object Error : AlbumListUiState 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_favorite.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TagApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.TagRoomModel 4 | import com.igorwojda.showcase.feature.album.domain.model.Tag 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | internal data class TagApiModel( 10 | @SerialName("name") val name: String, 11 | ) 12 | 13 | internal fun TagApiModel.toDomainModel() = 14 | Tag( 15 | name = this.name, 16 | ) 17 | 18 | internal fun TagApiModel.toRoomModel() = 19 | TagRoomModel( 20 | name = this.name, 21 | ) 22 | -------------------------------------------------------------------------------- /konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/AndroidKonsistTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.konsisttest 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.lemonappdev.konsist.api.Konsist 5 | import com.lemonappdev.konsist.api.ext.list.withParentClassOf 6 | import com.lemonappdev.konsist.api.verify.assertTrue 7 | import org.junit.jupiter.api.Test 8 | 9 | // Check Android specific coding rules. 10 | class AndroidKonsistTest { 11 | @Test 12 | fun `classes extending 'ViewModel' should have 'ViewModel' suffix`() { 13 | Konsist 14 | .scopeFromProject() 15 | .classes() 16 | .withParentClassOf(ViewModel::class) 17 | .assertTrue { it.name.endsWith("ViewModel") } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | - enhancement 10 | # Label to use when marking an issue as stale 11 | staleLabel: wontfix 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: true 19 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/PresentationModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation 2 | 3 | import coil.ImageLoader 4 | import com.igorwojda.showcase.feature.album.presentation.screen.albumdetail.AlbumDetailViewModel 5 | import com.igorwojda.showcase.feature.album.presentation.screen.albumlist.AlbumListViewModel 6 | import org.koin.core.module.dsl.singleOf 7 | import org.koin.core.module.dsl.viewModelOf 8 | import org.koin.dsl.module 9 | 10 | internal val presentationModule = 11 | module { 12 | 13 | // AlbumList 14 | viewModelOf(::AlbumListViewModel) 15 | 16 | singleOf(::ImageLoader) 17 | 18 | // AlbumDetails 19 | viewModelOf(::AlbumDetailViewModel) 20 | } 21 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageSizeApiModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import org.junit.jupiter.api.Test 4 | 5 | class ImageSizeApiModelTest { 6 | @Test 7 | fun `maps to AlbumDomainImageSize`() { 8 | // given 9 | val dataEnums = 10 | ImageSizeApiModel 11 | .entries 12 | .filterNot { it == ImageSizeApiModel.UNKNOWN } 13 | 14 | // when 15 | dataEnums.forEach { it.toDomainModel() } 16 | 17 | // then 18 | // no explicit check is required, because test will crash if any of 19 | // the costs in the enums can't be mapped to a domain enum 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Album.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.model 2 | 3 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 4 | 5 | // Images are loaded for both album list and album detail instance 6 | // Tracks and Tags are only loaded for album detail instance (not album list instance) 7 | internal data class Album( 8 | val name: String, 9 | val artist: String, 10 | val mbId: String? = null, 11 | val images: List = emptyList(), 12 | val tracks: List? = null, 13 | val tags: List? = null, 14 | ) { 15 | val id: String = "$artist - $name" 16 | 17 | fun getDefaultImageUrl() = images.firstOrNull { it.size == ImageSize.EXTRA_LARGE }?.url 18 | } 19 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/ImageSizeRoomModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.database.model 2 | 3 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 4 | 5 | internal enum class ImageSizeRoomModel { 6 | MEDIUM, 7 | SMALL, 8 | LARGE, 9 | EXTRA_LARGE, 10 | MEGA, 11 | } 12 | 13 | internal fun ImageSizeRoomModel.toDomainModel(): ImageSize? = 14 | when (this) { 15 | ImageSizeRoomModel.MEDIUM -> ImageSize.MEDIUM 16 | ImageSizeRoomModel.SMALL -> ImageSize.SMALL 17 | ImageSizeRoomModel.LARGE -> ImageSize.LARGE 18 | ImageSizeRoomModel.EXTRA_LARGE -> ImageSize.EXTRA_LARGE 19 | ImageSizeRoomModel.MEGA -> ImageSize.MEGA 20 | } 21 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/TextTitleLarge.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.compose.composable 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.tooling.preview.Preview 8 | 9 | @Composable 10 | fun TextTitleLarge( 11 | text: String, 12 | modifier: Modifier = Modifier, 13 | ) { 14 | Text( 15 | text = text, 16 | modifier = modifier, 17 | style = MaterialTheme.typography.titleLarge, 18 | ) 19 | } 20 | 21 | @Preview 22 | @Composable 23 | private fun TextTitleLargePreview() { 24 | TextTitleLarge(text = "Sample Large Title") 25 | } 26 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/TextTitleMedium.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.compose.composable 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.tooling.preview.Preview 8 | 9 | @Composable 10 | fun TextTitleMedium( 11 | text: String, 12 | modifier: Modifier = Modifier, 13 | ) { 14 | Text( 15 | text = text, 16 | modifier = modifier, 17 | style = MaterialTheme.typography.titleMedium, 18 | ) 19 | } 20 | 21 | @Preview 22 | @Composable 23 | private fun TextTitleMediumPreview() { 24 | TextTitleMedium(text = "Sample Medium Title") 25 | } 26 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TagMapper.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.TagApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.TagRoomModel 5 | import com.igorwojda.showcase.feature.album.domain.model.Tag 6 | 7 | internal class TagMapper { 8 | fun apiToDomain(apiModel: TagApiModel): Tag = 9 | Tag( 10 | name = apiModel.name, 11 | ) 12 | 13 | fun apiToRoom(apiModel: TagApiModel): TagRoomModel = 14 | TagRoomModel( 15 | name = apiModel.name, 16 | ) 17 | 18 | fun roomToDomain(roomModel: TagRoomModel): Tag = 19 | Tag( 20 | name = roomModel.name, 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /feature/base/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /feature/album/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /feature/favourite/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /feature/settings/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /library/test-utils/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageRoomModel 4 | import com.igorwojda.showcase.feature.album.domain.model.Image 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | internal data class ImageApiModel( 10 | @SerialName("#text") val url: String, 11 | @SerialName("size") val size: ImageSizeApiModel, 12 | ) 13 | 14 | internal fun ImageApiModel.toDomainModel() = 15 | Image( 16 | url = this.url, 17 | size = this.size.toDomainModel(), 18 | ) 19 | 20 | internal fun ImageApiModel.toRoomModel() = this.size.toRoomModel()?.let { ImageRoomModel(this.url, it) } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/data/api/AuthenticationInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app.data.api 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | 6 | class AuthenticationInterceptor( 7 | private val apiKey: String, 8 | ) : Interceptor { 9 | override fun intercept(chain: Interceptor.Chain): Response = 10 | chain.request().let { 11 | val url = 12 | it.url 13 | .newBuilder() 14 | .addQueryParameter("api_key", apiKey) 15 | .addQueryParameter("format", "json") 16 | .build() 17 | 18 | val newRequest = 19 | it 20 | .newBuilder() 21 | .url(url) 22 | .build() 23 | 24 | chain.proceed(newRequest) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/util/TimberLogTags.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.util 2 | 3 | /** 4 | * Centralized log tags for consistent logging throughout the application. 5 | * 6 | * These tags help filter and identify different types of logs during development and debugging: 7 | * - Use with Timber: `Timber.tag(LogTags.NETWORK).d("message")` 8 | * - Filter in Logcat by tag to see specific log categories 9 | */ 10 | object TimberLogTags { 11 | /** 12 | * Network requests, responses, and HTTP-related logs. 13 | **/ 14 | const val NETWORK = "Network" 15 | 16 | /** 17 | * User actions and UI state modifications. 18 | **/ 19 | const val ACTION = "Action" 20 | 21 | /** 22 | * Navigation events and route changes. 23 | **/ 24 | const val NAVIGATION = "Navigation" 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | *.aab 5 | 6 | # Files for the ART/Dalvik VM 7 | *.dex 8 | 9 | # Java class files 10 | *.class 11 | 12 | # Generated files 13 | bin/ 14 | gen/ 15 | out/ 16 | 17 | # Gradle files 18 | .gradle/ 19 | 20 | # Build folders 21 | **/build/ 22 | 23 | # Local configuration file (sdk path, etc) 24 | local.properties 25 | 26 | # Proguard folder generated by Eclipse 27 | proguard/ 28 | 29 | # Log Files 30 | *.log 31 | 32 | # Android Studio Navigation editor temp files 33 | .navigation/ 34 | 35 | # Android Studio captures folder 36 | captures/ 37 | 38 | # IntelliJ 39 | *.iml 40 | .idea/ 41 | 42 | # Google Services (e.g. APIs or Firebase) 43 | google-services.json 44 | 45 | # Cache of project 46 | .gradletasknamecache 47 | 48 | #MacOS DS_Store 49 | **/.DS_Store 50 | 51 | # Kotlin 52 | *.salive 53 | 54 | # Claude Code 55 | .claude/ 56 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TrackApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.TrackRoomModel 4 | import com.igorwojda.showcase.feature.album.domain.model.Track 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | internal data class TrackApiModel( 10 | @SerialName("name") val name: String, 11 | @SerialName("duration") val duration: Int? = null, 12 | ) 13 | 14 | internal fun TrackApiModel.toDomainModel() = 15 | Track( 16 | name = this.name, 17 | duration = this.duration, 18 | ) 19 | 20 | internal fun TrackApiModel.toRoomModel() = 21 | TrackRoomModel( 22 | name = this.name, 23 | duration = this.duration, 24 | ) 25 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/ErrorAnim.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.compose.composable 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.tooling.preview.Preview 9 | import com.igorwojda.showcase.feature.base.R 10 | 11 | @Composable 12 | fun ErrorAnim(modifier: Modifier = Modifier) { 13 | Box( 14 | modifier = modifier.fillMaxSize(), 15 | contentAlignment = Alignment.Center, 16 | ) { 17 | LabeledAnimation(R.string.common_data_not_found, R.raw.lottie_error_screen) 18 | } 19 | } 20 | 21 | @Preview 22 | @Composable 23 | private fun ErrorAnimPreview() { 24 | ErrorAnim() 25 | } 26 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/AlbumDao.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.database 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel 8 | 9 | @Dao 10 | internal interface AlbumDao { 11 | @Query("SELECT * FROM albums") 12 | suspend fun getAll(): List 13 | 14 | @Query("SELECT * FROM albums where artist = :artistName and name = :albumName and mbId = :mbId") 15 | suspend fun getAlbum( 16 | artistName: String, 17 | albumName: String, 18 | mbId: String?, 19 | ): AlbumRoomModel 20 | 21 | @Insert(onConflict = OnConflictStrategy.REPLACE) 22 | suspend fun insertAlbums(albums: List) 23 | } 24 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListAction.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumlist 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Album 4 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseAction 5 | 6 | internal sealed interface AlbumListAction : BaseAction { 7 | object AlbumListLoadStart : AlbumListAction { 8 | override fun reduce(state: AlbumListUiState) = AlbumListUiState.Loading 9 | } 10 | 11 | class AlbumListLoadSuccess( 12 | private val albums: List, 13 | ) : AlbumListAction { 14 | override fun reduce(state: AlbumListUiState) = AlbumListUiState.Content(albums) 15 | } 16 | 17 | object AlbumListLoadFailure : AlbumListAction { 18 | override fun reduce(state: AlbumListUiState) = AlbumListUiState.Error 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/domain/model/AlbumTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.model 2 | 3 | import com.igorwojda.showcase.feature.album.domain.DomainFixtures 4 | import org.amshove.kluent.shouldBeEqualTo 5 | import org.junit.jupiter.api.Test 6 | 7 | class AlbumTest { 8 | private lateinit var sut: Album 9 | 10 | @Test 11 | fun `get default image url`() { 12 | // given 13 | val image = DomainFixtures.getImage() 14 | 15 | // when 16 | sut = DomainFixtures.getAlbum(images = listOf(image)) 17 | 18 | // then 19 | sut.getDefaultImageUrl() shouldBeEqualTo image.url 20 | } 21 | 22 | @Test 23 | fun `get null default image url`() { 24 | // given 25 | sut = DomainFixtures.getAlbum(images = listOf()) 26 | 27 | // then 28 | sut.getDefaultImageUrl() shouldBeEqualTo null 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/data/retrofit/ApiResult.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.data.retrofit 2 | 3 | sealed interface ApiResult { 4 | /** 5 | * Represents a network result that successfully received a response containing body data. 6 | */ 7 | data class Success( 8 | val data: T, 9 | ) : ApiResult 10 | 11 | /** 12 | * Represents a network result that successfully received a response containing an error message. 13 | */ 14 | data class Error( 15 | val code: Int, 16 | val message: String?, 17 | ) : ApiResult 18 | 19 | /** 20 | * Represents a network result that faced an unexpected exception before getting a response 21 | * from the network such as IOException and UnKnownHostException. 22 | */ 23 | data class Exception( 24 | val throwable: Throwable, 25 | ) : ApiResult 26 | } 27 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "android-showcase" 2 | 3 | include( 4 | ":app", 5 | ":feature:album", 6 | ":feature:settings", 7 | ":feature:favourite", 8 | ":feature:base", 9 | ":library:test-utils", 10 | ":konsist-test", 11 | ) 12 | 13 | pluginManagement { 14 | includeBuild("build-logic") 15 | 16 | repositories { 17 | gradlePluginPortal() 18 | google() 19 | mavenCentral() 20 | } 21 | } 22 | 23 | @Suppress("UnstableApiUsage") 24 | dependencyResolutionManagement { 25 | repositories { 26 | google() 27 | // Added for testing local Konsist artifacts 28 | mavenLocal() 29 | mavenCentral() 30 | } 31 | } 32 | 33 | // Generate type safe accessors when referring to other projects eg. 34 | // Before: implementation(project(":feature_album")) 35 | // After: implementation(projects.featureAlbum) 36 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 37 | -------------------------------------------------------------------------------- /feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/presentation/screen/favourite/FavouriteScreen.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.favourite.presentation.screen.favourite 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.tooling.preview.Preview 9 | import com.igorwojda.showcase.feature.base.presentation.compose.composable.UnderConstructionAnim 10 | 11 | @Composable 12 | fun FavouriteScreen(modifier: Modifier = Modifier) { 13 | Box( 14 | modifier = modifier.fillMaxSize(), 15 | contentAlignment = Alignment.Center, 16 | ) { 17 | UnderConstructionAnim() 18 | } 19 | } 20 | 21 | @Preview 22 | @Composable 23 | private fun FavouriteScreenPreview() { 24 | FavouriteScreen() 25 | } 26 | -------------------------------------------------------------------------------- /konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/ModuleKonsistTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.konsisttest 2 | 3 | import com.lemonappdev.konsist.api.Konsist 4 | import com.lemonappdev.konsist.api.verify.assertTrue 5 | import org.junit.jupiter.api.Test 6 | 7 | // Check architecture coding rules. 8 | class ModuleKonsistTest { 9 | @Test 10 | fun `every file in module reside in module specific package`() { 11 | Konsist 12 | .scopeFromProject() 13 | .files 14 | .assertTrue { 15 | val modulePackageName = 16 | it.moduleName 17 | .lowercase() 18 | .replace("/", ".") 19 | .replace("-", "") 20 | 21 | val fullyQualifiedPackageName = "com.igorwojda.showcase.$modulePackageName" 22 | 23 | it.packagee?.name?.startsWith(fullyQualifiedPackageName) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumListUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.usecase 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Album 4 | import com.igorwojda.showcase.feature.album.domain.repository.AlbumRepository 5 | import com.igorwojda.showcase.feature.base.domain.result.Result 6 | import com.igorwojda.showcase.feature.base.domain.result.mapSuccess 7 | 8 | internal class GetAlbumListUseCase( 9 | private val albumRepository: AlbumRepository, 10 | ) { 11 | suspend operator fun invoke(query: String?): Result> { 12 | val result = 13 | albumRepository 14 | .searchAlbum(query) 15 | .mapSuccess { 16 | val albumsWithImages = value.filter { it.getDefaultImageUrl() != null } 17 | 18 | copy(value = albumsWithImages) 19 | } 20 | 21 | return result 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailUiState.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.igorwojda.showcase.feature.album.domain.model.Tag 5 | import com.igorwojda.showcase.feature.album.domain.model.Track 6 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseState 7 | 8 | @Immutable 9 | internal sealed interface AlbumDetailUiState : BaseState { 10 | @Immutable 11 | data class Content( 12 | val albumName: String = "", 13 | val artistName: String = "", 14 | val coverImageUrl: String = "", 15 | val tracks: List? = emptyList(), 16 | val tags: List? = emptyList(), 17 | ) : AlbumDetailUiState 18 | 19 | @Immutable 20 | data object Loading : AlbumDetailUiState 21 | 22 | @Immutable 23 | data object Error : AlbumDetailUiState 24 | } 25 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TrackMapper.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.TrackApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.TrackRoomModel 5 | import com.igorwojda.showcase.feature.album.domain.model.Track 6 | 7 | internal class TrackMapper { 8 | fun apiToDomain(apiModel: TrackApiModel): Track = 9 | Track( 10 | name = apiModel.name, 11 | duration = apiModel.duration, 12 | ) 13 | 14 | fun apiToRoom(apiModel: TrackApiModel): TrackRoomModel = 15 | TrackRoomModel( 16 | name = apiModel.name, 17 | duration = apiModel.duration, 18 | ) 19 | 20 | fun roomToDomain(roomModel: TrackRoomModel): Track = 21 | Track( 22 | name = roomModel.name, 23 | duration = roomModel.duration, 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_settings.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /feature/settings/src/test/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.settings 2 | 3 | import com.igorwojda.showcase.library.testutils.CoroutinesTestDispatcherExtension 4 | import com.igorwojda.showcase.library.testutils.InstantTaskExecutorExtension 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.test.runTest 7 | import org.amshove.kluent.shouldBeEqualTo 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.extension.ExtendWith 10 | 11 | @OptIn(ExperimentalCoroutinesApi::class) 12 | @ExtendWith(InstantTaskExecutorExtension::class, CoroutinesTestDispatcherExtension::class) 13 | class SettingsViewModelTest { 14 | private val sut = SettingsViewModel() 15 | 16 | @Test 17 | fun `initial state should be Content`() = 18 | runTest { 19 | // then 20 | sut.uiStateFlow.value shouldBeEqualTo SettingsUiState.Content 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageMapper.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageRoomModel 5 | import com.igorwojda.showcase.feature.album.domain.model.Image 6 | 7 | internal class ImageMapper( 8 | private val imageSizeMapper: ImageSizeMapper, 9 | ) { 10 | fun apiToDomain(apiModel: ImageApiModel) = 11 | Image( 12 | url = apiModel.url, 13 | size = imageSizeMapper.apiToDomain(apiModel.size), 14 | ) 15 | 16 | fun apiToRoom(apiModel: ImageApiModel) = 17 | imageSizeMapper.apiToRoom(apiModel.size)?.let { 18 | ImageRoomModel(apiModel.url, it) 19 | } 20 | 21 | fun roomToDomain(roomModel: ImageRoomModel) = 22 | imageSizeMapper.roomToDomain(roomModel.size)?.let { 23 | Image(roomModel.url, it) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "lockFileMaintenance": { 7 | "enabled": true, 8 | "automerge": true, 9 | "automergeType": "pr", 10 | "platformAutomerge": true 11 | }, 12 | "packageRules": [ 13 | { 14 | "matchUpdateTypes": [ 15 | "minor", 16 | "patch" 17 | ], 18 | "matchCurrentVersion": "!/^0/", 19 | "automerge": true, 20 | "automergeType": "pr", 21 | "platformAutomerge": true 22 | }, 23 | { 24 | "matchPackagePatterns": [ 25 | "androidx.compose.ui:ui", 26 | "androidx.compose.ui:ui-tooling-preview" 27 | ], 28 | "groupName": "androidx compose ui updates" 29 | }, 30 | { 31 | "matchPackageNames": [ 32 | "org.jetbrains.kotlin.*", 33 | "com.google.devtools.ksp", 34 | "com.android.library", 35 | "com.android.application" 36 | ], 37 | "enabled": false 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/service/AlbumRetrofitService.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.service 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.response.GetAlbumInfoResponse 4 | import com.igorwojda.showcase.feature.album.data.datasource.api.response.SearchAlbumResponse 5 | import com.igorwojda.showcase.feature.base.data.retrofit.ApiResult 6 | import retrofit2.http.POST 7 | import retrofit2.http.Query 8 | 9 | internal interface AlbumRetrofitService { 10 | @POST("./?method=album.search") 11 | suspend fun searchAlbumAsync( 12 | @Query("album") phrase: String?, 13 | @Query("limit") limit: Int = 60, 14 | ): ApiResult 15 | 16 | @POST("./?method=album.getInfo") 17 | suspend fun getAlbumInfoAsync( 18 | @Query("artist") artistName: String, 19 | @Query("album") albumName: String, 20 | @Query("mbid") mbId: String?, 21 | ): ApiResult 22 | } 23 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/data/retrofit/ApiResultAdapterFactory.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.data.retrofit 2 | 3 | import retrofit2.Call 4 | import retrofit2.CallAdapter 5 | import retrofit2.Retrofit 6 | import java.lang.reflect.ParameterizedType 7 | import java.lang.reflect.Type 8 | 9 | class ApiResultAdapterFactory : CallAdapter.Factory() { 10 | override fun get( 11 | returnType: Type, 12 | annotations: Array, 13 | retrofit: Retrofit, 14 | ): CallAdapter<*, *>? { 15 | if (Call::class.java != getRawType(returnType)) return null 16 | check(returnType is ParameterizedType) 17 | 18 | val responseType = getParameterUpperBound(0, returnType) 19 | if (getRawType(responseType) != ApiResult::class.java) return null 20 | check(responseType is ParameterizedType) 21 | 22 | val successType = getParameterUpperBound(0, responseType) 23 | 24 | return ApiResultCallAdapter(successType) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/data/api/UserAgentInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app.data.api 2 | 3 | import com.igorwojda.showcase.app.BuildConfig 4 | import okhttp3.Interceptor 5 | import okhttp3.Response 6 | 7 | /* 8 | * Adds a User-Agent header to the request. The header follows this format: 9 | * / Dalvik/ (Linux; U; Android ; Build/) 10 | * 11 | * See user agents in mobile apps: https://www.scientiamobile.com/correctly-form-user-agents-for-mobile-apps 12 | * See testing user agent: https://faisalman.github.io/ua-parser-js/ 13 | */ 14 | class UserAgentInterceptor : Interceptor { 15 | private val userAgent = "showcase/${BuildConfig.VERSION_NAME} ${System.getProperty("http.agent")}" 16 | 17 | override fun intercept(chain: Interceptor.Chain): Response = 18 | chain 19 | .request() 20 | .newBuilder() 21 | .header("User-Agent", userAgent) 22 | .build() 23 | .let { chain.proceed(it) } 24 | } 25 | -------------------------------------------------------------------------------- /feature/settings/src/test/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries 2 | 3 | import com.igorwojda.showcase.library.testutils.CoroutinesTestDispatcherExtension 4 | import com.igorwojda.showcase.library.testutils.InstantTaskExecutorExtension 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.test.runTest 7 | import org.amshove.kluent.shouldBeEqualTo 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.extension.ExtendWith 10 | 11 | @OptIn(ExperimentalCoroutinesApi::class) 12 | @ExtendWith(InstantTaskExecutorExtension::class, CoroutinesTestDispatcherExtension::class) 13 | class AboutLibrariesViewModelTest { 14 | private val sut = AboutLibrariesViewModel() 15 | 16 | @Test 17 | fun `initial state should be Content`() = 18 | runTest { 19 | // then 20 | sut.uiStateFlow.value shouldBeEqualTo AboutLibrariesUiState.Content 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.igorwojda.showcase.buildlogic.ext.buildConfigFieldFromGradleProperty 2 | 3 | plugins { 4 | id("com.igorwojda.showcase.convention.application") 5 | } 6 | 7 | android { 8 | namespace = "com.igorwojda.showcase.app" 9 | 10 | defaultConfig { 11 | applicationId = "com.igorwojda.showcase" 12 | 13 | versionCode = 1 14 | versionName = "0.0.1" // SemVer (Major.Minor.Patch) 15 | 16 | buildConfigFieldFromGradleProperty(project, "apiBaseUrl") 17 | buildConfigFieldFromGradleProperty(project, "apiToken") 18 | } 19 | 20 | buildTypes { 21 | getByName("release") { 22 | isMinifyEnabled = false 23 | proguardFiles("proguard-android.txt", "proguard-rules.pro") 24 | } 25 | } 26 | } 27 | 28 | dependencies { 29 | // "projects." Syntax utilizes Gradle TYPESAFE_PROJECT_ACCESSORS feature 30 | implementation(projects.feature.base) 31 | implementation(projects.feature.album) 32 | implementation(projects.feature.settings) 33 | implementation(projects.feature.favourite) 34 | } 35 | -------------------------------------------------------------------------------- /feature/base/src/main/res/drawable/image_placeholder_3.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/Loading.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.compose.composable 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material3.CircularProgressIndicator 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import com.igorwojda.showcase.feature.base.common.res.Dimen 12 | 13 | @Composable 14 | fun LoadingIndicator(modifier: Modifier = Modifier) { 15 | Box( 16 | modifier = modifier.fillMaxSize(), 17 | contentAlignment = Alignment.Center, 18 | ) { 19 | CircularProgressIndicator( 20 | modifier = 21 | Modifier 22 | .size(Dimen.spaceXXL), 23 | ) 24 | } 25 | } 26 | 27 | @Preview 28 | @Composable 29 | private fun LoadingIndicatorPreview() { 30 | LoadingIndicator() 31 | } 32 | -------------------------------------------------------------------------------- /feature/base/src/main/res/drawable/image_placeholder_2.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /feature/base/src/main/res/drawable/image_placeholder_1.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/CleanArchitectureKonsistTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.konsisttest 2 | 3 | import com.lemonappdev.konsist.api.Konsist 4 | import com.lemonappdev.konsist.api.architecture.KoArchitectureCreator.assertArchitecture 5 | import com.lemonappdev.konsist.api.architecture.Layer 6 | import org.junit.jupiter.api.Test 7 | 8 | // Check architecture coding rules. 9 | class CleanArchitectureKonsistTest { 10 | @Test 11 | fun `clean architecture layers have correct dependencies`() { 12 | Konsist 13 | .scopeFromProduction() 14 | .assertArchitecture { 15 | // Define layers 16 | val packagePrefix = "com.igorwojda.showcase" 17 | val domain = Layer("Domain", "$packagePrefix..domain..") 18 | val presentation = Layer("Presentation", "$packagePrefix..presentation..") 19 | val data = Layer("Data", "$packagePrefix..data..") 20 | 21 | // Define architecture assertions 22 | domain.dependsOnNothing() 23 | presentation.dependsOn(domain) 24 | data.dependsOn(domain) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailAction.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Album 4 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseAction 5 | 6 | internal sealed interface AlbumDetailAction : BaseAction { 7 | object AlbumLoadStart : AlbumDetailAction { 8 | override fun reduce(state: AlbumDetailUiState) = AlbumDetailUiState.Loading 9 | } 10 | 11 | class AlbumLoadSuccess( 12 | private val album: Album, 13 | ) : AlbumDetailAction { 14 | override fun reduce(state: AlbumDetailUiState) = 15 | AlbumDetailUiState.Content( 16 | artistName = album.artist, 17 | albumName = album.name, 18 | coverImageUrl = album.getDefaultImageUrl() ?: "", 19 | tracks = album.tracks, 20 | tags = album.tags, 21 | ) 22 | } 23 | 24 | object AlbumLoadFailure : AlbumDetailAction { 25 | override fun reduce(state: AlbumDetailUiState) = AlbumDetailUiState.Error 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/ShowcaseApplication.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app 2 | 3 | import android.app.Application 4 | import com.igorwojda.showcase.feature.album.featureAlbumModules 5 | import com.igorwojda.showcase.feature.favourite.featureFavouriteModules 6 | import com.igorwojda.showcase.feature.settings.featureSettingsModules 7 | import org.koin.android.ext.koin.androidContext 8 | import org.koin.android.ext.koin.androidLogger 9 | import org.koin.core.context.GlobalContext 10 | import timber.log.Timber 11 | 12 | class ShowcaseApplication : Application() { 13 | override fun onCreate() { 14 | super.onCreate() 15 | 16 | initKoin() 17 | initTimber() 18 | } 19 | 20 | private fun initKoin() { 21 | GlobalContext.startKoin { 22 | androidLogger() 23 | androidContext(this@ShowcaseApplication) 24 | 25 | modules(appModule) 26 | modules(featureFavouriteModules) 27 | modules(featureAlbumModules) 28 | modules(featureSettingsModules) 29 | } 30 | } 31 | 32 | private fun initTimber() { 33 | if (BuildConfig.DEBUG) { 34 | Timber.plant(Timber.DebugTree()) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /library/test-utils/src/main/kotlin/com/igorwojda/showcase/library/testutils/CoroutinesTestDispatcherExtension.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.library.testutils 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.StandardTestDispatcher 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.setMain 8 | import org.junit.jupiter.api.extension.AfterEachCallback 9 | import org.junit.jupiter.api.extension.BeforeEachCallback 10 | import org.junit.jupiter.api.extension.ExtensionContext 11 | 12 | /** 13 | * A JUnit Test Extension that swaps the coroutine dispatcher for the TestDispatcher. 14 | * 15 | * Add this JUnit 5 extension to your test class using 16 | * @JvmField 17 | * @RegisterExtension 18 | * val coroutinesTestExtension = CoroutinesTestExtension() 19 | */ 20 | @ExperimentalCoroutinesApi 21 | class CoroutinesTestDispatcherExtension : 22 | BeforeEachCallback, 23 | AfterEachCallback { 24 | override fun beforeEach(context: ExtensionContext) { 25 | Dispatchers.setMain(StandardTestDispatcher()) 26 | } 27 | 28 | override fun afterEach(context: ExtensionContext) { 29 | Dispatchers.resetMain() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/domain/DomainFixtures.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain 2 | 3 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 4 | import com.igorwojda.showcase.feature.album.domain.model.Album 5 | import com.igorwojda.showcase.feature.album.domain.model.Image 6 | import com.igorwojda.showcase.feature.album.domain.model.Tag 7 | import com.igorwojda.showcase.feature.album.domain.model.Track 8 | 9 | object DomainFixtures { 10 | internal fun getAlbum( 11 | name: String = "albumName", 12 | artist: String = "artistName", 13 | mbId: String? = "mbId", 14 | images: List = listOf(getImage()), 15 | tracks: List = listOf(getTrack()), 16 | tags: List = listOf(getTag()), 17 | ): Album = Album(name, artist, mbId, images, tracks, tags) 18 | 19 | internal fun getImage( 20 | url: String = "url_${ImageSize.EXTRA_LARGE}", 21 | size: ImageSize = ImageSize.EXTRA_LARGE, 22 | ) = Image(url, size) 23 | 24 | private fun getTrack( 25 | name: String = "track", 26 | duration: Int = 12, 27 | ) = Track(name, duration) 28 | 29 | private fun getTag(name: String = "tag") = Tag(name) 30 | } 31 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageApiModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import com.igorwojda.showcase.feature.album.data.DataFixtures 4 | import com.igorwojda.showcase.feature.album.domain.model.Image 5 | import org.amshove.kluent.shouldBeEqualTo 6 | import org.amshove.kluent.shouldThrow 7 | import org.junit.jupiter.api.Test 8 | 9 | class ImageApiModelTest { 10 | @Test 11 | fun `map to AlbumWikiDomainModel`() { 12 | // given 13 | val url = "url" 14 | val size = ImageSizeApiModel.EXTRA_LARGE 15 | val sut = DataFixtures.getImageModelApiModel(url, size) 16 | 17 | // when 18 | val domainModel = sut.toDomainModel() 19 | 20 | // then 21 | domainModel shouldBeEqualTo Image(url, size.toDomainModel()) 22 | } 23 | 24 | @Test 25 | fun `crash when mapping unknown AlbumWikiDomainModel`() { 26 | // given 27 | val url = "url" 28 | val size = ImageSizeApiModel.UNKNOWN 29 | val sut = DataFixtures.getImageModelApiModel(url, size) 30 | 31 | // when 32 | val func = { sut.toDomainModel() } 33 | 34 | // then 35 | func shouldThrow IllegalArgumentException::class 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageSizeApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageSizeRoomModel 4 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | internal enum class ImageSizeApiModel { 10 | @SerialName("medium") 11 | MEDIUM, 12 | 13 | @SerialName("small") 14 | SMALL, 15 | 16 | @SerialName("large") 17 | LARGE, 18 | 19 | @SerialName("extralarge") 20 | EXTRA_LARGE, 21 | 22 | @SerialName("mega") 23 | MEGA, 24 | 25 | @SerialName("") 26 | UNKNOWN, 27 | } 28 | 29 | internal fun ImageSizeApiModel.toDomainModel() = ImageSize.valueOf(this.name) 30 | 31 | internal fun ImageSizeApiModel.toRoomModel(): ImageSizeRoomModel? = 32 | when (this) { 33 | ImageSizeApiModel.MEDIUM -> ImageSizeRoomModel.MEDIUM 34 | ImageSizeApiModel.SMALL -> ImageSizeRoomModel.SMALL 35 | ImageSizeApiModel.LARGE -> ImageSizeRoomModel.LARGE 36 | ImageSizeApiModel.EXTRA_LARGE -> ImageSizeRoomModel.EXTRA_LARGE 37 | ImageSizeApiModel.MEGA -> ImageSizeRoomModel.MEGA 38 | ImageSizeApiModel.UNKNOWN -> null 39 | } 40 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TagMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.TagApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.TagRoomModel 5 | import com.igorwojda.showcase.feature.album.domain.model.Tag 6 | import org.amshove.kluent.shouldBeEqualTo 7 | import org.junit.jupiter.api.Test 8 | 9 | class TagMapperTest { 10 | private val sut = TagMapper() 11 | 12 | @Test 13 | fun `apiToDomain maps tag correctly`() { 14 | // given 15 | val apiModel = TagApiModel("rock") 16 | 17 | // when 18 | val result = sut.apiToDomain(apiModel) 19 | 20 | // then 21 | result shouldBeEqualTo Tag("rock") 22 | } 23 | 24 | @Test 25 | fun `apiToRoom maps tag correctly`() { 26 | // given 27 | val apiModel = TagApiModel("rock") 28 | 29 | // when 30 | val result = sut.apiToRoom(apiModel) 31 | 32 | // then 33 | result shouldBeEqualTo TagRoomModel("rock") 34 | } 35 | 36 | @Test 37 | fun `roomToDomain maps tag correctly`() { 38 | // given 39 | val roomModel = TagRoomModel("rock") 40 | 41 | // when 42 | val result = sut.roomToDomain(roomModel) 43 | 44 | // then 45 | result shouldBeEqualTo Tag("rock") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageSizeMapper.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageSizeApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageSizeRoomModel 5 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 6 | 7 | internal class ImageSizeMapper { 8 | fun apiToDomain(apiModel: ImageSizeApiModel): ImageSize = ImageSize.valueOf(apiModel.name) 9 | 10 | fun apiToRoom(apiModel: ImageSizeApiModel): ImageSizeRoomModel? = 11 | when (apiModel) { 12 | ImageSizeApiModel.MEDIUM -> ImageSizeRoomModel.MEDIUM 13 | ImageSizeApiModel.SMALL -> ImageSizeRoomModel.SMALL 14 | ImageSizeApiModel.LARGE -> ImageSizeRoomModel.LARGE 15 | ImageSizeApiModel.EXTRA_LARGE -> ImageSizeRoomModel.EXTRA_LARGE 16 | ImageSizeApiModel.MEGA -> ImageSizeRoomModel.MEGA 17 | ImageSizeApiModel.UNKNOWN -> null 18 | } 19 | 20 | fun roomToDomain(roomModel: ImageSizeRoomModel): ImageSize? = 21 | when (roomModel) { 22 | ImageSizeRoomModel.MEDIUM -> ImageSize.MEDIUM 23 | ImageSizeRoomModel.SMALL -> ImageSize.SMALL 24 | ImageSizeRoomModel.LARGE -> ImageSize.LARGE 25 | ImageSizeRoomModel.EXTRA_LARGE -> ImageSize.EXTRA_LARGE 26 | ImageSizeRoomModel.MEGA -> ImageSize.MEGA 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/GeneralKonsistTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.konsisttest 2 | 3 | import com.lemonappdev.konsist.api.Konsist 4 | import com.lemonappdev.konsist.api.ext.list.properties 5 | import com.lemonappdev.konsist.api.verify.assertFalse 6 | import com.lemonappdev.konsist.api.verify.assertTrue 7 | import org.junit.jupiter.api.Test 8 | 9 | // Check General coding rules. 10 | class GeneralKonsistTest { 11 | @Test 12 | fun `package name must match file path`() { 13 | Konsist 14 | .scopeFromProject() 15 | .packages 16 | .assertTrue { it.hasMatchingPath } 17 | } 18 | 19 | @Test 20 | fun `no field should have 'm' prefix`() { 21 | Konsist 22 | .scopeFromProject() 23 | .classes() 24 | .properties() 25 | .assertFalse { 26 | val secondCharacterIsUppercase = it.name.getOrNull(1)?.isUpperCase() ?: false 27 | it.name.startsWith('m') && secondCharacterIsUppercase 28 | } 29 | } 30 | 31 | @Test 32 | fun `no class should use Android util logging`() { 33 | Konsist 34 | .scopeFromProject() 35 | .files 36 | .assertFalse { it.hasImportWithName("android.util.Log") } 37 | } 38 | 39 | @Test 40 | fun `no empty files allowed`() { 41 | Konsist 42 | .scopeFromProject() 43 | .files 44 | .assertFalse { it.text.isEmpty() } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TrackMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.TrackApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.TrackRoomModel 5 | import com.igorwojda.showcase.feature.album.domain.model.Track 6 | import org.amshove.kluent.shouldBeEqualTo 7 | import org.junit.jupiter.api.Test 8 | 9 | class TrackMapperTest { 10 | private val trackMapper = TrackMapper() 11 | 12 | @Test 13 | fun `apiToDomain maps track correctly`() { 14 | // given 15 | val apiModel = TrackApiModel("Test Track", 180) 16 | 17 | // when 18 | val result = trackMapper.apiToDomain(apiModel) 19 | 20 | // then 21 | result shouldBeEqualTo Track("Test Track", 180) 22 | } 23 | 24 | @Test 25 | fun `apiToRoom maps track correctly`() { 26 | // given 27 | val apiModel = TrackApiModel("Test Track", 180) 28 | 29 | // when 30 | val result = trackMapper.apiToRoom(apiModel) 31 | 32 | // then 33 | result shouldBeEqualTo TrackRoomModel("Test Track", 180) 34 | } 35 | 36 | @Test 37 | fun `roomToDomain maps track correctly`() { 38 | // given 39 | val roomModel = TrackRoomModel("Test Track", 180) 40 | 41 | // when 42 | val result = trackMapper.roomToDomain(roomModel) 43 | 44 | // then 45 | result shouldBeEqualTo Track("Test Track", 180) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /library/test-utils/src/main/kotlin/com/igorwojda/showcase/library/testutils/InstantTaskExecutorExtension.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.library.testutils 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.arch.core.executor.ArchTaskExecutor 5 | import androidx.arch.core.executor.TaskExecutor 6 | import org.junit.jupiter.api.extension.AfterEachCallback 7 | import org.junit.jupiter.api.extension.BeforeEachCallback 8 | import org.junit.jupiter.api.extension.ExtensionContext 9 | 10 | /** 11 | * A JUnit Test Extension that swaps the background executor used by the Architecture Components with a 12 | * different one which executes each task synchronously. 13 | * 14 | * Extension can be used for your host side tests that use Architecture Components. 15 | */ 16 | @SuppressLint("RestrictedApi") 17 | class InstantTaskExecutorExtension : 18 | BeforeEachCallback, 19 | AfterEachCallback { 20 | override fun beforeEach(context: ExtensionContext) { 21 | ArchTaskExecutor.getInstance().setDelegate( 22 | object : TaskExecutor() { 23 | override fun executeOnDiskIO(runnable: Runnable) { 24 | runnable.run() 25 | } 26 | 27 | override fun postToMainThread(runnable: Runnable) { 28 | runnable.run() 29 | } 30 | 31 | override fun isMainThread() = true 32 | }, 33 | ) 34 | } 35 | 36 | override fun afterEach(context: ExtensionContext) { 37 | ArchTaskExecutor.getInstance().setDelegate(null) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/viewmodel/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.igorwojda.showcase.feature.base.BuildConfig 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.asStateFlow 7 | import kotlin.properties.Delegates 8 | 9 | abstract class BaseViewModel>( 10 | initialState: State, 11 | ) : ViewModel() { 12 | private val _uiStateFlow = MutableStateFlow(initialState) 13 | val uiStateFlow = _uiStateFlow.asStateFlow() 14 | 15 | private var stateTimeTravelDebugger: StateTimeTravelDebugger? = null 16 | 17 | init { 18 | if (BuildConfig.DEBUG) { 19 | stateTimeTravelDebugger = StateTimeTravelDebugger(this::class.java.simpleName) 20 | } 21 | } 22 | 23 | // Delegate handles state event deduplication (multiple states of the same type holding the same data 24 | // will not be emitted multiple times to UI) 25 | private var state by Delegates.observable(initialState) { _, old, new -> 26 | if (old != new) { 27 | _uiStateFlow.value = new 28 | 29 | stateTimeTravelDebugger?.apply { 30 | addStateTransition(old, new) 31 | logLast() 32 | } 33 | } 34 | } 35 | 36 | protected fun sendAction(action: Action) { 37 | stateTimeTravelDebugger?.addAction(action) 38 | state = action.reduce(state) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We appreciate contributions of any kind - new contributions 4 | are welcome whether it's through bug reports or new pull requests. 5 | 6 | ## Tell us about enhancements and bugs 7 | 8 | Please add an issue. We'll review it, add labels and reply when we get the chance. 9 | 10 | ## See an issue you'd like to work on 11 | 12 | Comment on the issue that you'd like to work on and we'll add the 13 | `claimed` label. If you see the `claimed` label already on the issue you 14 | might want to ask the contributor if they'd like some help. 15 | 16 | ## Documentation needs updating 17 | 18 | Go right ahead! Just submit a pull request when you're done. 19 | 20 | ## Pull Requests 21 | 22 | We love pull requests from everyone: 23 | 24 | 1. [Fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) this repository: 25 | 2. Clone forked repository `git clone git@github.com:YOUR-USERNAME/android-showcase.git` 26 | 3. Branch of the `main` branch. 27 | 4. Make changes, push changes to your fork and 28 | [submit a pull request](https://github.com/igorwojda/android-showcase/compare) against the `main` branch. 29 | 30 | At this point you're waiting on us. We like to at least comment on pull requests within few days. We may suggest some 31 | changes or improvements or alternatives. 32 | 33 | Some things that will increase the chance that your pull request is accepted: 34 | 35 | 1. Write a [good commit message](https://chris.beams.io/posts/git-commit/) 36 | 2. Make sure all tests and lint checks are passing (review them on the pull request page) 37 | 3. Update [README](README.md) with any changes are needed 38 | 4. Write tests (if needed) 39 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumUseCase 5 | import com.igorwojda.showcase.feature.base.domain.result.Result.Failure 6 | import com.igorwojda.showcase.feature.base.domain.result.Result.Success 7 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseViewModel 8 | import kotlinx.coroutines.launch 9 | 10 | internal class AlbumDetailViewModel( 11 | private val getAlbumUseCase: GetAlbumUseCase, 12 | ) : BaseViewModel(AlbumDetailUiState.Loading) { 13 | fun onInit( 14 | albumName: String, 15 | artistName: String, 16 | albumMbId: String?, 17 | ) { 18 | getAlbum(albumName, artistName, albumMbId) 19 | } 20 | 21 | private fun getAlbum( 22 | albumName: String, 23 | artistName: String, 24 | albumMbId: String?, 25 | ) { 26 | sendAction(AlbumDetailAction.AlbumLoadStart) 27 | 28 | viewModelScope.launch { 29 | getAlbumUseCase(artistName, albumName, albumMbId).also { 30 | when (it) { 31 | is Success -> { 32 | sendAction(AlbumDetailAction.AlbumLoadSuccess(it.value)) 33 | } 34 | is Failure -> { 35 | sendAction(AlbumDetailAction.AlbumLoadFailure) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageSizeMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageSizeApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageSizeRoomModel 5 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 6 | import org.amshove.kluent.shouldBeEqualTo 7 | import org.junit.jupiter.api.Test 8 | 9 | class ImageSizeMapperTest { 10 | private val sut = ImageSizeMapper() 11 | 12 | @Test 13 | fun `apiToDomain maps image size correctly`() { 14 | // when & then 15 | sut.apiToDomain(ImageSizeApiModel.LARGE) shouldBeEqualTo ImageSize.LARGE 16 | sut.apiToDomain(ImageSizeApiModel.MEDIUM) shouldBeEqualTo ImageSize.MEDIUM 17 | sut.apiToDomain(ImageSizeApiModel.SMALL) shouldBeEqualTo ImageSize.SMALL 18 | } 19 | 20 | @Test 21 | fun `apiToRoom maps image size correctly`() { 22 | // when & then 23 | sut.apiToRoom(ImageSizeApiModel.LARGE) shouldBeEqualTo ImageSizeRoomModel.LARGE 24 | sut.apiToRoom(ImageSizeApiModel.MEDIUM) shouldBeEqualTo ImageSizeRoomModel.MEDIUM 25 | sut.apiToRoom(ImageSizeApiModel.SMALL) shouldBeEqualTo ImageSizeRoomModel.SMALL 26 | } 27 | 28 | @Test 29 | fun `roomToDomain maps image size correctly`() { 30 | // when & then 31 | sut.roomToDomain(ImageSizeRoomModel.LARGE) shouldBeEqualTo ImageSize.LARGE 32 | sut.roomToDomain(ImageSizeRoomModel.MEDIUM) shouldBeEqualTo ImageSize.MEDIUM 33 | sut.roomToDomain(ImageSizeRoomModel.SMALL) shouldBeEqualTo ImageSize.SMALL 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/DataModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data 2 | 3 | import androidx.room.Room 4 | import com.igorwojda.showcase.feature.album.data.datasource.api.service.AlbumRetrofitService 5 | import com.igorwojda.showcase.feature.album.data.datasource.database.AlbumDatabase 6 | import com.igorwojda.showcase.feature.album.data.mapper.AlbumMapper 7 | import com.igorwojda.showcase.feature.album.data.mapper.ImageMapper 8 | import com.igorwojda.showcase.feature.album.data.mapper.ImageSizeMapper 9 | import com.igorwojda.showcase.feature.album.data.mapper.TagMapper 10 | import com.igorwojda.showcase.feature.album.data.mapper.TrackMapper 11 | import com.igorwojda.showcase.feature.album.data.repository.AlbumRepositoryImpl 12 | import com.igorwojda.showcase.feature.album.domain.repository.AlbumRepository 13 | import org.koin.core.module.dsl.bind 14 | import org.koin.core.module.dsl.singleOf 15 | import org.koin.dsl.module 16 | import retrofit2.Retrofit 17 | 18 | internal val dataModule = 19 | module { 20 | 21 | singleOf(::AlbumRepositoryImpl) { bind() } 22 | 23 | single { get().create(AlbumRetrofitService::class.java) } 24 | 25 | single { 26 | Room 27 | .databaseBuilder( 28 | get(), 29 | AlbumDatabase::class.java, 30 | "Albums.db", 31 | ).build() 32 | } 33 | 34 | single { get().albums() } 35 | 36 | singleOf(::ImageSizeMapper) 37 | singleOf(::ImageMapper) 38 | singleOf(::TrackMapper) 39 | singleOf(::TagMapper) 40 | singleOf(::AlbumMapper) 41 | } 42 | -------------------------------------------------------------------------------- /DeveloperReadme.md: -------------------------------------------------------------------------------- 1 | # Developer Readme 2 | 3 | ## Detekt 4 | 5 | - [Detekt configuration](https://detekt.dev/docs/introduction/configurations/) contains link to `default-detekt-config.yml`. 6 | 7 | ## Known Issues 8 | 9 | - AboutLibraries 10 | - AboutLibraries `12.2.4` Gradle plugin does nto include test dependencies https://github.com/mikepenz/AboutLibraries/issues/1238 11 | - AboutLibraries `13.0.0-rc01` Gradle plugin required Kotlin 2.2.0 https://github.com/mikepenz/AboutLibraries/issues/1237 12 | - Gradle 13 | - Gradle `9.0` - Generated type-safe version catalogs accessors for `projcts` are not avialable inside `build-logic` module 14 | - Gradle `9.0` - Generated type-safe version catalogs accessors for `libs` are not accessible from precompiled script plugin e.g. add("implementation", libs.koin). Workaround is to use `implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))`. 15 | - Mockk 16 | - Unable to mock some methods with implicit `continuation` 17 | parameter in the `AlbumListViewModelTest` class ([Issue-957](https://github.com/mockk/mockk/issues/957)) 18 | - Detekt 19 | - The `UnnecessaryParentheses` rule was disabled https://github.com/detekt/detekt/issues/8668 20 | - Kotlin Plugin 21 | - Auto-import (an import intention) for delegate does not work if the variable has the same name https://youtrack.jetbrains.com/issue/KTIJ-17403 22 | - Android Studio 23 | - False positive "Unused symbol" for a custom Android application class referenced in `AndroidManifest.xml` 24 | file ([KT-27971](https://youtrack.jetbrains.net/issue/KT-27971)) 25 | - Coil 26 | - No way to automatically retry image load, so some images may not be loaded when connection speed 27 | is low ([Issue 132](https://github.com/coil-kt/coil/issues/132)) 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/presentation/MainShowcaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app.presentation 2 | 3 | import android.os.Build 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.compose.foundation.isSystemInDarkTheme 8 | import androidx.compose.material3.ColorScheme 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.darkColorScheme 11 | import androidx.compose.material3.dynamicDarkColorScheme 12 | import androidx.compose.material3.dynamicLightColorScheme 13 | import androidx.compose.material3.lightColorScheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.platform.LocalContext 16 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 17 | 18 | class MainShowcaseActivity : ComponentActivity() { 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | installSplashScreen() 21 | 22 | super.onCreate(savedInstanceState) 23 | 24 | setContent { 25 | MaterialTheme(colorScheme = getColorScheme()) { 26 | MainShowcaseScreen() 27 | } 28 | } 29 | } 30 | 31 | @Composable 32 | private fun getColorScheme(): ColorScheme { 33 | val darkTheme: Boolean = isSystemInDarkTheme() 34 | val dynamicColor: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 35 | 36 | val context = LocalContext.current 37 | return when { 38 | dynamicColor -> if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 39 | darkTheme -> darkColorScheme() 40 | else -> lightColorScheme() 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel 4 | import com.igorwojda.showcase.feature.album.domain.model.Album 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | internal data class AlbumApiModel( 10 | @SerialName("mbid") val mbId: String? = null, 11 | @SerialName("name") val name: String, 12 | @SerialName("artist") val artist: String, 13 | @SerialName("image") val images: List? = null, 14 | @SerialName("tracks") val tracks: TrackListApiModel? = null, 15 | @SerialName("tags") val tags: TagListApiModel? = null, 16 | ) 17 | 18 | internal fun AlbumApiModel.toRoomModel() = 19 | AlbumRoomModel( 20 | mbId = this.mbId ?: "", 21 | name = this.name, 22 | artist = this.artist, 23 | images = this.images?.mapNotNull { it.toRoomModel() } ?: listOf(), 24 | tracks = this.tracks?.track?.map { it.toRoomModel() }, 25 | tags = this.tags?.tag?.map { it.toRoomModel() }, 26 | ) 27 | 28 | internal fun AlbumApiModel.toDomainModel(): Album { 29 | val images = 30 | this.images 31 | ?.filterNot { it.size == ImageSizeApiModel.UNKNOWN || it.url.isBlank() } 32 | ?.map { it.toDomainModel() } 33 | 34 | return Album( 35 | mbId = this.mbId, 36 | name = this.name, 37 | artist = this.artist, 38 | images = images ?: listOf(), 39 | tracks = this.tracks?.track?.map { it.toDomainModel() }, 40 | tags = this.tags?.tag?.map { it.toDomainModel() }, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/ViewModelKonsistTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.konsisttest 2 | 3 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseViewModel 4 | import com.lemonappdev.konsist.api.KoModifier 5 | import com.lemonappdev.konsist.api.Konsist 6 | import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutAllModifiers 7 | import com.lemonappdev.konsist.api.ext.list.withDeclarations 8 | import com.lemonappdev.konsist.api.ext.list.withNameEndingWith 9 | import com.lemonappdev.konsist.api.ext.list.withParentClassOf 10 | import com.lemonappdev.konsist.api.verify.assertTrue 11 | import java.util.Locale 12 | import org.junit.jupiter.api.Test 13 | 14 | // Check test coding rules. 15 | class ViewModelKonsistTest { 16 | @Test 17 | fun `every view model has test`() { 18 | Konsist 19 | .scopeFromProduction() 20 | .classes() 21 | .withParentClassOf(BaseViewModel::class) 22 | .withDeclarations() // Filter out empty view models 23 | .assertTrue { 24 | it.hasTestClasses(testPropertyName = "sut") 25 | } 26 | } 27 | 28 | @Test 29 | fun `every view model constructor parameter has name derived from parameter type`() { 30 | Konsist 31 | .scopeFromProject() 32 | .classes() 33 | .withNameEndingWith("ViewModel") 34 | .withoutAllModifiers(KoModifier.ABSTRACT) 35 | .flatMap { it.constructors } 36 | .flatMap { it.parameters } 37 | .assertTrue { 38 | val nameTitleCase = it.name.replaceFirstChar { char -> char.titlecase(Locale.getDefault()) } 39 | nameTitleCase == it.type.sourceType 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # -------Gradle-------- 8 | # Specifies the JVM arguments used for the daemon process. 9 | # The setting is particularly useful for tweaking memory settings. 10 | org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 11 | org.gradle.daemon=true 12 | org.gradle.parallel=true 13 | org.gradle.caching=true 14 | org.gradle.configuration-cache=true 15 | 16 | # -------Build parameters-------- 17 | # Values may be overridden in CI using gradlew "-Pname=value" param 18 | apiBaseUrl="http://ws.audioscrobbler.com/2.0/" 19 | # Typically we shouldn't store token in public repository, however this is just a sample project, so 20 | # we can favour convenience (app can be compiled and launched after checkout) over security (each person who 21 | # checkouts the project must generate own api key and change app configuration before running it). 22 | # In real-live setup this key could be provided\overriden by CI. 23 | apiToken="70696db59158cb100370ad30a7a705c1" 24 | 25 | # -------Kotlin-------- 26 | # Kotlin code style for this project: "official" or "obsolete": 27 | kotlin.code.style=official 28 | kapt.use.worker.api=true 29 | # Enable Compile Avoidance, which skips annotation processing if only method bodies are changed in dependencies 30 | # To turn on Compile Avoidance we need to turn off AP discovery in compile path. 31 | kapt.include.compile.classpath=false 32 | 33 | # -------Android------- 34 | android.useAndroidX=true 35 | android.enableJetifier=true 36 | android.nonTransitiveRClass=true 37 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.usecase 2 | 3 | import com.igorwojda.showcase.feature.album.data.repository.AlbumRepositoryImpl 4 | import com.igorwojda.showcase.feature.album.domain.model.Album 5 | import com.igorwojda.showcase.feature.base.domain.result.Result 6 | import io.mockk.coEvery 7 | import io.mockk.coVerify 8 | import io.mockk.mockk 9 | import kotlinx.coroutines.runBlocking 10 | import org.amshove.kluent.shouldBeEqualTo 11 | import org.junit.jupiter.api.Test 12 | 13 | class GetAlbumUseCaseTest { 14 | private val mockAlbumRepository: AlbumRepositoryImpl = mockk() 15 | 16 | private val sut = GetAlbumUseCase(mockAlbumRepository) 17 | 18 | @Test 19 | fun `return album`() { 20 | // given 21 | val albumName = "Thriller" 22 | val artistName = "Michael Jackson" 23 | val mbId = "123" 24 | 25 | val album = mockk() 26 | coEvery { mockAlbumRepository.getAlbumInfo(artistName, albumName, mbId) } answers { Result.Success(album) } 27 | 28 | // when 29 | val actual = runBlocking { sut(artistName, albumName, mbId) } 30 | 31 | // then 32 | actual shouldBeEqualTo Result.Success(album) 33 | } 34 | 35 | @Test 36 | fun `return error`() { 37 | // given 38 | val albumName = "Thriller" 39 | val artistName = "Michael Jackson" 40 | val mbId = "123" 41 | val resultFailure = mockk() 42 | 43 | coEvery { mockAlbumRepository.getAlbumInfo(artistName, albumName, mbId) } returns 44 | resultFailure 45 | 46 | // when 47 | val actual = runBlocking { sut(artistName, albumName, mbId) } 48 | 49 | // then 50 | coVerify { mockAlbumRepository.getAlbumInfo(artistName, albumName, mbId) } 51 | actual shouldBeEqualTo resultFailure 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/PlaceholderImage.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.compose.composable 2 | 3 | import androidx.compose.material3.Surface 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.saveable.rememberSaveable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.compose.ui.res.painterResource 11 | import androidx.compose.ui.tooling.preview.Preview 12 | import coil.compose.AsyncImage 13 | import coil.request.ImageRequest 14 | import com.igorwojda.showcase.feature.base.R 15 | 16 | private val PLACEHOLDER_IMAGES = 17 | listOf( 18 | R.drawable.image_placeholder_1, 19 | R.drawable.image_placeholder_2, 20 | R.drawable.image_placeholder_3, 21 | ) 22 | 23 | @Composable 24 | fun PlaceholderImage( 25 | url: Any?, 26 | contentDescription: String?, 27 | modifier: Modifier = Modifier, 28 | ) { 29 | Surface(modifier = modifier) { 30 | val randomPlaceHolder by rememberSaveable { 31 | mutableStateOf(PLACEHOLDER_IMAGES.random()) 32 | } 33 | 34 | val model = 35 | ImageRequest 36 | .Builder(LocalContext.current) 37 | .data(url) 38 | .crossfade(true) 39 | .build() 40 | 41 | AsyncImage( 42 | model = model, 43 | contentDescription = contentDescription, 44 | placeholder = painterResource(randomPlaceHolder), 45 | ) 46 | } 47 | } 48 | 49 | @Preview 50 | @Composable 51 | private fun PlaceholderImagePreview() { 52 | PlaceholderImage( 53 | url = "https://github.com/igorwojda/android-showcase/raw/main/misc/image/module_dependencies.png?raw=true", 54 | contentDescription = "Sample image", 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumlist 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.viewModelScope 5 | import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumListUseCase 6 | import com.igorwojda.showcase.feature.base.domain.result.Result 7 | import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseViewModel 8 | import kotlinx.coroutines.Job 9 | import kotlinx.coroutines.launch 10 | 11 | internal class AlbumListViewModel( 12 | private val savedStateHandle: SavedStateHandle, 13 | private val getAlbumListUseCase: GetAlbumListUseCase, 14 | ) : BaseViewModel(AlbumListUiState.Loading) { 15 | private var job: Job? = null 16 | 17 | fun onInit(query: String? = (savedStateHandle[SAVED_QUERY_KEY] as? String) ?: DEFAULT_QUERY_NAME) { 18 | getAlbumList(query) 19 | } 20 | 21 | private fun getAlbumList(query: String?) { 22 | if (job != null) { 23 | job?.cancel() 24 | job = null 25 | } 26 | 27 | savedStateHandle[SAVED_QUERY_KEY] = query 28 | 29 | sendAction(AlbumListAction.AlbumListLoadStart) 30 | 31 | job = 32 | viewModelScope.launch { 33 | getAlbumListUseCase(query).also { result -> 34 | val albumListAction = 35 | when (result) { 36 | is Result.Success -> { 37 | AlbumListAction.AlbumListLoadSuccess(result.value) 38 | } 39 | is Result.Failure -> { 40 | AlbumListAction.AlbumListLoadFailure 41 | } 42 | } 43 | 44 | sendAction(albumListAction) 45 | } 46 | } 47 | } 48 | 49 | companion object { 50 | const val DEFAULT_QUERY_NAME = "Jackson" 51 | private const val SAVED_QUERY_KEY = "query" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v5 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@v1 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. 44 | # prompt: 'Update the pull request description to include a summary of changes.' 45 | 46 | # Optional: Add claude_args to customize behavior and configuration 47 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 48 | # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 49 | # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' 50 | 51 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/AlbumMapper.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.AlbumApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageSizeApiModel 5 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel 6 | import com.igorwojda.showcase.feature.album.domain.model.Album 7 | 8 | internal class AlbumMapper( 9 | private val imageMapper: ImageMapper, 10 | private val trackMapper: TrackMapper, 11 | private val tagMapper: TagMapper, 12 | ) { 13 | fun apiToRoom(apiModel: AlbumApiModel) = 14 | AlbumRoomModel( 15 | mbId = apiModel.mbId ?: "", 16 | name = apiModel.name, 17 | artist = apiModel.artist, 18 | images = apiModel.images?.mapNotNull { imageMapper.apiToRoom(it) } ?: listOf(), 19 | tracks = apiModel.tracks?.track?.map { trackMapper.apiToRoom(it) }, 20 | tags = apiModel.tags?.tag?.map { tagMapper.apiToRoom(it) }, 21 | ) 22 | 23 | fun apiToDomain(apiModel: AlbumApiModel): Album { 24 | val images = 25 | apiModel.images 26 | ?.filterNot { it.size == ImageSizeApiModel.UNKNOWN || it.url.isBlank() } 27 | ?.map { imageMapper.apiToDomain(it) } 28 | 29 | return Album( 30 | mbId = apiModel.mbId, 31 | name = apiModel.name, 32 | artist = apiModel.artist, 33 | images = images ?: listOf(), 34 | tracks = apiModel.tracks?.track?.map { trackMapper.apiToDomain(it) }, 35 | tags = apiModel.tags?.tag?.map { tagMapper.apiToDomain(it) }, 36 | ) 37 | } 38 | 39 | fun roomToDomain(roomModel: AlbumRoomModel) = 40 | Album( 41 | roomModel.name, 42 | roomModel.artist, 43 | roomModel.mbId, 44 | roomModel.images.mapNotNull { imageMapper.roomToDomain(it) }, 45 | roomModel.tracks?.map { trackMapper.roomToDomain(it) }, 46 | roomModel.tags?.map { tagMapper.roomToDomain(it) }, 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v5 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@v1 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | prompt: | 40 | Please review this pull request and provide feedback on: 41 | - Code quality and best practices 42 | - Potential bugs or issues 43 | - Performance considerations 44 | - Security concerns 45 | - Test coverage 46 | 47 | Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. 48 | 49 | Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. 50 | 51 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 52 | # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 53 | claude_args: '--allowed-tools "Bash(./gradlew *),Bash(./gradlew konsist-test:*),Bash(./gradlew library:test-utils:*),Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' 54 | 55 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/AlbumRoomModel.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.database.model 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import androidx.room.TypeConverter 6 | import androidx.room.TypeConverters 7 | import com.igorwojda.showcase.feature.album.domain.model.Album 8 | import kotlinx.serialization.json.Json 9 | 10 | @Entity(tableName = "albums") 11 | @TypeConverters( 12 | AlbumImageRoomTypeConverter::class, 13 | AlbumTrackRoomTypeConverter::class, 14 | AlbumTagRoomTypeConverter::class, 15 | ) 16 | internal data class AlbumRoomModel( 17 | @PrimaryKey(autoGenerate = true) val id: Int = 0, 18 | val mbId: String, 19 | val name: String, 20 | val artist: String, 21 | val images: List = listOf(), 22 | val tracks: List?, 23 | val tags: List?, 24 | ) 25 | 26 | internal fun AlbumRoomModel.toDomainModel() = 27 | Album( 28 | this.name, 29 | this.artist, 30 | this.mbId, 31 | this.images.mapNotNull { it.toDomainModel() }, 32 | this.tracks?.map { it.toDomainModel() }, 33 | this.tags?.map { it.toDomainModel() }, 34 | ) 35 | 36 | internal class AlbumImageRoomTypeConverter { 37 | @TypeConverter 38 | fun stringToList(data: String?) = data?.let { Json.decodeFromString>(it) } ?: listOf() 39 | 40 | @TypeConverter 41 | fun listToString(someObjects: List): String = Json.encodeToString(someObjects) 42 | } 43 | 44 | internal class AlbumTrackRoomTypeConverter { 45 | @TypeConverter 46 | fun stringToList(data: String?) = data?.let { Json.decodeFromString>(it) } ?: listOf() 47 | 48 | @TypeConverter 49 | fun listToString(someObjects: List): String = Json.encodeToString(someObjects) 50 | } 51 | 52 | internal class AlbumTagRoomTypeConverter { 53 | @TypeConverter 54 | fun stringToList(data: String?) = data?.let { Json.decodeFromString>(it) } ?: listOf() 55 | 56 | @TypeConverter 57 | fun listToString(someObjects: List): String = Json.encodeToString(someObjects) 58 | } 59 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageMapperTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.mapper 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageSizeApiModel 5 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageRoomModel 6 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageSizeRoomModel 7 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 8 | import com.igorwojda.showcase.feature.album.domain.model.Image 9 | import io.mockk.every 10 | import io.mockk.mockk 11 | import org.amshove.kluent.shouldBeEqualTo 12 | import org.junit.jupiter.api.Test 13 | 14 | class ImageMapperTest { 15 | private val imageSizeMapper: ImageSizeMapper = mockk() 16 | private val sut = ImageMapper(imageSizeMapper) 17 | 18 | @Test 19 | fun `apiToDomain maps image correctly`() { 20 | // given 21 | val apiModel = ImageApiModel("https://example.com/image.jpg", ImageSizeApiModel.LARGE) 22 | every { imageSizeMapper.apiToDomain(ImageSizeApiModel.LARGE) } returns ImageSize.LARGE 23 | 24 | // when 25 | val result = sut.apiToDomain(apiModel) 26 | 27 | // then 28 | result shouldBeEqualTo Image("https://example.com/image.jpg", ImageSize.LARGE) 29 | } 30 | 31 | @Test 32 | fun `apiToRoom maps image correctly`() { 33 | // given 34 | val apiModel = ImageApiModel("https://example.com/image.jpg", ImageSizeApiModel.LARGE) 35 | every { imageSizeMapper.apiToRoom(ImageSizeApiModel.LARGE) } returns ImageSizeRoomModel.LARGE 36 | 37 | // when 38 | val result = sut.apiToRoom(apiModel) 39 | 40 | // then 41 | result shouldBeEqualTo ImageRoomModel("https://example.com/image.jpg", ImageSizeRoomModel.LARGE) 42 | } 43 | 44 | @Test 45 | fun `roomToDomain maps image correctly`() { 46 | // given 47 | val roomModel = ImageRoomModel("https://example.com/image.jpg", ImageSizeRoomModel.LARGE) 48 | every { imageSizeMapper.roomToDomain(ImageSizeRoomModel.LARGE) } returns ImageSize.LARGE 49 | 50 | // when 51 | val result = sut.roomToDomain(roomModel) 52 | 53 | // then 54 | result shouldBeEqualTo Image("https://example.com/image.jpg", ImageSize.LARGE) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/Lottie.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.compose.composable 2 | 3 | import androidx.annotation.RawRes 4 | import androidx.annotation.StringRes 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.requiredSize 8 | import androidx.compose.foundation.layout.wrapContentSize 9 | import androidx.compose.material3.Card 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import com.airbnb.lottie.compose.LottieAnimation 17 | import com.airbnb.lottie.compose.LottieCompositionSpec 18 | import com.airbnb.lottie.compose.rememberLottieComposition 19 | import com.igorwojda.showcase.feature.base.common.res.Dimen 20 | 21 | @Composable 22 | fun LabeledAnimation( 23 | @StringRes label: Int, 24 | @RawRes assetResId: Int, 25 | modifier: Modifier = Modifier, 26 | ) { 27 | Card( 28 | modifier = modifier.wrapContentSize(), 29 | ) { 30 | Column( 31 | horizontalAlignment = Alignment.CenterHorizontally, 32 | modifier = 33 | Modifier 34 | .wrapContentSize() 35 | .padding(Dimen.spaceXL), 36 | ) { 37 | TextTitleMedium(text = stringResource(label)) 38 | LottieAssetLoader(assetResId) 39 | } 40 | } 41 | } 42 | 43 | @Composable 44 | fun LottieAssetLoader( 45 | @RawRes assetResId: Int, 46 | modifier: Modifier = Modifier, 47 | ) { 48 | val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(assetResId)) 49 | 50 | LottieAnimation( 51 | composition, 52 | modifier = modifier.requiredSize(Dimen.imageSize), 53 | ) 54 | } 55 | 56 | @Preview 57 | @Composable 58 | private fun LabeledAnimationPreview() { 59 | LabeledAnimation( 60 | label = android.R.string.ok, 61 | assetResId = com.igorwojda.showcase.feature.base.R.raw.lottie_building_screen, 62 | ) 63 | } 64 | 65 | @Preview 66 | @Composable 67 | private fun LottieAssetLoaderPreview() { 68 | LottieAssetLoader( 69 | assetResId = com.igorwojda.showcase.feature.base.R.raw.lottie_building_screen, 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/data/retrofit/ApiResultCall.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.data.retrofit 2 | 3 | import okhttp3.Request 4 | import okio.Timeout 5 | import retrofit2.Call 6 | import retrofit2.Callback 7 | import retrofit2.Response 8 | 9 | internal class ApiResultCall constructor( 10 | private val callDelegate: Call, 11 | ) : Call> { 12 | @Suppress("detekt.MagicNumber") 13 | override fun enqueue(callback: Callback>) = 14 | callDelegate.enqueue( 15 | object : Callback { 16 | override fun onResponse( 17 | call: Call, 18 | response: Response, 19 | ) { 20 | response.body()?.let { 21 | when (response.code()) { 22 | in 200..208 -> { 23 | callback.onResponse(this@ApiResultCall, Response.success(ApiResult.Success(it))) 24 | } 25 | in 400..409 -> { 26 | callback.onResponse( 27 | this@ApiResultCall, 28 | Response.success(ApiResult.Error(response.code(), response.message())), 29 | ) 30 | } 31 | } 32 | } ?: callback.onResponse(this@ApiResultCall, Response.success(ApiResult.Error(123, "message"))) 33 | } 34 | 35 | override fun onFailure( 36 | call: Call, 37 | throwable: Throwable, 38 | ) { 39 | callback.onResponse(this@ApiResultCall, Response.success(ApiResult.Exception(throwable))) 40 | call.cancel() 41 | } 42 | }, 43 | ) 44 | 45 | override fun clone(): Call> = ApiResultCall(callDelegate.clone()) 46 | 47 | override fun execute(): Response> = throw UnsupportedOperationException("ResponseCall does not support execute.") 48 | 49 | override fun isExecuted(): Boolean = callDelegate.isExecuted 50 | 51 | override fun cancel() = callDelegate.cancel() 52 | 53 | override fun isCanceled(): Boolean = callDelegate.isCanceled 54 | 55 | override fun request(): Request = callDelegate.request() 56 | 57 | override fun timeout(): Timeout = callDelegate.timeout() 58 | } 59 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumlist 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import com.igorwojda.showcase.feature.album.domain.model.Album 5 | import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumListUseCase 6 | import com.igorwojda.showcase.feature.base.domain.result.Result 7 | import com.igorwojda.showcase.library.testutils.CoroutinesTestDispatcherExtension 8 | import com.igorwojda.showcase.library.testutils.InstantTaskExecutorExtension 9 | import io.mockk.coEvery 10 | import io.mockk.mockk 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.test.advanceUntilIdle 13 | import kotlinx.coroutines.test.runTest 14 | import org.amshove.kluent.shouldBeEqualTo 15 | import org.junit.jupiter.api.Test 16 | import org.junit.jupiter.api.extension.ExtendWith 17 | 18 | @OptIn(ExperimentalCoroutinesApi::class) 19 | @ExtendWith(InstantTaskExecutorExtension::class, CoroutinesTestDispatcherExtension::class) 20 | class AlbumListViewModelTest { 21 | private val mockGetAlbumListUseCase: GetAlbumListUseCase = mockk() 22 | 23 | private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) 24 | 25 | private val sut = 26 | AlbumListViewModel( 27 | savedStateHandle, 28 | mockGetAlbumListUseCase, 29 | ) 30 | 31 | @Test 32 | fun `onInit emits state error`() = 33 | runTest { 34 | // given 35 | coEvery { mockGetAlbumListUseCase.invoke("Jackson") } returns Result.Failure() 36 | 37 | // when 38 | sut.onInit("Jackson") 39 | 40 | // then 41 | advanceUntilIdle() 42 | 43 | sut.uiStateFlow.value shouldBeEqualTo AlbumListUiState.Error 44 | } 45 | 46 | @Test 47 | fun `onInit emits state success`() = 48 | runTest { 49 | // given 50 | val album = Album("albumName", "artistName") 51 | val albums = listOf(album) 52 | coEvery { mockGetAlbumListUseCase.invoke("Jackson") } returns Result.Success(albums) 53 | 54 | // when 55 | sut.onInit("Jackson") 56 | 57 | // then 58 | advanceUntilIdle() 59 | 60 | sut.uiStateFlow.value shouldBeEqualTo 61 | AlbumListUiState.Content( 62 | albums = albums, 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumListUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.domain.usecase 2 | 3 | import com.igorwojda.showcase.feature.album.data.repository.AlbumRepositoryImpl 4 | import com.igorwojda.showcase.feature.album.domain.DomainFixtures 5 | import com.igorwojda.showcase.feature.base.domain.result.Result 6 | import io.mockk.coEvery 7 | import io.mockk.coVerify 8 | import io.mockk.mockk 9 | import kotlinx.coroutines.runBlocking 10 | import org.amshove.kluent.shouldBeEqualTo 11 | import org.junit.jupiter.api.Test 12 | 13 | class GetAlbumListUseCaseTest { 14 | private val mockAlbumRepository: AlbumRepositoryImpl = mockk() 15 | 16 | private val sut = GetAlbumListUseCase(mockAlbumRepository) 17 | 18 | @Test 19 | fun `return list of albums`() { 20 | // given 21 | val albums = listOf(DomainFixtures.getAlbum(), DomainFixtures.getAlbum()) 22 | coEvery { mockAlbumRepository.searchAlbum(any()) } returns Result.Success(albums) 23 | 24 | // when 25 | val actual = runBlocking { sut(null) } 26 | 27 | // then 28 | actual shouldBeEqualTo Result.Success(albums) 29 | } 30 | 31 | @Test 32 | fun `WHEN onInit is called with no value then the default query search term is null`() = 33 | runBlocking { 34 | // given 35 | val albums = listOf(DomainFixtures.getAlbum(), DomainFixtures.getAlbum()) 36 | coEvery { mockAlbumRepository.searchAlbum(any()) } returns Result.Success(albums) 37 | 38 | sut(null) 39 | 40 | coVerify { mockAlbumRepository.searchAlbum(null) } 41 | } 42 | 43 | @Test 44 | fun `filter albums with default image`() { 45 | // given 46 | val albumWithImage = DomainFixtures.getAlbum() 47 | val albumWithoutImage = DomainFixtures.getAlbum(images = listOf()) 48 | val albums = listOf(albumWithImage, albumWithoutImage) 49 | coEvery { mockAlbumRepository.searchAlbum(any()) } returns Result.Success(albums) 50 | 51 | // when 52 | val actual = runBlocking { sut(null) } 53 | 54 | // then 55 | actual shouldBeEqualTo Result.Success(listOf(albumWithImage)) 56 | } 57 | 58 | @Test 59 | fun `return error when repository throws an exception`() { 60 | // given 61 | val resultFailure = mockk() 62 | coEvery { mockAlbumRepository.searchAlbum(any()) } returns resultFailure 63 | 64 | // when 65 | val actual = runBlocking { sut(null) } 66 | 67 | // then 68 | actual shouldBeEqualTo resultFailure 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/UseCaseKonsistTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.konsisttest 2 | 3 | import com.lemonappdev.konsist.api.Konsist 4 | import com.lemonappdev.konsist.api.ext.list.withNameEndingWith 5 | import com.lemonappdev.konsist.api.verify.assertTrue 6 | import java.util.Locale 7 | import org.junit.jupiter.api.Test 8 | 9 | // Check test coding rules. 10 | class UseCaseKonsistTest { 11 | @Test 12 | fun `every use case class has test`() { 13 | Konsist 14 | .scopeFromProduction() 15 | .classes() 16 | .withNameEndingWith("UseCase") 17 | .assertTrue { it.hasTestClasses(testPropertyName = "sut") } 18 | } 19 | 20 | @Test 21 | fun `every use case constructor has alphabetically ordered parameters`() { 22 | Konsist 23 | .scopeFromProject() 24 | .classes() 25 | .withNameEndingWith("UseCase") 26 | .flatMap { it.constructors } 27 | .assertTrue { 28 | val names = it.parameters.map { parameter -> parameter.name } 29 | val sortedNames = names.sorted() 30 | names == sortedNames 31 | } 32 | } 33 | 34 | @Test 35 | fun `classes with 'UseCase' suffix should reside in 'domain' and 'usecase' packages`() { 36 | Konsist 37 | .scopeFromProject() 38 | .classes() 39 | .withNameEndingWith("UseCase") 40 | .assertTrue { it.resideInPackage("..domain..usecase..") } 41 | } 42 | 43 | @Test 44 | fun `classes with 'UseCase' suffix should have single public operator method named 'invoke'`() { 45 | Konsist 46 | .scopeFromProject() 47 | .classes() 48 | .withNameEndingWith("UseCase") 49 | .assertTrue { 50 | val hasSingleInvokeOperatorMethod = 51 | it.hasFunction { function -> 52 | function.name == "invoke" && function.hasPublicOrDefaultModifier && function.hasOperatorModifier 53 | } 54 | 55 | hasSingleInvokeOperatorMethod && it.numPublicOrDefaultDeclarations() == 1 56 | } 57 | } 58 | 59 | @Test 60 | fun `every use case constructor parameter has name derived from parameter type`() { 61 | Konsist 62 | .scopeFromProject() 63 | .classes() 64 | .withNameEndingWith("UseCase") 65 | .flatMap { it.constructors } 66 | .flatMap { it.parameters } 67 | .assertTrue { 68 | val nameTitleCase = it.name.replaceFirstChar { char -> char.titlecase(Locale.getDefault()) } 69 | nameTitleCase == it.type.sourceType 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/presentation/util/NavigationDestinationLogger.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app.presentation.util 2 | 3 | import android.os.Bundle 4 | import androidx.navigation.NavDestination 5 | import com.igorwojda.showcase.app.presentation.NavigationRoute 6 | import com.igorwojda.showcase.feature.base.util.TimberLogTags 7 | import timber.log.Timber 8 | 9 | object NavigationDestinationLogger { 10 | fun logDestinationChange( 11 | destination: NavDestination, 12 | arguments: Bundle?, 13 | ) { 14 | val className = NavigationRoute::class.simpleName 15 | val destinationRoute = destination.route?.substringAfter("$className.") ?: "Unknown" 16 | val destinationId = destination.id 17 | val destinationLabel = destination.label ?: "No Label" 18 | 19 | val logMessage = 20 | buildString { 21 | appendLine("Navigation destination changed:") 22 | appendLine("\tRoute: $destinationRoute") 23 | appendLine("\tID: $destinationId") 24 | appendLine("\tLabel: $destinationLabel") 25 | 26 | arguments?.let { bundle -> 27 | if (!bundle.isEmpty) { 28 | appendLine(" Arguments:") 29 | bundle.keySet().forEach { key -> 30 | val value = getValueFromBundle(bundle, key) ?: "null" 31 | appendLine("\t\t$key: $value") 32 | } 33 | } 34 | } 35 | } 36 | 37 | Timber.tag(TimberLogTags.NAVIGATION).d(logMessage) 38 | } 39 | 40 | /** 41 | * Retrieves a value from Bundle using Android Navigation supported types. 42 | * Navigation supports: String, Int, Long, Float, Boolean, Parcelable, Serializable, and their arrays. 43 | * 44 | * @return String representation of the value, or null if no matching type found 45 | */ 46 | private fun getValueFromBundle( 47 | bundle: Bundle, 48 | key: String, 49 | ): String? = 50 | bundle.getString(key)?.let { "\"$it\"" } 51 | ?: runCatching { bundle.getInt(key) }.getOrNull()?.toString() 52 | ?: runCatching { bundle.getLong(key) }.getOrNull()?.toString() 53 | ?: runCatching { bundle.getFloat(key) }.getOrNull()?.toString() 54 | ?: runCatching { bundle.getBoolean(key) }.getOrNull()?.toString() 55 | ?: bundle.getStringArray(key)?.contentToString() 56 | ?: bundle.getIntArray(key)?.contentToString() 57 | ?: bundle.getLongArray(key)?.contentToString() 58 | ?: bundle.getFloatArray(key)?.contentToString() 59 | ?: bundle.getBooleanArray(key)?.contentToString() 60 | } 61 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail 2 | 3 | import com.igorwojda.showcase.feature.album.domain.model.Album 4 | import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumUseCase 5 | import com.igorwojda.showcase.feature.base.domain.result.Result 6 | import com.igorwojda.showcase.library.testutils.CoroutinesTestDispatcherExtension 7 | import com.igorwojda.showcase.library.testutils.InstantTaskExecutorExtension 8 | import io.mockk.coEvery 9 | import io.mockk.mockk 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.test.advanceUntilIdle 12 | import kotlinx.coroutines.test.runTest 13 | import org.amshove.kluent.shouldBeEqualTo 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.extension.ExtendWith 16 | 17 | @OptIn(ExperimentalCoroutinesApi::class) 18 | @ExtendWith(InstantTaskExecutorExtension::class, CoroutinesTestDispatcherExtension::class) 19 | class AlbumDetailViewModelTest { 20 | private val mockGetAlbumUseCase: GetAlbumUseCase = mockk() 21 | 22 | private val sut = 23 | AlbumDetailViewModel( 24 | mockGetAlbumUseCase, 25 | ) 26 | 27 | @Test 28 | fun `onInit album is not found`() = 29 | runTest { 30 | // given 31 | val albumName = "Thriller" 32 | val artistName = "Michael Jackson" 33 | val mbId = "123" 34 | 35 | coEvery { 36 | mockGetAlbumUseCase.invoke(artistName, albumName, mbId) 37 | } returns Result.Failure() 38 | 39 | // when 40 | sut.onInit(albumName, artistName, mbId) 41 | 42 | // then 43 | advanceUntilIdle() 44 | sut.uiStateFlow.value shouldBeEqualTo AlbumDetailUiState.Error 45 | } 46 | 47 | @Test 48 | fun `onInit album is found`() = 49 | runTest { 50 | // given 51 | val albumName = "Thriller" 52 | val artistName = "Michael Jackson" 53 | val mbId = "123" 54 | val album = Album(albumName, artistName, mbId) 55 | 56 | coEvery { 57 | mockGetAlbumUseCase.invoke(artistName, albumName, mbId) 58 | } returns Result.Success(album) 59 | 60 | // when 61 | sut.onInit(albumName, artistName, mbId) 62 | 63 | // then 64 | advanceUntilIdle() 65 | sut.uiStateFlow.value shouldBeEqualTo 66 | AlbumDetailUiState.Content( 67 | albumName = albumName, 68 | artistName = artistName, 69 | coverImageUrl = "", 70 | tracks = null, 71 | tags = null, 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumApiModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.datasource.api.model 2 | 3 | import com.igorwojda.showcase.feature.album.data.DataFixtures 4 | import com.igorwojda.showcase.feature.album.domain.enum.ImageSize 5 | import com.igorwojda.showcase.feature.album.domain.model.Album 6 | import com.igorwojda.showcase.feature.album.domain.model.Tag 7 | import com.igorwojda.showcase.feature.album.domain.model.Track 8 | import org.amshove.kluent.shouldBeEqualTo 9 | import org.junit.jupiter.api.Test 10 | 11 | class AlbumApiModelTest { 12 | @Test 13 | fun `data model with full data maps to AlbumDomainModel`() { 14 | // given 15 | val sut = DataFixtures.getAlbumApiModel() 16 | 17 | // when 18 | val domainModel = sut.toDomainModel() 19 | 20 | // then 21 | domainModel shouldBeEqualTo 22 | Album( 23 | sut.name, 24 | sut.artist, 25 | sut.mbId, 26 | sut.images?.map { it.toDomainModel() } ?: listOf(), 27 | sut.tracks?.track?.map { it.toDomainModel() }, 28 | sut.tags?.tag?.map { it.toDomainModel() }, 29 | ) 30 | } 31 | 32 | @Test 33 | fun `data model with missing data maps to AlbumDomainModel`() { 34 | // given 35 | val sut = 36 | DataFixtures.getAlbumApiModel( 37 | images = emptyList(), 38 | ) 39 | 40 | // when 41 | val domainModel = sut.toDomainModel() 42 | 43 | // then 44 | domainModel shouldBeEqualTo 45 | Album( 46 | mbId = "mbId", 47 | name = "album", 48 | artist = "artist", 49 | images = emptyList(), 50 | tracks = listOf(Track("track", 12)), 51 | tags = listOf(Tag("tag")), 52 | ) 53 | } 54 | 55 | @Test 56 | fun `mapping filters out unknown size`() { 57 | // given 58 | val albumDataImages = 59 | listOf(ImageSizeApiModel.EXTRA_LARGE, ImageSizeApiModel.UNKNOWN) 60 | .map { DataFixtures.getImageModelApiModel(size = it) } 61 | val sut = DataFixtures.getAlbumApiModel(images = albumDataImages) 62 | 63 | // when 64 | val domainModel = sut.toDomainModel() 65 | 66 | // then 67 | domainModel.images.single { it.size == ImageSize.EXTRA_LARGE } 68 | } 69 | 70 | @Test 71 | fun `mapping filters out blank url`() { 72 | // given 73 | val images = 74 | listOf("", "url") 75 | .map { DataFixtures.getImageModelApiModel(url = it) } 76 | 77 | val sut = DataFixtures.getAlbumApiModel(images = images) 78 | 79 | // when 80 | val domainModel = sut.toDomainModel() 81 | 82 | // then 83 | domainModel.images.single { it.url == "url" } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesScreen.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 7 | import androidx.compose.material3.ExperimentalMaterial3Api 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.IconButton 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.material3.Text 12 | import androidx.compose.material3.TopAppBar 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 19 | import com.igorwojda.showcase.feature.settings.R 20 | import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer 21 | import org.koin.androidx.compose.koinViewModel 22 | 23 | @OptIn(ExperimentalMaterial3Api::class) 24 | @Composable 25 | fun AboutLibrariesScreen( 26 | onBackClick: () -> Unit, 27 | modifier: Modifier = Modifier, 28 | ) { 29 | val viewModel: AboutLibrariesViewModel = koinViewModel() 30 | val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle() 31 | 32 | when (uiState) { 33 | is AboutLibrariesUiState.Content -> { 34 | AboutLibrariesContent( 35 | onBackClick = onBackClick, 36 | modifier = modifier, 37 | ) 38 | } 39 | } 40 | } 41 | 42 | @OptIn(ExperimentalMaterial3Api::class) 43 | @Composable 44 | private fun AboutLibrariesContent( 45 | onBackClick: () -> Unit, 46 | modifier: Modifier = Modifier, 47 | ) { 48 | Scaffold( 49 | modifier = modifier, 50 | topBar = { 51 | TopAppBar( 52 | title = { Text(stringResource(R.string.about_libraries_screen_title)) }, 53 | navigationIcon = { 54 | IconButton(onClick = onBackClick) { 55 | Icon( 56 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 57 | contentDescription = stringResource(R.string.about_libraries_screen_back), 58 | ) 59 | } 60 | }, 61 | ) 62 | }, 63 | ) { paddingValues -> 64 | LibrariesContainer( 65 | modifier = 66 | Modifier 67 | .fillMaxSize() 68 | .padding(paddingValues), 69 | ) 70 | } 71 | } 72 | 73 | @Preview 74 | @Composable 75 | private fun AboutLibrariesScreenPreview() { 76 | AboutLibrariesContent( 77 | onBackClick = { }, 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/repository/AlbumRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data.repository 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.service.AlbumRetrofitService 4 | import com.igorwojda.showcase.feature.album.data.datasource.database.AlbumDao 5 | import com.igorwojda.showcase.feature.album.data.mapper.AlbumMapper 6 | import com.igorwojda.showcase.feature.album.domain.model.Album 7 | import com.igorwojda.showcase.feature.album.domain.repository.AlbumRepository 8 | import com.igorwojda.showcase.feature.base.data.retrofit.ApiResult 9 | import com.igorwojda.showcase.feature.base.domain.result.Result 10 | import timber.log.Timber 11 | 12 | internal class AlbumRepositoryImpl( 13 | private val albumRetrofitService: AlbumRetrofitService, 14 | private val albumDao: AlbumDao, 15 | private val albumMapper: AlbumMapper, 16 | ) : AlbumRepository { 17 | override suspend fun searchAlbum(phrase: String?): Result> = 18 | when (val apiResult = albumRetrofitService.searchAlbumAsync(phrase)) { 19 | is ApiResult.Success -> { 20 | val albums = 21 | apiResult 22 | .data 23 | .results 24 | .albumMatches 25 | .album 26 | .also { albumsApiModels -> 27 | val albumsRoomModels = albumsApiModels.map { albumMapper.apiToRoom(it) } 28 | albumDao.insertAlbums(albumsRoomModels) 29 | }.map { albumMapper.apiToDomain(it) } 30 | 31 | Result.Success(albums) 32 | } 33 | is ApiResult.Error -> { 34 | Result.Failure() 35 | } 36 | is ApiResult.Exception -> { 37 | Timber.e(apiResult.throwable) 38 | 39 | val albums = 40 | albumDao 41 | .getAll() 42 | .map { albumMapper.roomToDomain(it) } 43 | 44 | Result.Success(albums) 45 | } 46 | } 47 | 48 | override suspend fun getAlbumInfo( 49 | artistName: String, 50 | albumName: String, 51 | mbId: String?, 52 | ): Result = 53 | when (val apiResult = albumRetrofitService.getAlbumInfoAsync(artistName, albumName, mbId)) { 54 | is ApiResult.Success -> { 55 | val album = 56 | apiResult 57 | .data 58 | .album 59 | .let { albumMapper.apiToDomain(it) } 60 | 61 | Result.Success(album) 62 | } 63 | is ApiResult.Error -> { 64 | Result.Failure() 65 | } 66 | is ApiResult.Exception -> { 67 | Timber.e(apiResult.throwable) 68 | 69 | val album = 70 | albumDao 71 | .getAlbum(artistName, albumName, mbId) 72 | .let { albumMapper.roomToDomain(it) } 73 | 74 | Result.Success(album) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at {{ email }}. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/viewmodel/StateTimeTravelDebugger.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.base.presentation.viewmodel 2 | 3 | import com.igorwojda.showcase.feature.base.util.TimberLogTags 4 | import kotlin.reflect.full.memberProperties 5 | import timber.log.Timber 6 | 7 | /** 8 | * Logs actions and view state transitions to facilitate debugging. 9 | */ 10 | class StateTimeTravelDebugger( 11 | private val viewClassName: String, 12 | ) { 13 | private val stateTimeline = mutableListOf() 14 | private var lastViewAction: BaseAction<*>? = null 15 | 16 | // Get list of properties from ViewState instances (all have the same type) 17 | private val propertyNames by lazy { 18 | stateTimeline 19 | .first() 20 | .oldState.javaClass.kotlin.memberProperties 21 | .map { it.name } 22 | } 23 | 24 | fun addAction(viewAction: BaseAction<*>) { 25 | lastViewAction = viewAction 26 | } 27 | 28 | fun addStateTransition( 29 | oldState: BaseState, 30 | newState: BaseState, 31 | ) { 32 | val lastViewAction = checkNotNull(lastViewAction) { "lastViewAction is null. Please log action before logging state transition" } 33 | stateTimeline.add(StateTransition(oldState, lastViewAction, newState)) 34 | this.lastViewAction = null 35 | } 36 | 37 | private fun getMessage() = getMessage(stateTimeline) 38 | 39 | private fun getMessage(stateTimeline: List): String { 40 | if (stateTimeline.isEmpty()) return "$viewClassName has no state transitions\n" 41 | 42 | return stateTimeline.joinToString(separator = "\n", postfix = "\n") { st -> 43 | buildString { 44 | append("Action: $viewClassName.${st.action.javaClass.simpleName}") 45 | 46 | if (propertyNames.isNotEmpty()) { 47 | append('\n') 48 | 49 | append( 50 | propertyNames.joinToString(separator = "") { prop -> 51 | getLogLine(st.oldState, st.newState, prop) 52 | }, 53 | ) 54 | } 55 | } 56 | } 57 | } 58 | 59 | fun logAll() { 60 | Timber.d(getMessage()) 61 | } 62 | 63 | fun logLast() { 64 | val states = listOf(stateTimeline.last()) 65 | Timber.tag(TimberLogTags.ACTION).d(getMessage(states)) 66 | } 67 | 68 | private fun getLogLine( 69 | oldState: BaseState, 70 | newState: BaseState, 71 | propertyName: String, 72 | ): String { 73 | val oldValue = getPropertyValue(oldState, propertyName) 74 | val newValue = getPropertyValue(newState, propertyName) 75 | val indent = "\t" 76 | 77 | return if (oldValue != newValue) { 78 | "$indent*$propertyName: $oldValue -> $newValue\n" 79 | } else { 80 | "$indent$propertyName: $newValue\n" 81 | } 82 | } 83 | 84 | private fun getPropertyValue( 85 | baseState: BaseState, 86 | propertyName: String, 87 | ): String { 88 | baseState::class.memberProperties.forEach { 89 | if (propertyName == it.name) { 90 | var value = it.getter.call(baseState).toString() 91 | 92 | if (value.isBlank()) { 93 | value = "\"\"" 94 | } 95 | 96 | return value 97 | } 98 | } 99 | 100 | return "" 101 | } 102 | 103 | private data class StateTransition( 104 | val oldState: BaseState, 105 | val action: BaseAction<*>, 106 | val newState: BaseState, 107 | ) 108 | } 109 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/AppKoinModule.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app 2 | 3 | import com.igorwojda.showcase.app.data.api.AuthenticationInterceptor 4 | import com.igorwojda.showcase.app.data.api.UserAgentInterceptor 5 | import com.igorwojda.showcase.feature.base.data.retrofit.ApiResultAdapterFactory 6 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 7 | import kotlinx.serialization.ExperimentalSerializationApi 8 | import okhttp3.MediaType.Companion.toMediaType 9 | import okhttp3.OkHttpClient 10 | import okhttp3.logging.HttpLoggingInterceptor 11 | import org.koin.core.module.dsl.singleOf 12 | import org.koin.dsl.module 13 | import retrofit2.Retrofit 14 | import timber.log.Timber 15 | 16 | val appModule = 17 | module { 18 | 19 | single { AuthenticationInterceptor(BuildConfig.GRADLE_API_TOKEN) } 20 | 21 | singleOf(::UserAgentInterceptor) 22 | 23 | single { 24 | HttpLoggingInterceptor { message -> 25 | Timber.d("Http: $message") 26 | }.apply { 27 | level = HttpLoggingInterceptor.Level.BODY 28 | } 29 | } 30 | 31 | /* 32 | * OkHttp logging interceptor with custom Timber logger. 33 | * 34 | * By default, HttpLoggingInterceptor uses the calling class name as the log tag which clutters Logcat and makes filtering harder. 35 | * 36 | * This custom configuration ensures: 37 | * - All HTTP logs are tagged consistently as `"Network"`. 38 | * - Logs are printed through Timber (instead of Android's `Log`). 39 | * - Logging level is set to BODY to include headers and payloads. 40 | */ 41 | single { 42 | HttpLoggingInterceptor { message -> 43 | Timber.tag("Network").d(message) 44 | }.apply { 45 | /* 46 | Use BODY logging only in debug builds. 47 | Even if Timber.DebugTree() is planted only in debug, the interceptor still 48 | reads/constructs request/response bodies when level = BODY. 49 | This adds unnecessary overhead and may leak sensitive data if any logger 50 | is active in production. Setting NONE in release avoids both risks. 51 | */ 52 | level = 53 | if (BuildConfig.DEBUG) { 54 | HttpLoggingInterceptor.Level.BODY 55 | } else { 56 | HttpLoggingInterceptor.Level.NONE 57 | } 58 | } 59 | } 60 | 61 | single { 62 | OkHttpClient 63 | .Builder() 64 | .apply { 65 | if (BuildConfig.DEBUG) { 66 | addInterceptor(get()) 67 | } 68 | addInterceptor(get()) 69 | addInterceptor(get()) 70 | }.build() 71 | } 72 | 73 | single { 74 | val contentType = "application/json".toMediaType() 75 | 76 | val json = 77 | kotlinx.serialization.json.Json { 78 | // By default Kotlin serialization will serialize all of the keys present in JSON object and throw an 79 | // exception if given key is not present in the Kotlin class. This flag allows to ignore JSON fields 80 | ignoreUnknownKeys = true 81 | } 82 | 83 | @OptIn(ExperimentalSerializationApi::class) 84 | Retrofit 85 | .Builder() 86 | .baseUrl(BuildConfig.GRADLE_API_BASE_URL) 87 | .client(get()) 88 | .addConverterFactory(json.asConverterFactory(contentType)) 89 | .addCallAdapterFactory(ApiResultAdapterFactory()) 90 | .build() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/DataFixtures.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.data 2 | 3 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.AlbumApiModel 4 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.AlbumListApiModel 5 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageApiModel 6 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageSizeApiModel 7 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.SearchAlbumResultsApiModel 8 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.TagApiModel 9 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.TagListApiModel 10 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.TrackApiModel 11 | import com.igorwojda.showcase.feature.album.data.datasource.api.model.TrackListApiModel 12 | import com.igorwojda.showcase.feature.album.data.datasource.api.response.SearchAlbumResponse 13 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel 14 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageRoomModel 15 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageSizeRoomModel 16 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.TagRoomModel 17 | import com.igorwojda.showcase.feature.album.data.datasource.database.model.TrackRoomModel 18 | 19 | object DataFixtures { 20 | internal fun getAlbumsApiModel() = 21 | listOf( 22 | getAlbumApiModel("mbid1", "album1", "artist1"), 23 | ) 24 | 25 | internal fun getAlbumsRoomModels() = 26 | listOf( 27 | getAlbumRoomModel(1, "mbid1", "album1", "artist1"), 28 | getAlbumRoomModel(2, "mbid2", "album2", "artist2"), 29 | ) 30 | 31 | internal fun getAlbumApiModel( 32 | mbId: String = "mbId", 33 | name: String = "album", 34 | artist: String = "artist", 35 | images: List? = listOf(getImageModelApiModel()), 36 | tracks: TrackListApiModel = TrackListApiModel(getTrackModelApiModel()), 37 | tags: TagListApiModel = TagListApiModel(getTagModelApiModel()), 38 | ): AlbumApiModel = 39 | AlbumApiModel( 40 | mbId, 41 | name, 42 | artist, 43 | images, 44 | tracks, 45 | tags, 46 | ) 47 | 48 | internal fun getImageModelApiModel( 49 | url: String = "url_${ImageSizeApiModel.EXTRA_LARGE}", 50 | size: ImageSizeApiModel = ImageSizeApiModel.EXTRA_LARGE, 51 | ) = ImageApiModel(url, size) 52 | 53 | private fun getTrackModelApiModel( 54 | name: String = "track", 55 | duration: Int? = 12, 56 | ) = listOf(TrackApiModel(name, duration)) 57 | 58 | private fun getTagModelApiModel(name: String = "tag") = listOf(TagApiModel(name)) 59 | 60 | private fun getAlbumRoomModel( 61 | id: Int = 0, 62 | mbId: String = "mbId", 63 | name: String = "album", 64 | artist: String = "artist", 65 | images: List = listOf(getImageRoomModel()), 66 | tracks: List = listOf(getTrackRoomModel()), 67 | tags: List = listOf(getTagRoomModel()), 68 | ): AlbumRoomModel = 69 | AlbumRoomModel( 70 | id, 71 | mbId, 72 | name, 73 | artist, 74 | images, 75 | tracks, 76 | tags, 77 | ) 78 | 79 | private fun getImageRoomModel( 80 | url: String = "url_${ImageSizeApiModel.EXTRA_LARGE}", 81 | size: ImageSizeRoomModel = ImageSizeRoomModel.EXTRA_LARGE, 82 | ) = ImageRoomModel(url, size) 83 | 84 | private fun getTrackRoomModel( 85 | name: String = "track", 86 | duration: Int = 12, 87 | ) = TrackRoomModel(name, duration) 88 | 89 | private fun getTagRoomModel(name: String = "tag") = TagRoomModel(name) 90 | 91 | object ApiResponse { 92 | internal fun getSearchAlbum() = 93 | SearchAlbumResponse( 94 | SearchAlbumResultsApiModel( 95 | AlbumListApiModel(getAlbumsApiModel()), 96 | ), 97 | ) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/composable/SearchBarComposable.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.composable 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.Clear 7 | import androidx.compose.material.icons.filled.Search 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.IconButton 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.OutlinedTextField 12 | import androidx.compose.material3.OutlinedTextFieldDefaults 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.LaunchedEffect 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.mutableStateOf 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.runtime.setValue 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.res.stringResource 22 | import androidx.compose.ui.text.input.TextFieldValue 23 | import androidx.compose.ui.tooling.preview.Preview 24 | import com.igorwojda.showcase.feature.album.R 25 | import com.igorwojda.showcase.feature.base.common.res.Dimen 26 | import kotlinx.coroutines.delay 27 | 28 | @Composable 29 | fun SearchBar( 30 | query: String, 31 | onQueryChange: (String) -> Unit, 32 | onSearch: (String) -> Unit, 33 | modifier: Modifier = Modifier, 34 | ) { 35 | val minimumProductQuerySize = 1 36 | val delayBeforeSubmittingQuery = 300L 37 | 38 | var textFieldValue by remember(query) { mutableStateOf(TextFieldValue(query)) } 39 | 40 | // Debounce search - only trigger search after user stops typing 41 | LaunchedEffect(textFieldValue.text, onSearch, onQueryChange) { 42 | if (textFieldValue.text.length >= minimumProductQuerySize) { 43 | delay(delayBeforeSubmittingQuery) 44 | onSearch(textFieldValue.text) 45 | onQueryChange(textFieldValue.text) 46 | } else if (textFieldValue.text.isEmpty()) { 47 | // Immediately search when query is cleared 48 | onSearch("") 49 | onQueryChange("") 50 | } 51 | } 52 | 53 | OutlinedTextField( 54 | value = textFieldValue, 55 | modifier = 56 | modifier 57 | .fillMaxWidth() 58 | .padding(Dimen.spaceM), 59 | onValueChange = { newValue -> 60 | textFieldValue = newValue 61 | }, 62 | placeholder = { 63 | Text(stringResource(R.string.album_list_search_placeholder)) 64 | }, 65 | leadingIcon = { 66 | Icon( 67 | imageVector = Icons.Default.Search, 68 | contentDescription = "Search", 69 | ) 70 | }, 71 | trailingIcon = 72 | if (textFieldValue.text.isNotEmpty()) { 73 | { 74 | IconButton( 75 | onClick = { 76 | textFieldValue = TextFieldValue("") 77 | onSearch("") 78 | onQueryChange("") 79 | }, 80 | ) { 81 | Icon( 82 | imageVector = Icons.Default.Clear, 83 | contentDescription = "Clear search", 84 | ) 85 | } 86 | } 87 | } else { 88 | null 89 | }, 90 | singleLine = true, 91 | colors = 92 | OutlinedTextFieldDefaults.colors( 93 | unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), 94 | focusedBorderColor = MaterialTheme.colorScheme.primary, 95 | ), 96 | ) 97 | } 98 | 99 | @Preview 100 | @Composable 101 | private fun SearchBarPreview() { 102 | SearchBar( 103 | query = "Sample query", 104 | onQueryChange = { }, 105 | onSearch = { }, 106 | ) 107 | } 108 | 109 | @Preview 110 | @Composable 111 | private fun SearchBarEmptyPreview() { 112 | SearchBar( 113 | query = "", 114 | onQueryChange = { }, 115 | onSearch = { }, 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/presentation/BottomNavigationBar.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app.presentation 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.annotation.StringRes 5 | import androidx.compose.material3.Icon 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.NavigationBar 8 | import androidx.compose.material3.NavigationBarItem 9 | import androidx.compose.material3.NavigationBarItemDefaults 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.navigation.NavController 18 | import androidx.navigation.compose.currentBackStackEntryAsState 19 | import androidx.navigation.compose.rememberNavController 20 | import com.igorwojda.showcase.app.R 21 | 22 | @Composable 23 | fun BottomNavigationBar( 24 | navController: NavController, 25 | modifier: Modifier = Modifier, 26 | ) { 27 | val navigationItems = getBottomNavigationItems() 28 | 29 | val navBackStackEntry by navController.currentBackStackEntryAsState() 30 | val currentRoute = navBackStackEntry?.destination?.route 31 | 32 | val selectedNavigationIndex = getSelectedNavigationIndex(currentRoute, navigationItems) 33 | 34 | NavigationBar( 35 | modifier = modifier, 36 | ) { 37 | navigationItems.forEachIndexed { index, item -> 38 | NavigationBarItem( 39 | selected = selectedNavigationIndex == index, 40 | onClick = { 41 | navController.navigate(item.route) { 42 | popUpTo(0) 43 | restoreState = true // Restores previous state if returning 44 | } 45 | }, 46 | icon = { 47 | Icon( 48 | painter = painterResource(item.iconRes), 49 | contentDescription = stringResource(item.titleRes), 50 | ) 51 | }, 52 | label = { 53 | Text( 54 | stringResource(item.titleRes), 55 | ) 56 | }, 57 | colors = 58 | NavigationBarItemDefaults.colors( 59 | selectedIconColor = MaterialTheme.colorScheme.surface, 60 | indicatorColor = MaterialTheme.colorScheme.primary, 61 | ), 62 | ) 63 | } 64 | } 65 | } 66 | 67 | private fun getBottomNavigationItems() = 68 | listOf( 69 | NavigationBarItem( 70 | R.string.bottom_navigation_albums, 71 | R.drawable.ic_music_library, 72 | NavigationRoute.AlbumList, 73 | ), 74 | NavigationBarItem( 75 | R.string.bottom_navigation_favorites, 76 | R.drawable.ic_favorite, 77 | NavigationRoute.Favourites, 78 | ), 79 | NavigationBarItem( 80 | R.string.bottom_navigation_settings, 81 | R.drawable.ic_settings, 82 | NavigationRoute.Settings, 83 | ), 84 | ) 85 | 86 | /* 87 | Returns the index of the selected bottom menu item based on the current route. 88 | If no match is found, it defaults to the first item (index 0). 89 | */ 90 | private fun getSelectedNavigationIndex( 91 | currentRoute: String?, 92 | navigationItems: List, 93 | ): Int = 94 | navigationItems 95 | .indexOfFirst { item -> 96 | when (currentRoute) { 97 | null -> false 98 | NavigationRoute.AlbumDetail::class.qualifiedName -> item.route is NavigationRoute.AlbumList 99 | NavigationRoute.AboutLibraries::class.qualifiedName -> item.route is NavigationRoute.Settings 100 | else -> item.route::class.qualifiedName == currentRoute 101 | } 102 | }.takeIf { it >= 0 } ?: 0 103 | 104 | data class NavigationBarItem( 105 | @StringRes val titleRes: Int, 106 | @DrawableRes val iconRes: Int, 107 | val route: NavigationRoute, 108 | ) 109 | 110 | @Preview 111 | @Composable 112 | private fun BottomNavigationBarPreview() { 113 | BottomNavigationBar( 114 | navController = rememberNavController(), 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/igorwojda/showcase/app/presentation/MainShowcaseScreen.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.app.presentation 2 | 3 | import android.os.Bundle 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.Scaffold 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.navigation.NavController 10 | import androidx.navigation.NavDestination 11 | import androidx.navigation.compose.NavHost 12 | import androidx.navigation.compose.composable 13 | import androidx.navigation.compose.rememberNavController 14 | import androidx.navigation.createGraph 15 | import androidx.navigation.toRoute 16 | import com.igorwojda.showcase.app.BuildConfig 17 | import com.igorwojda.showcase.app.presentation.util.NavigationDestinationLogger 18 | import com.igorwojda.showcase.feature.album.presentation.screen.albumdetail.AlbumDetailScreen 19 | import com.igorwojda.showcase.feature.album.presentation.screen.albumlist.AlbumListScreen 20 | import com.igorwojda.showcase.feature.favourite.presentation.screen.favourite.FavouriteScreen 21 | import com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries.AboutLibrariesScreen 22 | import com.igorwojda.showcase.feature.settings.presentation.screen.settings.SettingsScreen 23 | 24 | @Composable 25 | fun MainShowcaseScreen(modifier: Modifier = Modifier) { 26 | val navController = rememberNavController() 27 | 28 | if (BuildConfig.DEBUG) { 29 | addOnDestinationChangedListener(navController) 30 | } 31 | 32 | Scaffold( 33 | modifier = modifier.fillMaxSize(), 34 | bottomBar = { BottomNavigationBar(navController) }, 35 | ) { innerPadding -> 36 | 37 | val graph = 38 | navController.createGraph(startDestination = NavigationRoute.AlbumList) { 39 | composable { 40 | AlbumListScreen( 41 | // artistName: String, albumName: String, mbId: String? 42 | onNavigateToAlbumDetail = { artistName, albumName, albumMbId -> 43 | navController.navigate( 44 | NavigationRoute.AlbumDetail(artistName, albumName, albumMbId), 45 | ) 46 | }, 47 | ) 48 | } 49 | composable { backStackEntry -> 50 | // Retrieve typed args 51 | val args = backStackEntry.toRoute() 52 | 53 | AlbumDetailScreen( 54 | albumName = args.albumName, 55 | artistName = args.artistName, 56 | albumMbId = args.albumMbId, 57 | onBackClick = { 58 | navController.popBackStack() 59 | }, 60 | ) 61 | } 62 | composable { 63 | FavouriteScreen() 64 | } 65 | composable { 66 | SettingsScreen( 67 | onNavigateToAboutLibraries = { 68 | navController.navigate(NavigationRoute.AboutLibraries) 69 | }, 70 | ) 71 | } 72 | composable { 73 | AboutLibrariesScreen( 74 | onBackClick = { 75 | navController.popBackStack() 76 | }, 77 | ) 78 | } 79 | } 80 | NavHost( 81 | navController = navController, 82 | graph = graph, 83 | modifier = Modifier.padding(innerPadding), 84 | ) 85 | } 86 | } 87 | 88 | private fun addOnDestinationChangedListener(navController: NavController) { 89 | navController.addOnDestinationChangedListener( 90 | object : NavController.OnDestinationChangedListener { 91 | override fun onDestinationChanged( 92 | controller: NavController, 93 | destination: NavDestination, 94 | arguments: Bundle?, 95 | ) { 96 | NavigationDestinationLogger.logDestinationChange(destination, arguments) 97 | } 98 | }, 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.settings.presentation.screen.settings 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.automirrored.filled.ArrowForward 12 | import androidx.compose.material.icons.filled.Info 13 | import androidx.compose.material3.Card 14 | import androidx.compose.material3.CardDefaults 15 | import androidx.compose.material3.ExperimentalMaterial3Api 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.ListItem 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.res.stringResource 24 | import androidx.compose.ui.tooling.preview.Preview 25 | import androidx.compose.ui.unit.dp 26 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 27 | import com.igorwojda.showcase.feature.settings.R 28 | import org.koin.androidx.compose.koinViewModel 29 | 30 | @OptIn(ExperimentalMaterial3Api::class) 31 | @Composable 32 | fun SettingsScreen( 33 | onNavigateToAboutLibraries: () -> Unit, 34 | modifier: Modifier = Modifier, 35 | ) { 36 | val viewModel: SettingsViewModel = koinViewModel() 37 | val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle() 38 | 39 | Column( 40 | modifier = 41 | modifier 42 | .fillMaxSize() 43 | .verticalScroll(rememberScrollState()), 44 | ) { 45 | when (uiState) { 46 | SettingsUiState.Content -> SettingsContent(onNavigateToAboutLibraries) 47 | } 48 | } 49 | } 50 | 51 | @Composable 52 | private fun SettingsContent(onNavigateToAboutLibraries: () -> Unit) { 53 | Column( 54 | modifier = Modifier.padding(vertical = 8.dp), 55 | ) { 56 | Card( 57 | modifier = 58 | Modifier 59 | .fillMaxWidth(), 60 | colors = 61 | CardDefaults.cardColors( 62 | containerColor = MaterialTheme.colorScheme.surface, 63 | ), 64 | ) { 65 | SettingsItem( 66 | title = stringResource(R.string.settings_screen_open_source_licenses), 67 | subtitle = stringResource(R.string.settings_screen_view_licenses_description), 68 | icon = { 69 | Icon( 70 | imageVector = Icons.Default.Info, 71 | contentDescription = stringResource(R.string.settings_screen_licenses), 72 | tint = MaterialTheme.colorScheme.primary, 73 | ) 74 | }, 75 | onClick = onNavigateToAboutLibraries, 76 | ) 77 | } 78 | } 79 | } 80 | 81 | @Composable 82 | private fun SettingsItem( 83 | title: String, 84 | subtitle: String? = null, 85 | icon: (@Composable () -> Unit)? = null, 86 | enabled: Boolean = true, 87 | onClick: () -> Unit, 88 | ) { 89 | ListItem( 90 | modifier = 91 | Modifier.clickable( 92 | enabled = enabled, 93 | onClick = onClick, 94 | ), 95 | headlineContent = { 96 | Text( 97 | text = title, 98 | style = MaterialTheme.typography.bodyLarge, 99 | ) 100 | }, 101 | supportingContent = 102 | subtitle?.let { 103 | { 104 | Text( 105 | text = it, 106 | style = MaterialTheme.typography.bodyMedium, 107 | ) 108 | } 109 | }, 110 | leadingContent = icon, 111 | trailingContent = 112 | if (enabled) { 113 | { 114 | Icon( 115 | imageVector = Icons.AutoMirrored.Filled.ArrowForward, 116 | contentDescription = stringResource(R.string.settings_screen_navigate), 117 | tint = MaterialTheme.colorScheme.onSurfaceVariant, 118 | ) 119 | } 120 | } else { 121 | null 122 | }, 123 | ) 124 | } 125 | 126 | @Preview 127 | @Composable 128 | private fun SettingsScreenPreview() { 129 | SettingsContent( 130 | onNavigateToAboutLibraries = { }, 131 | ) 132 | } 133 | -------------------------------------------------------------------------------- /feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.igorwojda.showcase.feature.album.presentation.screen.albumlist 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.size 8 | import androidx.compose.foundation.layout.wrapContentSize 9 | import androidx.compose.foundation.lazy.grid.GridCells 10 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 11 | import androidx.compose.foundation.lazy.grid.items 12 | import androidx.compose.material3.ElevatedCard 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.runtime.mutableStateOf 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.runtime.setValue 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.res.stringResource 22 | import androidx.compose.ui.tooling.preview.Preview 23 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 24 | import com.igorwojda.showcase.feature.album.R 25 | import com.igorwojda.showcase.feature.album.domain.model.Album 26 | import com.igorwojda.showcase.feature.album.presentation.composable.SearchBar 27 | import com.igorwojda.showcase.feature.base.common.res.Dimen 28 | import com.igorwojda.showcase.feature.base.presentation.compose.composable.ErrorAnim 29 | import com.igorwojda.showcase.feature.base.presentation.compose.composable.LoadingIndicator 30 | import com.igorwojda.showcase.feature.base.presentation.compose.composable.PlaceholderImage 31 | import org.koin.androidx.compose.koinViewModel 32 | 33 | @Composable 34 | fun AlbumListScreen( 35 | modifier: Modifier = Modifier, 36 | onNavigateToAlbumDetail: ((artistName: String, albumName: String, albumMbId: String?) -> Unit)? = null, 37 | ) { 38 | val viewModel: AlbumListViewModel = koinViewModel() 39 | 40 | val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle() 41 | 42 | var searchQuery by remember { mutableStateOf("") } 43 | 44 | LaunchedEffect(Unit) { 45 | viewModel.onInit() 46 | } 47 | 48 | Column(modifier = modifier.fillMaxSize()) { 49 | // Search bar 50 | SearchBar( 51 | query = searchQuery, 52 | onQueryChange = { newQuery -> 53 | searchQuery = newQuery 54 | }, 55 | onSearch = { query -> 56 | if (query.isNotEmpty()) { 57 | viewModel.onInit(query) 58 | } else { 59 | viewModel.onInit() 60 | } 61 | }, 62 | ) 63 | 64 | // Content 65 | Box( 66 | modifier = Modifier.fillMaxSize(), 67 | contentAlignment = Alignment.Center, 68 | ) { 69 | when (val currentUiState = uiState) { // Extract to local variable for smart casting 70 | AlbumListUiState.Error -> ErrorAnim() 71 | AlbumListUiState.Loading -> LoadingIndicator() 72 | is AlbumListUiState.Content -> AlbumListContent(currentUiState, onNavigateToAlbumDetail) 73 | } 74 | } 75 | } 76 | } 77 | 78 | @Composable 79 | private fun AlbumListContent( 80 | uiState: AlbumListUiState.Content, 81 | onNavigateToAlbumDetail: ((String, String, String?) -> Unit)?, 82 | ) { 83 | AlbumGrid( 84 | albums = uiState.albums, 85 | onAlbumClick = { album -> 86 | onNavigateToAlbumDetail?.invoke(album.artist, album.name, album.mbId) 87 | }, 88 | ) 89 | } 90 | 91 | @Composable 92 | private fun AlbumGrid( 93 | albums: List, 94 | onAlbumClick: (Album) -> Unit, 95 | ) { 96 | LazyVerticalGrid( 97 | columns = GridCells.Adaptive(Dimen.imageSize), 98 | ) { 99 | items(items = albums, key = { it.id }) { album -> 100 | ElevatedCard( 101 | modifier = 102 | Modifier 103 | .padding(Dimen.spaceS) 104 | .wrapContentSize(), 105 | onClick = { onAlbumClick(album) }, 106 | ) { 107 | PlaceholderImage( 108 | url = album.getDefaultImageUrl(), 109 | contentDescription = stringResource(id = R.string.album_detail_cover_content_description), 110 | modifier = Modifier.size(Dimen.imageSize), 111 | ) 112 | } 113 | } 114 | } 115 | } 116 | 117 | @Preview 118 | @Composable 119 | private fun AlbumGridPreview() { 120 | val sampleAlbums = 121 | listOf( 122 | Album( 123 | name = "Sample Album 1", 124 | artist = "Sample Artist", 125 | mbId = null, 126 | images = emptyList(), 127 | ), 128 | Album( 129 | name = "Sample Album 2", 130 | artist = "Sample Artist 2", 131 | mbId = null, 132 | images = emptyList(), 133 | ), 134 | ) 135 | 136 | AlbumGrid( 137 | albums = sampleAlbums, 138 | onAlbumClick = { }, 139 | ) 140 | } 141 | --------------------------------------------------------------------------------