Showing preview only (1,663K chars total). Download the full file or copy to clipboard to get everything.
Repository: igorwojda/android-showcase
Branch: main
Commit: a09e435e70b5
Files: 206
Total size: 1.5 MB
Directory structure:
gitextract_jwuhl568/
├── .editorconfig
├── .github/
│ ├── stale.yml
│ └── workflows/
│ ├── auto-approve.yml
│ ├── check.yml
│ ├── claude-code-review.yml
│ └── claude.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── DeveloperReadme.md
├── README.md
├── app/
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/
│ ├── debug/
│ │ ├── AndroidManifest.xml
│ │ └── res/
│ │ └── xml/
│ │ └── network_security_config.xml
│ └── main/
│ ├── AndroidManifest.xml
│ ├── kotlin/
│ │ └── com/
│ │ └── igorwojda/
│ │ └── showcase/
│ │ └── app/
│ │ ├── AppKoinModule.kt
│ │ ├── ShowcaseApplication.kt
│ │ ├── data/
│ │ │ └── api/
│ │ │ ├── AuthenticationInterceptor.kt
│ │ │ └── UserAgentInterceptor.kt
│ │ └── presentation/
│ │ ├── BottomNavigationBar.kt
│ │ ├── MainShowcaseActivity.kt
│ │ ├── MainShowcaseScreen.kt
│ │ ├── NavigationRoute.kt
│ │ └── util/
│ │ └── NavigationDestinationLogger.kt
│ └── res/
│ ├── drawable/
│ │ ├── ic_favorite.xml
│ │ ├── ic_launcher_foreground.xml
│ │ ├── ic_launcher_foreground_themed.xml
│ │ ├── ic_music_library.xml
│ │ └── ic_settings.xml
│ ├── mipmap-anydpi/
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ ├── mipmap-anydpi-v33/
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ ├── values/
│ │ ├── colors.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── strings.xml
│ │ └── styles.xml
│ └── xml/
│ └── data_extraction_rules.xml
├── build-logic/
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ └── src/
│ └── main/
│ └── kotlin/
│ └── com/
│ └── igorwojda/
│ └── showcase/
│ └── buildlogic/
│ ├── AboutLibrariesConventionPlugin.kt
│ ├── ApplicationConventionPlugin.kt
│ ├── DetektConventionPlugin.kt
│ ├── EasyLauncherConventionPlugin.kt
│ ├── FeatureConventionPlugin.kt
│ ├── KotlinConventionPlugin.kt
│ ├── LibraryConventionPlugin.kt
│ ├── SpotlessConventionPlugin.kt
│ ├── TestConventionLibraryPlugin.kt
│ ├── TestConventionPlugin.kt
│ ├── config/
│ │ └── JavaBuildConfig.kt
│ └── ext/
│ ├── BuildConfigExt.kt
│ ├── DependencyHandlerExt.kt
│ ├── PackagingExt.kt
│ └── ProjectExt.kt
├── build.gradle.kts
├── detekt.yml
├── feature/
│ ├── album/
│ │ ├── build.gradle.kts
│ │ ├── proguard-rules.pro
│ │ └── src/
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── igorwojda/
│ │ │ │ └── showcase/
│ │ │ │ └── feature/
│ │ │ │ └── album/
│ │ │ │ ├── AlbumKoinModule.kt
│ │ │ │ ├── data/
│ │ │ │ │ ├── DataModule.kt
│ │ │ │ │ ├── datasource/
│ │ │ │ │ │ ├── api/
│ │ │ │ │ │ │ ├── model/
│ │ │ │ │ │ │ │ ├── AlbumApiModel.kt
│ │ │ │ │ │ │ │ ├── AlbumListApiModel.kt
│ │ │ │ │ │ │ │ ├── ImageApiModel.kt
│ │ │ │ │ │ │ │ ├── ImageSizeApiModel.kt
│ │ │ │ │ │ │ │ ├── SearchAlbumResultsApiModel.kt
│ │ │ │ │ │ │ │ ├── TagApiModel.kt
│ │ │ │ │ │ │ │ ├── TagListApiModel.kt
│ │ │ │ │ │ │ │ ├── TrackApiModel.kt
│ │ │ │ │ │ │ │ └── TrackListApiModel.kt
│ │ │ │ │ │ │ ├── response/
│ │ │ │ │ │ │ │ ├── GetAlbumInfoResponse.kt
│ │ │ │ │ │ │ │ └── SearchAlbumResponse.kt
│ │ │ │ │ │ │ └── service/
│ │ │ │ │ │ │ └── AlbumRetrofitService.kt
│ │ │ │ │ │ └── database/
│ │ │ │ │ │ ├── AlbumDao.kt
│ │ │ │ │ │ ├── AlbumDatabase.kt
│ │ │ │ │ │ └── model/
│ │ │ │ │ │ ├── AlbumRoomModel.kt
│ │ │ │ │ │ ├── ImageRoomModel.kt
│ │ │ │ │ │ ├── ImageSizeRoomModel.kt
│ │ │ │ │ │ ├── TagRoomModel.kt
│ │ │ │ │ │ └── TrackRoomModel.kt
│ │ │ │ │ ├── mapper/
│ │ │ │ │ │ ├── AlbumMapper.kt
│ │ │ │ │ │ ├── ImageMapper.kt
│ │ │ │ │ │ ├── ImageSizeMapper.kt
│ │ │ │ │ │ ├── TagMapper.kt
│ │ │ │ │ │ └── TrackMapper.kt
│ │ │ │ │ └── repository/
│ │ │ │ │ └── AlbumRepositoryImpl.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── DomainModule.kt
│ │ │ │ │ ├── enum/
│ │ │ │ │ │ └── ImageSize.kt
│ │ │ │ │ ├── model/
│ │ │ │ │ │ ├── Album.kt
│ │ │ │ │ │ ├── Image.kt
│ │ │ │ │ │ ├── Tag.kt
│ │ │ │ │ │ └── Track.kt
│ │ │ │ │ ├── repository/
│ │ │ │ │ │ └── AlbumRepository.kt
│ │ │ │ │ └── usecase/
│ │ │ │ │ ├── GetAlbumListUseCase.kt
│ │ │ │ │ └── GetAlbumUseCase.kt
│ │ │ │ └── presentation/
│ │ │ │ ├── PresentationModule.kt
│ │ │ │ ├── composable/
│ │ │ │ │ └── SearchBarComposable.kt
│ │ │ │ ├── screen/
│ │ │ │ │ ├── albumdetail/
│ │ │ │ │ │ ├── AlbumDetailAction.kt
│ │ │ │ │ │ ├── AlbumDetailScreen.kt
│ │ │ │ │ │ ├── AlbumDetailUiState.kt
│ │ │ │ │ │ └── AlbumDetailViewModel.kt
│ │ │ │ │ └── albumlist/
│ │ │ │ │ ├── AlbumListAction.kt
│ │ │ │ │ ├── AlbumListScreen.kt
│ │ │ │ │ ├── AlbumListUiState.kt
│ │ │ │ │ └── AlbumListViewModel.kt
│ │ │ │ └── util/
│ │ │ │ └── TimeUtil.kt
│ │ │ └── res/
│ │ │ └── values/
│ │ │ └── strings.xml
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── igorwojda/
│ │ └── showcase/
│ │ └── feature/
│ │ └── album/
│ │ ├── data/
│ │ │ ├── DataFixtures.kt
│ │ │ ├── datasource/
│ │ │ │ └── api/
│ │ │ │ └── model/
│ │ │ │ ├── AlbumApiModelTest.kt
│ │ │ │ ├── ImageApiModelTest.kt
│ │ │ │ └── ImageSizeApiModelTest.kt
│ │ │ ├── mapper/
│ │ │ │ ├── AlbumMapperTest.kt
│ │ │ │ ├── ImageMapperTest.kt
│ │ │ │ ├── ImageSizeMapperTest.kt
│ │ │ │ ├── TagMapperTest.kt
│ │ │ │ └── TrackMapperTest.kt
│ │ │ └── repository/
│ │ │ └── AlbumRepositoryImplTest.kt
│ │ ├── domain/
│ │ │ ├── DomainFixtures.kt
│ │ │ ├── model/
│ │ │ │ └── AlbumTest.kt
│ │ │ └── usecase/
│ │ │ ├── GetAlbumListUseCaseTest.kt
│ │ │ └── GetAlbumUseCaseTest.kt
│ │ └── presentation/
│ │ └── screen/
│ │ ├── albumdetail/
│ │ │ └── AlbumDetailViewModelTest.kt
│ │ └── albumlist/
│ │ └── AlbumListViewModelTest.kt
│ ├── base/
│ │ ├── build.gradle.kts
│ │ ├── consumer-rules.pro
│ │ ├── proguard-rules.pro
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ ├── kotlin/
│ │ │ └── com/
│ │ │ └── igorwojda/
│ │ │ └── showcase/
│ │ │ └── feature/
│ │ │ └── base/
│ │ │ ├── common/
│ │ │ │ ├── delegate/
│ │ │ │ │ └── Observer.kt
│ │ │ │ └── res/
│ │ │ │ └── Dimen.kt
│ │ │ ├── data/
│ │ │ │ └── retrofit/
│ │ │ │ ├── ApiResult.kt
│ │ │ │ ├── ApiResultAdapterFactory.kt
│ │ │ │ ├── ApiResultCall.kt
│ │ │ │ └── ApiResultCallAdapter.kt
│ │ │ ├── domain/
│ │ │ │ └── result/
│ │ │ │ ├── Result.kt
│ │ │ │ └── ResultExt.kt
│ │ │ ├── presentation/
│ │ │ │ ├── compose/
│ │ │ │ │ └── composable/
│ │ │ │ │ ├── ErrorAnim.kt
│ │ │ │ │ ├── Loading.kt
│ │ │ │ │ ├── Lottie.kt
│ │ │ │ │ ├── PlaceholderImage.kt
│ │ │ │ │ ├── TextTitleLarge.kt
│ │ │ │ │ ├── TextTitleMedium.kt
│ │ │ │ │ └── UnderConstructionAnim.kt
│ │ │ │ └── viewmodel/
│ │ │ │ ├── BaseAction.kt
│ │ │ │ ├── BaseState.kt
│ │ │ │ ├── BaseViewModel.kt
│ │ │ │ └── StateTimeTravelDebugger.kt
│ │ │ └── util/
│ │ │ └── TimberLogTags.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── ic_search.xml
│ │ │ ├── image_placeholder_1.xml
│ │ │ ├── image_placeholder_2.xml
│ │ │ └── image_placeholder_3.xml
│ │ ├── raw/
│ │ │ ├── lottie_building_screen.json
│ │ │ └── lottie_error_screen.json
│ │ └── values/
│ │ ├── color_palete.xml
│ │ ├── ids.xml
│ │ ├── strings.xml
│ │ └── styles.xml
│ ├── favourite/
│ │ ├── build.gradle.kts
│ │ ├── proguard-rules.pro
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── com/
│ │ └── igorwojda/
│ │ └── showcase/
│ │ └── feature/
│ │ └── favourite/
│ │ ├── FavouriteKoinModule.kt
│ │ ├── data/
│ │ │ └── DataModule.kt
│ │ ├── domain/
│ │ │ └── DomainModule.kt
│ │ └── presentation/
│ │ ├── PresentationModule.kt
│ │ └── screen/
│ │ └── favourite/
│ │ └── FavouriteScreen.kt
│ └── settings/
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── kotlin/
│ │ │ └── com/
│ │ │ └── igorwojda/
│ │ │ └── showcase/
│ │ │ └── feature/
│ │ │ └── settings/
│ │ │ ├── SettingsKoinModule.kt
│ │ │ ├── data/
│ │ │ │ └── DataModule.kt
│ │ │ ├── domain/
│ │ │ │ └── DomainModule.kt
│ │ │ └── presentation/
│ │ │ ├── PresentationModule.kt
│ │ │ └── screen/
│ │ │ ├── aboutlibraries/
│ │ │ │ ├── AboutLibrariesAction.kt
│ │ │ │ ├── AboutLibrariesScreen.kt
│ │ │ │ ├── AboutLibrariesUiState.kt
│ │ │ │ └── AboutLibrariesViewModel.kt
│ │ │ └── settings/
│ │ │ ├── SettingsAction.kt
│ │ │ ├── SettingsScreen.kt
│ │ │ ├── SettingsUiState.kt
│ │ │ └── SettingsViewModel.kt
│ │ └── res/
│ │ └── values/
│ │ └── strings.xml
│ └── test/
│ └── kotlin/
│ └── com/
│ └── igorwojda/
│ └── showcase/
│ └── feature/
│ └── settings/
│ └── presentation/
│ └── screen/
│ ├── aboutlibraries/
│ │ └── AboutLibrariesViewModelTest.kt
│ └── settings/
│ └── SettingsViewModelTest.kt
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── konsist-test/
│ ├── build.gradle.kts
│ └── src/
│ └── test/
│ └── kotlin/
│ └── com/
│ └── igorwojda/
│ └── showcase/
│ └── konsisttest/
│ ├── AndroidKonsistTest.kt
│ ├── CleanArchitectureKonsistTest.kt
│ ├── GeneralKonsistTest.kt
│ ├── ModuleKonsistTest.kt
│ ├── TestKonsistTest.kt
│ ├── UseCaseKonsistTest.kt
│ └── ViewModelKonsistTest.kt
├── library/
│ └── test-utils/
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/
│ └── main/
│ ├── AndroidManifest.xml
│ └── kotlin/
│ └── com/
│ └── igorwojda/
│ └── showcase/
│ └── library/
│ └── testutils/
│ ├── CoroutinesTestDispatcherExtension.kt
│ └── InstantTaskExecutorExtension.kt
├── renovate.json
└── settings.gradle.kts
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*.{kt,kts}]
end_of_line = lf
insert_final_newline = true
max_line_length = 140
ktlint_function_naming_ignore_when_annotated_with=Composable
# Detekt orders imports correctly, while ktlint does not
ktlint_standard_import-ordering = disabled
================================================
FILE: .github/stale.yml
================================================
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- enhancement
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: true
================================================
FILE: .github/workflows/auto-approve.yml
================================================
name: Auto Approve
on: pull_request_target
jobs:
auto-approve:
runs-on: ubuntu-latest
permissions:
pull-requests: write
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor == github.event.pull_request.user.login &&
contains(fromJson('["renovate[bot]", "igorwojda"]'), github.actor)
steps:
- uses: hmarr/auto-approve-action@v4
================================================
FILE: .github/workflows/check.yml
================================================
name: Check
on:
push:
branches: [ main ] # Just in case main was not up to date while merging PR
pull_request:
types: [ opened, synchronize ]
# Cancel previous runs on new push
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-debug:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
java-version: 17
distribution: 'zulu'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: Build App
run: ./gradlew :app:assembleDebug --no-build-cache
- uses: actions/upload-artifact@v5
with:
name: app-debug
path: app/build/outputs/apk/debug/app-debug.apk
android-lint:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
java-version: 17
distribution: 'zulu'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: lintDebug
run: ./gradlew lint
- uses: actions/upload-artifact@v5
if: always()
with:
name: android-lint-report
path: |
app/build/reports/lint-results*.html
feature/*/build/reports/lint-results*.html
library/*/build/reports/lint-results*.html
detekt:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
java-version: 17
distribution: 'zulu'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: detekt
run: ./gradlew detektCheck
- uses: actions/upload-artifact@v5
if: always()
with:
name: detekt-report
path: |
build/reports/detekt/detekt.*
app/build/reports/detekt/detekt.*
feature/*/build/reports/detekt/detekt.*
library/*/build/reports/detekt/detekt.*
konsist-test/build/reports/detekt/detekt.*
konsist:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
java-version: 17
distribution: 'zulu'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: konsist
run: ./gradlew konsist-test:test --rerun-tasks
- uses: actions/upload-artifact@v5
if: always()
with:
name: konsist-report
path: ./konsist-test/build/reports/*
spotless:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
java-version: 17
distribution: 'zulu'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: spotlessCheck
run: ./gradlew spotlessCheck
- uses: actions/upload-artifact@v5
if: always()
with:
name: spotless-report
path: |
build/reports/spotless/*
app/build/reports/spotless/*
feature/*/build/reports/spotless/*
library/*/build/reports/spotless/*
konsist-test/build/reports/spotless/*
# ui-test:
# runs-on: macos-latest
# steps:
# - name: checkout
# uses: actions/checkout@v4
#
# - name: Set up JDK
# uses: actions/setup-java@v4
# with:
# java-version: 17
# distribution: 'zulu'
#
# - name: Setup Gradle
# uses: gradle/actions/setup-gradle@v4
#
# - name: run ui tests
# uses: reactivecircus/android-emulator-runner@v2
# with:
# api-level: 29
# target: default
# arch: x86
# profile: Nexus 6
# disable-animations: true
# script: ./gradlew connectedCheck
#
# - uses: actions/upload-artifact@v4
# with:
# name: ui-test-report
# path: ./**/build/reports/androidTests/
unit-test:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v5
- name: Set up JDK
uses: actions/setup-java@v5
with:
java-version: 17
distribution: 'zulu'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
- name: unitTest
run: ./gradlew test -x konsist-test:test
- uses: actions/upload-artifact@v5
if: always()
with:
name: unit-test-report
path: |
app/build/reports/tests/
feature/*/build/reports/tests/
library/*/build/reports/tests/
================================================
FILE: .github/workflows/claude-code-review.yml
================================================
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
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:*)"'
================================================
FILE: .github/workflows/claude.yml
================================================
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
# claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
================================================
FILE: .gitignore
================================================
# Built application files
*.apk
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
# Build folders
**/build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
# Google Services (e.g. APIs or Firebase)
google-services.json
# Cache of project
.gradletasknamecache
#MacOS DS_Store
**/.DS_Store
# Kotlin
*.salive
# Claude Code
.claude/
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at {{ email }}. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
We appreciate contributions of any kind - new contributions
are welcome whether it's through bug reports or new pull requests.
## Tell us about enhancements and bugs
Please add an issue. We'll review it, add labels and reply when we get the chance.
## See an issue you'd like to work on
Comment on the issue that you'd like to work on and we'll add the
`claimed` label. If you see the `claimed` label already on the issue you
might want to ask the contributor if they'd like some help.
## Documentation needs updating
Go right ahead! Just submit a pull request when you're done.
## Pull Requests
We love pull requests from everyone:
1. [Fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) this repository:
2. Clone forked repository `git clone git@github.com:YOUR-USERNAME/android-showcase.git`
3. Branch of the `main` branch.
4. Make changes, push changes to your fork and
[submit a pull request](https://github.com/igorwojda/android-showcase/compare) against the `main` branch.
At this point you're waiting on us. We like to at least comment on pull requests within few days. We may suggest some
changes or improvements or alternatives.
Some things that will increase the chance that your pull request is accepted:
1. Write a [good commit message](https://chris.beams.io/posts/git-commit/)
2. Make sure all tests and lint checks are passing (review them on the pull request page)
3. Update [README](README.md) with any changes are needed
4. Write tests (if needed)
================================================
FILE: DeveloperReadme.md
================================================
# Developer Readme
## Detekt
- [Detekt configuration](https://detekt.dev/docs/introduction/configurations/) contains link to `default-detekt-config.yml`.
## Known Issues
- AboutLibraries
- AboutLibraries `12.2.4` Gradle plugin does nto include test dependencies https://github.com/mikepenz/AboutLibraries/issues/1238
- AboutLibraries `13.0.0-rc01` Gradle plugin required Kotlin 2.2.0 https://github.com/mikepenz/AboutLibraries/issues/1237
- Gradle
- Gradle `9.0` - Generated type-safe version catalogs accessors for `projcts` are not avialable inside `build-logic` module
- 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))`.
- Mockk
- Unable to mock some methods with implicit `continuation`
parameter in the `AlbumListViewModelTest` class ([Issue-957](https://github.com/mockk/mockk/issues/957))
- Detekt
- The `UnnecessaryParentheses` rule was disabled https://github.com/detekt/detekt/issues/8668
- Kotlin Plugin
- Auto-import (an import intention) for delegate does not work if the variable has the same name https://youtrack.jetbrains.com/issue/KTIJ-17403
- Android Studio
- False positive "Unused symbol" for a custom Android application class referenced in `AndroidManifest.xml`
file ([KT-27971](https://youtrack.jetbrains.net/issue/KT-27971))
- Coil
- No way to automatically retry image load, so some images may not be loaded when connection speed
is low ([Issue 132](https://github.com/coil-kt/coil/issues/132))
================================================
FILE: README.md
================================================
# 💎 Android Showcase 2.0
[](https://kotlinlang.org)
[](https://developer.android.com/studio/releases/gradle-plugin)
[](https://gradle.org)
[](https://www.codefactor.io/repository/github/igorwojda/android-showcase)
A production-ready Android application demonstrating modern development practices and architectural patterns. This project showcases how to build scalable, maintainable, and testable Android applications using industry-standard tools and libraries.
Built with **Clean Architecture** principles, this app serves as a comprehensive example of modular design, advanced Gradle configuration, and robust CI/CD practices. Perfect for teams looking to establish solid architectural foundations for large-scale Android projects.
- [💎 Android Showcase 2.0](#-android-showcase-20)
- [Application Scope](#application-scope)
- [Tech-Stack](#tech-stack)
- [Architecture](#architecture)
- [Module Types and Dependencies](#module-types-and-dependencies)
- [Feature Module Structure](#feature-module-structure)
- [Presentation Layer](#presentation-layer)
- [Domain Layer](#domain-layer)
- [Data Layer](#data-layer)
- [Common Module Components](#common-module-components)
- [Data Flow](#data-flow)
- [Project Features](#project-features)
- [Development \& Debugging](#development--debugging)
- [Custom Icons For Each Variant](#custom-icons-for-each-variant)
- [Themed Icons](#themed-icons)
- [Gradle Config](#gradle-config)
- [Dependency Management](#dependency-management)
- [Convention Plugins](#convention-plugins)
- [Type Safe Project Accessors](#type-safe-project-accessors)
- [Unified Version Configuration](#unified-version-configuration)
- [Java/JVM Version Configuration](#javajvm-version-configuration)
- [Generated type-safe version catalogs accessors in `build-logic` module](#generated-type-safe-version-catalogs-accessors-in-build-logic-module)
- [Gradle Configuration Cache](#gradle-configuration-cache)
- [Code Verification](#code-verification)
- [CI Pipeline](#ci-pipeline)
- [Pre-push Hooks](#pre-push-hooks)
- [Project Scope \& Limitations](#project-scope--limitations)
- [Getting Started](#getting-started)
- [Roadmap](#roadmap)
- [Resources](#resources)
- [Contributing](#contributing)
- [Author](#author)
- [License](#license)
- [Animations License](#animations-license)
## Application Scope
A music discovery app built with Jetpack Compose that displays album information sourced from the [Last.fm API](https://www.last.fm/api). The application demonstrates real-world scenarios including network requests, local caching, navigation, and state management.
**Features:**
- **Album List** - Browse albums with search functionality
- **Album Details** - View detailed album information and track listings
- **Favorites** - Save preferred albums (WIP)
- **Profile** - User preferences and settings (WIP)
<p>
<img src="misc/image/screen_album_list.png" width="250" />
<img src="misc/image/screen_album_detail.png" width="250" />
<img src="misc/image/screen_favorites.png" width="250" />
<img src="misc/image/screen_settings.png" width="250" />
<img src="misc/image/screen_open_source_libraries.png" width="250" />
</p>
## Tech-Stack
Built with modern Android development tools and libraries, prioritizing, project structure stability and production-readiness.
**Core Technologies:**
- **[Kotlin 2.2+](https://kotlinlang.org/)** - Modern, expressive programming language
- **[Coroutines](https://kotlinlang.org/docs/coroutines-overview.html)** - Asynchronous programming
- **[Flow](https://kotlinlang.org/docs/flow.html)** - Reactive data streams
- **[KSP (Kotlin Symbol Processing)](https://kotlinlang.org/docs/ksp-overview.html)** - Kotlin Symbol Processing
- **[Serialization](https://kotlinlang.org/docs/serialization.html)** - JSON parsing
**Android Jetpack:**
- **[Compose](https://developer.android.com/jetpack/compose)** - Declarative UI framework
- **[Navigation Compose](https://developer.android.com/jetpack/compose/navigation)** - Type-safe navigation
- **[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel)** - UI-related data management
- **[Room](https://developer.android.com/jetpack/androidx/releases/room)** - Local database with SQLite
- **[Core Splashscreen](https://developer.android.com/jetpack/androidx/releases/core#core_splashscreen_version_12_2)** - app Splashscreen
**Networking & Images:**
- **[Retrofit](https://square.github.io/retrofit/)** - HTTP client for API communication
- **[Coil](https://github.com/coil-kt/coil)** - Image loading optimized for Compose
**Dependency Injection:**
- **[Koin](https://insert-koin.io/)** - Lightweight dependency injection framework
**Architecture:**
- **[Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)** - Separation of concerns with defined layers
- **Single Activity Architecture** - Modern navigation approach
- **MVVM + MVI** - Reactive presentation layer pattern providing common UI state.
- **Modular Design** - Feature-based modules for scalability
**UI & Design:**
- **[Material Design 3](https://m3.material.io/)** - Latest design system
- **[Dynamic Theming](https://m3.material.io/styles/color/dynamic-color/overview)** - Wallpaper-based themes (Android 12+)
- **[Dark Theme](https://material.io/develop/android/theming/dark)** - System-aware dark mode
- **[Lottie](http://airbnb.io/lottie)** - Vector animations
**Testing:**
- **[JUnit 6](https://junit.org/)** - Modern testing framework
- **[Mockk](https://mockk.io/)** - Kotlin-first mocking library
- **[Kluent](https://github.com/MarkusAmshove/Kluent)** - Fluent assertion library
- **[Espresso](https://developer.android.com/training/testing/espresso)** - UI testing (WIP)
**Code Quality:**
- **[Konsist](https://docs.konsist.lemonappdev.com/)** - Architecture and code structure convention tests
- **[Ktlint](https://github.com/pinterest/ktlint)** - Kotlin code formatting and issue detection
- **[Ktlint Standard Rules](https://pinterest.github.io/ktlint/0.49.1/rules/standard/)** - set of custom rules for Jetpack Compose
- **[Nlopez Jetpack Compose Rules](https://mrmans0n.github.io/compose-rules/)** - set of custom rules for Jetpack Compose
- **[Twitter's Jetpack Compose Rules](https://twitter.github.io/compose-rules/)** - set of custom rules for Jetpack Compose
- **[Detekt](https://github.com/arturbosch/detekt)** - Static analysis and complexity checks
- **[Android Lint](http://tools.android.com/tips/lint)** - Android-specific code analysis
- **[Spotless](https://github.com/diffplug/spotless)** - Code formatting enforcement
**Build & CI:**
- **[Gradle Kotlin DSL](https://docs.gradle.org/current/userguide/kotlin_dsl.html)** - Type-safe build scripts
- **[Version Catalogs](https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog)** - Centralized dependency management
- **[Convention Plugins](https://docs.gradle.org/current/samples/sample_convention_plugins.html)** - Shared build logic
- **[Renovate](https://github.com/renovatebot/renovate)** - Automated dependency updates
**GitHub Actions:**
- **[Check](.github/workflows/check.yml)** - CI pipeline with build, lint, test, and code quality checks
- **[Auto Approve](.github/workflows/auto-approve.yml)** - Auto-approval for trusted bot and maintainer PRs
- **[Claude Code](.github/workflows/claude.yml)** - AI-powered code assistance and review
- **[Claude Code Review](.github/workflows/claude-code-review.yml)** - Automated PR reviews using Claude
**Gradle Plugins:**
- **[Android Application](https://developer.android.com/build/releases/gradle-plugin)** (`com.android.application`) - Android app module configuration
- **[Android Library](https://developer.android.com/build/releases/gradle-plugin)** (`com.android.library`) - Android library module configuration
- **[Kotlin Android](https://kotlinlang.org/docs/gradle.html)** (`org.jetbrains.kotlin.android`) - Kotlin compilation for Android
- **[Kotlin Serialization](https://kotlinlang.org/docs/serialization.html)** (`org.jetbrains.kotlin.plugin.serialization`) - JSON serialization support
- **[Kotlin Compose Compiler](https://developer.android.com/jetpack/androidx/releases/compose-kotlin)** (`org.jetbrains.kotlin.plugin.compose`) - Compose compiler plugin
- **[KSP](https://kotlinlang.org/docs/ksp-overview.html)** - Kotlin Symbol Processing
- **[Detekt](https://detekt.dev/)** - Static code analysis
- **[Spotless](https://github.com/diffplug/spotless)** - Code formatting
- **[Test Logger](https://github.com/radarsh/gradle-test-logger-plugin)** - Enhanced test log output
- **[Easylauncher](https://github.com/usefulness/easylauncher-gradle-plugin)** - Modify the launcher icon of each of your app-variants
- **[AboutLibraries](https://github.com/mikepenz/AboutLibraries)** - collects dependency details, including licenses and visualize these in the app
## Architecture
The project implements **Clean Architecture** with a modular approach, treating each feature as an independent, reusable component similar to a microservice. This design enables maintainability and scalability for large development teams.
**Benefits of Modular Architecture:**
- **Reusability** - Shared code across multiple app variants
- **Separation of Concerns** - Clear module boundaries with explicit dependencies
- **Parallel Development** - Teams can work on features independently
- **Faster Build Times** - Incremental compilation and build caching
- **Testability** - Isolated testing of individual components
### Module Types and Dependencies

**Module Types:**
- **`app`** - Main application module containing navigation setup, DI configuration, and app-level components
- **`feature-*`** - Feature modules (album, profile, favourite) containing feature-specific business logic
- **`feature-base`** - Shared foundation module providing common utilities and base classes
- **`library-*`** - Utility modules for testing and shared functionality
### Feature Module Structure
`Clean Architecture` is implemented at the module level - each module contains its own set of Clean Architecture layers:

> Notice that the `app` module and `library_x` modules structure differs a bit from the feature module structure.
Each feature module contains 3 layers with a distinct set of responsibilities and common module components.

#### Presentation Layer
This layer is closest to what the user sees on the screen.
The `presentation` layer mixes `MVVM` and `MVI` patterns:
- `MVVM` - Jetpack `ViewModel` is used to encapsulate a `common UI state`. It exposes the `state` via observable state
holder (`Kotlin Flow`)
- `MVI` - `action` modifies the `common UI state` and emits a new state to a view via `Kotlin Flow`
> The `common state` is a single source of truth for each view. This solution derives from
> [Unidirectional Data Flow](https://en.wikipedia.org/wiki/Unidirectional_Data_Flow_(computer_science)) and [Redux
> principles](https://redux.js.org/introduction/three-principles).
This approach facilitates the creation of consistent states. The state is collected via `collectAsUiStateWithLifecycle`
method. Flows collection happens in a lifecycle-aware manner, so
[no resources are wasted](https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3).
Stated is annotated with [Immutable](https://developer.android.com/reference/kotlin/androidx/compose/runtime/Immutable)
annotation that is used by Jetpack compose to enable composition optimizations.
Components:
- **Screen (Composable)** - observes common view state (through `Kotlin Flow`). Compose transform state (emitted by Kotlin
Flow) into application UI Consumes the state and transforms it into application UI (via `Jetpack Compose`). Pass user
interactions to `ViewModel`. Views are hard to test, so they should be as simple as possible.
- **ViewModel** - emits (through `Kotlin Flow`) view state changes to the view and deals with user interactions (these
view models are not simply [POJO classes](https://en.wikipedia.org/wiki/Plain_old_Java_object)).
- **ViewState** - common state for a single view
- **StateTimeTravelDebugger** - logs actions and view state transitions to facilitate debugging.
- **NavManager** - singleton that facilitates handling all navigation events inside `NavHostActivity` (instead of
separately, inside each view)
#### Domain Layer
This is the core layer of the application. Notice that the `domain` layer is independent of any other layers. This
allows making domain models and business logic independent from other layers. In other words, changes in other layers
will not affect the `domain` layer eg. changing the database (`data` layer) or screen UI (`presentation` layer) ideally will
not result in any code change within the `domain` layer.
Components:
- **UseCase** - contains business logic
- **DomainModel** - defines the core structure of the data that will be used within the application. This is the source
of truth for application data.
- **Repository interface** - required to keep the `domain` layer independent from
the `data layer` ([Dependency inversion](https://en.wikipedia.org/wiki/Dependency_inversion_principle)).
#### Data Layer
Encapsulates application data. Provides the data to the `domain` layer eg. retrieves data from the internet and cache the
data in disk cache (when the device is offline).
Components:
- **Repository** is exposing data to the `domain` layer. Depending on the application structure and quality of the
external API repository can also merge, filter, and transform the data. These operations intend to create
a high-quality data source for the `domain` layer. It is the responsibility of the Repository (one or more) to construct
Domain models by reading from the `Data Source` and accepting Domain models to be written to the `Data Source`
- **Mapper** - maps `data model` to `domain model` (to keep `domain` layer independent from the `data` layer).
This application has two `Data Sources` - `Retrofit` (used for network access) and `Room` (local storage used to access
device persistent memory). These data sources can be treated as an implicit sub-layer. Each data source consists of
multiple classes:
- **Retrofit Service** - defines a set of API endpoints
- **Retrofit Response Model** - definition of the network objects for a given endpoint (top-level model for the data
consists of `ApiModels`)
- **Retrofit Api Data Model** - defines the network objects (sub-objects of the `Response Model`)
- **Room Database** - persistence database to store app data
- **Room DAO** - interact with the stored data
- **Room Entity** - definition of the stored objects
Both `Retrofit API Data Models` and `Room Entities` contain annotations, so the given framework understands how to parse the
data into objects.
#### Common Module Components
Each module in the Android project contains several standard items that provide essential functionality and configuration:
Components:
- **Gradle Build Script** - `build.gradle.kts` defining dependencies, build configurations, and plugins.
- **Koin DI Module** - Dependency injection configuration
- **Tests** - Unit tests (`test/`) and integration tests (`androidTest/`)
- **Android Resources** - resources (`res/`) including strings, drawables, and assets.
- **Android Manifest** - The `AndroidManifest.xml` file declaring module metadata.
### Data Flow
The below diagram presents application data flow when a user interacts with the `album list screen`:

## Project Features
### Development & Debugging
Tags ([LogTags](feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/util/LogTags.kt)) help filter and identify different types of logs during development and debugging.
The app provides detailed logging for development and debugging, with each log easily filterable by its tag:
- `Navigation` - Navigation events and route changes

- `Action` - User actions and UI state modifications

- `Network` - Network requests, responses, and HTTP-related logs

### Custom Icons For Each Variant
Thanks to [Easylauncher Gradle plugin](https://github.com/usefulness/easylauncher-gradle-plugin) the `debug` build has custom icon label:
<img src="./misc/image/application_icon_label.png" alt="application_icon_label" width="114"/>
### Themed Icons
App supports [Themed Icons](https://medium.com/@enikebraimoh/android-themed-icons-a-comprehensive-guide-3abb33ab51a7).
Left (classic icon), Right (themed icon):
<img src="./misc/image/application_themed_icon.png" alt="application_icon_label" width="225"/>
## Gradle Config
### Dependency Management
Gradle [versions catalog](https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog) is used as a centralized dependency management third-party dependency coordinates (group, artifact, version) are shared across all modules (Gradle projects and subprojects).
Gradle versions catalog consists of a few major sections:
- `[versions]` - declare versions that can be referenced by all dependencies
- `[libraries]` - declare the aliases to library coordinates
- `[bundles]` - declare dependency bundles (groups)
- `[plugins]` - declare Gradle plugin dependencies
Each module uses convention a plugin, so common dependencies are shared without the need to add them explicitly in each module.
### Convention Plugins
[Convention plugins](https://docs.gradle.org/current/samples/sample_convention_plugins.html) standardize build configuration across modules by encapsulating common build logic into reusable plugins:
- **[`Application Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ApplicationConventionPlugin.kt)** - Main application module configuration with Android app setup
- **[`Feature Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/FeatureConventionPlugin.kt)** - Feature module configuration combining library and Kotlin conventions
- **[`Library Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/LibraryConventionPlugin.kt)** - Android library module setup with common Android configuration
- **[`Lotlin Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/KotlinConventionPlugin.kt)** - Kotlin compilation settings, toolchain, and compiler options
- **[`Test Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/TestConventionPlugin.kt)** - Testing framework setup (JUnit, test logging, and test configurations)
- **[`Test Library Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/TestConventionLibraryPlugin.kt)** - Testing setup specifically for library modules
- **[`Detekt Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/DetektConventionPlugin.kt)** - Static code analysis configuration with Detekt
- **[`Spotless Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/SpotlessConventionPlugin.kt)** - Code formatting and style enforcement with Spotless
- **[`Easylauncher Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/EasyLauncherConventionPlugin.kt)** - App icon customization for different build variants
- **[`AboutLibraries Convention`](./build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/AboutLibrariesConventionPlugin.kt)** - About libraries configuration
### Type Safe Project Accessors
Enables type-safe project references instead of error-prone string-based module paths:
```kotlin
// Before
implementation(project(":feature:album"))
// After
implementation(projects.feature.album)
```
### Unified Version Configuration
All dependency and Gradle plugin versions are defined in the TOML version catalog file ([libs.versions.toml](gradle/libs.versions.toml)).
#### Java/JVM Version Configuration
The Java/JVM version is centralized across the project.
It is defined once in [`libs.versions.toml`](gradle/libs.versions.toml) file under the java entry.
The `generateJavaBuildConfig` task reads this value and generates a `JavaBuildConfig.kt` file with constants.
These constants are then used in Gradle convention plugins to configure both Java and Kotlin consistently:
```kotlin
compileOptions {
sourceCompatibility = JavaBuildConfig.JAVA_VERSION
targetCompatibility = JavaBuildConfig.JAVA_VERSION
}
kotlin {
compilerOptions {
jvmTarget = JavaBuildConfig.jvmTarget
}
jvmToolchain(JavaBuildConfig.jvmToolchainVersion)
}
```
### Generated type-safe version catalogs accessors in `build-logic` module
The `build-logic` module provides type-safe access to version catalogs from within precompiled script plugins.
This is enabled via the `versionCatalogs` block in `build-logic/settings.gradle.kts`, which references the main version catalog file and `implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))` dependency in `build-logic/build.gradle.kts` file.
This setup allows you to use catalog dependencies in plugins, for example:
```kotlin
add("implementation", libs.timber)
```
Additionally, extensions defined in [DependencyHandlerScope](build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/DependencyHandlerExt.kt) make the syntax more natural and equivalent to standard Gradle usage:
```kotlin
implementation(libs.timber)
```
### Gradle Configuration Cache
Enabled [Gradle Configuration Cache](https://docs.gradle.org/9.0.0/userguide/configuration_cache_enabling.html).
## Code Verification
**Quality Checks:**
```bash
./gradlew konsist-test:test --rerun-tasks # Architecture & convention validation
./gradlew lintDebug # Android lint analysis
./gradlew detektCheck # Code complexity & style analysis
./gradlew spotlessCheck # Code formatting verification
./gradlew testDebugUnitTest -x konsist-test:test # Unit test execution (without Konsist tests)
./gradlew connectedCheck # UI test execution (WIP)
./gradlew :app:bundleDebug # Production build verification
```
**Auto-fix Commands:**
```bash
./gradlew detektApply # Apply Detekt formatting fixes
./gradlew spotlessApply # Apply code formatting fixes
./gradlew lintDebug # Update lint baseline
```
### CI Pipeline
[GitHub Actions](https://github.com/features/actions) workflows execute quality checks automatically:
- **PR Validation** - All checks run in parallel on pull requests
- **Main Branch Protection** - Post-merge validation ensures code quality
- **Automated Dependency Updates** - Renovate bot creates PRs for dependency updates
Configuration: [`.github/workflows`](.github/workflows)
### Pre-push Hooks
Optional [Git hooks](https://git-scm.com/docs/githooks#_pre_push) can execute quality checks before pushing code, providing fast feedback during development.
## Project Scope & Limitations
This showcase prioritizes **architecture, tooling, and development practices** over complex UI design. The interface uses Material Design 3 components but remains intentionally straightforward to focus on the underlying technical implementation.
## Getting Started
**Prerequisites:**
- Android Studio Giraffe | 2022.3.1+
- JDK 17+
- Android SDK 34+
**Setup:**
```bash
# Clone the repository
git clone https://github.com/igorwojda/android-showcase.git
# Open in Android Studio
# File -> Open -> Select cloned directory
```
**Recommended IDE Plugins:**
- [Detekt](https://plugins.jetbrains.com/plugin/10761-detekt) - Configure with [detekt.yml](detekt.yml)
- [Kotlin](https://plugins.jetbrains.com/plugin/6954-kotlin) - Usually pre-installed
- [Android](https://developer.android.com/studio) - Usually pre-installed
## Roadmap
Active development continues with focus on modern Android practices. View planned [enhancements](https://github.com/igorwojda/android-showcase/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Aenhancement) and contribute ideas.
## Resources
**Development Tools:**
- [Material Theme Builder](https://m3.material.io/theme-builder#/dynamic) - Generate Material 3 dynamic themes
- [Compose Material 3 Components](https://developer.android.com/reference/kotlin/androidx/compose/material3/package-summary) - Component reference
- [Android Ecosystem Cheat Sheet](https://github.com/igorwojda/android-ecosystem-cheat-sheet) - 200+ essential Android tools
- [Kotlin Coroutines Use Cases](https://github.com/LukasLechnerDev/Kotlin-Coroutine-Use-Cases-on-Android) - Practical coroutine examples
**Recommended Projects:**
- [Now in Android](https://github.com/android/nowinandroid) - Google's official modern Android showcase
- [Android Architecture Blueprints](https://github.com/googlesamples/android-architecture) - Architecture pattern examples
- [Compose Samples](https://github.com/android/compose-samples) - Official Jetpack Compose examples
- [Kotlin Android Template](https://github.com/cortinico/kotlin-android-template) - Pre-configured project template
- [Androidify](https://github.com/android/androidify) - Android's official character customization app
- [WeatherXM Android](https://github.com/WeatherXM/wxm-android) - Weather data collection and rewards platform
- [Songify](https://github.com/JamesBuhanan/Songify) - Spotify-inspired music streaming app
- [Alkaa](https://github.com/igorescodro/alkaa) - Task management app with modern architecture
- [KotlinConf App](https://github.com/JetBrains/kotlinconf-app) - JetBrains' official conference app
- [Tivi](https://github.com/chrisbanes/tivi) - TV show tracking app by Chris Banes
- [CatchUp](https://github.com/ZacSweers/CatchUp) - News aggregation app with modular architecture
- [Heron](https://github.com/tunjid/heron) - Social media client showcasing modern Android development
## Contributing
Contributions are welcome! Please check the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines before submitting PRs.
**Areas for Contribution:**
- Feature implementations (Profile, Favorites screens)
- UI/UX improvements and animations
- Performance optimizations
- Testing coverage expansion
- Documentation improvements
## Author
**Igor Wojda** - Senior Android Engineer
[](https://twitter.com/igorwojda)
[](https://github.com/igorwojda)
## License
```
MIT License
Copyright (c) 2025 Igor Wojda
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
## Animations License
Flowing animations are distributed under `Creative Commons License 2.0`:
- [Error screen](https://lottiefiles.com/8049-error-screen) by Chetan Potnuru
- [Building Screen](https://lottiefiles.com/1271-building-screen) by Carolina Cajazeira
================================================
FILE: app/build.gradle.kts
================================================
import com.igorwojda.showcase.buildlogic.ext.buildConfigFieldFromGradleProperty
plugins {
id("com.igorwojda.showcase.convention.application")
}
android {
namespace = "com.igorwojda.showcase.app"
defaultConfig {
applicationId = "com.igorwojda.showcase"
versionCode = 1
versionName = "0.0.1" // SemVer (Major.Minor.Patch)
buildConfigFieldFromGradleProperty(project, "apiBaseUrl")
buildConfigFieldFromGradleProperty(project, "apiToken")
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles("proguard-android.txt", "proguard-rules.pro")
}
}
}
dependencies {
// "projects." Syntax utilizes Gradle TYPESAFE_PROJECT_ACCESSORS feature
implementation(projects.feature.base)
implementation(projects.feature.album)
implementation(projects.feature.settings)
implementation(projects.feature.favourite)
}
================================================
FILE: app/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: app/src/debug/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>
================================================
FILE: app/src/debug/res/xml/network_security_config.xml
================================================
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">ws.audioscrobbler.com</domain>
</domain-config>
<debug-overrides>
<trust-anchors>
<!-- Trust user added CAs.
Enables usage of Charles Proxy to review the traffic (debug build variant only).
See: https://www.charlesproxy.com/documentation/using-charles/ssl-certificates/
-->
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!--
Data backup:
dataExtractionRules - API 31+
allowBackup and fullBackupContent - API < 31
-->
<application
android:name=".ShowcaseApplication"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Showcase"
android:usesCleartextTraffic="true">
<activity
android:name="com.igorwojda.showcase.app.presentation.MainShowcaseActivity"
android:exported="true"
android:theme="@style/Theme.Showcase.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/AppKoinModule.kt
================================================
package com.igorwojda.showcase.app
import com.igorwojda.showcase.app.data.api.AuthenticationInterceptor
import com.igorwojda.showcase.app.data.api.UserAgentInterceptor
import com.igorwojda.showcase.feature.base.data.retrofit.ApiResultAdapterFactory
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.ExperimentalSerializationApi
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import retrofit2.Retrofit
import timber.log.Timber
val appModule =
module {
single { AuthenticationInterceptor(BuildConfig.GRADLE_API_TOKEN) }
singleOf(::UserAgentInterceptor)
single {
HttpLoggingInterceptor { message ->
Timber.d("Http: $message")
}.apply {
level = HttpLoggingInterceptor.Level.BODY
}
}
/*
* OkHttp logging interceptor with custom Timber logger.
*
* By default, HttpLoggingInterceptor uses the calling class name as the log tag which clutters Logcat and makes filtering harder.
*
* This custom configuration ensures:
* - All HTTP logs are tagged consistently as `"Network"`.
* - Logs are printed through Timber (instead of Android's `Log`).
* - Logging level is set to BODY to include headers and payloads.
*/
single {
HttpLoggingInterceptor { message ->
Timber.tag("Network").d(message)
}.apply {
/*
Use BODY logging only in debug builds.
Even if Timber.DebugTree() is planted only in debug, the interceptor still
reads/constructs request/response bodies when level = BODY.
This adds unnecessary overhead and may leak sensitive data if any logger
is active in production. Setting NONE in release avoids both risks.
*/
level =
if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
single {
OkHttpClient
.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(get<HttpLoggingInterceptor>())
}
addInterceptor(get<AuthenticationInterceptor>())
addInterceptor(get<UserAgentInterceptor>())
}.build()
}
single {
val contentType = "application/json".toMediaType()
val json =
kotlinx.serialization.json.Json {
// By default Kotlin serialization will serialize all of the keys present in JSON object and throw an
// exception if given key is not present in the Kotlin class. This flag allows to ignore JSON fields
ignoreUnknownKeys = true
}
@OptIn(ExperimentalSerializationApi::class)
Retrofit
.Builder()
.baseUrl(BuildConfig.GRADLE_API_BASE_URL)
.client(get())
.addConverterFactory(json.asConverterFactory(contentType))
.addCallAdapterFactory(ApiResultAdapterFactory())
.build()
}
}
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/ShowcaseApplication.kt
================================================
package com.igorwojda.showcase.app
import android.app.Application
import com.igorwojda.showcase.feature.album.featureAlbumModules
import com.igorwojda.showcase.feature.favourite.featureFavouriteModules
import com.igorwojda.showcase.feature.settings.featureSettingsModules
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext
import timber.log.Timber
class ShowcaseApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin()
initTimber()
}
private fun initKoin() {
GlobalContext.startKoin {
androidLogger()
androidContext(this@ShowcaseApplication)
modules(appModule)
modules(featureFavouriteModules)
modules(featureAlbumModules)
modules(featureSettingsModules)
}
}
private fun initTimber() {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
}
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/data/api/AuthenticationInterceptor.kt
================================================
package com.igorwojda.showcase.app.data.api
import okhttp3.Interceptor
import okhttp3.Response
class AuthenticationInterceptor(
private val apiKey: String,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response =
chain.request().let {
val url =
it.url
.newBuilder()
.addQueryParameter("api_key", apiKey)
.addQueryParameter("format", "json")
.build()
val newRequest =
it
.newBuilder()
.url(url)
.build()
chain.proceed(newRequest)
}
}
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/data/api/UserAgentInterceptor.kt
================================================
package com.igorwojda.showcase.app.data.api
import com.igorwojda.showcase.app.BuildConfig
import okhttp3.Interceptor
import okhttp3.Response
/*
* Adds a User-Agent header to the request. The header follows this format:
* <AppName>/<version> Dalvik/<version> (Linux; U; Android <android version>; <device ID> Build/<buildtag>)
*
* See user agents in mobile apps: https://www.scientiamobile.com/correctly-form-user-agents-for-mobile-apps
* See testing user agent: https://faisalman.github.io/ua-parser-js/
*/
class UserAgentInterceptor : Interceptor {
private val userAgent = "showcase/${BuildConfig.VERSION_NAME} ${System.getProperty("http.agent")}"
override fun intercept(chain: Interceptor.Chain): Response =
chain
.request()
.newBuilder()
.header("User-Agent", userAgent)
.build()
.let { chain.proceed(it) }
}
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/presentation/BottomNavigationBar.kt
================================================
package com.igorwojda.showcase.app.presentation
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.igorwojda.showcase.app.R
@Composable
fun BottomNavigationBar(
navController: NavController,
modifier: Modifier = Modifier,
) {
val navigationItems = getBottomNavigationItems()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val selectedNavigationIndex = getSelectedNavigationIndex(currentRoute, navigationItems)
NavigationBar(
modifier = modifier,
) {
navigationItems.forEachIndexed { index, item ->
NavigationBarItem(
selected = selectedNavigationIndex == index,
onClick = {
navController.navigate(item.route) {
popUpTo(0)
restoreState = true // Restores previous state if returning
}
},
icon = {
Icon(
painter = painterResource(item.iconRes),
contentDescription = stringResource(item.titleRes),
)
},
label = {
Text(
stringResource(item.titleRes),
)
},
colors =
NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.surface,
indicatorColor = MaterialTheme.colorScheme.primary,
),
)
}
}
}
private fun getBottomNavigationItems() =
listOf(
NavigationBarItem(
R.string.bottom_navigation_albums,
R.drawable.ic_music_library,
NavigationRoute.AlbumList,
),
NavigationBarItem(
R.string.bottom_navigation_favorites,
R.drawable.ic_favorite,
NavigationRoute.Favourites,
),
NavigationBarItem(
R.string.bottom_navigation_settings,
R.drawable.ic_settings,
NavigationRoute.Settings,
),
)
/*
Returns the index of the selected bottom menu item based on the current route.
If no match is found, it defaults to the first item (index 0).
*/
private fun getSelectedNavigationIndex(
currentRoute: String?,
navigationItems: List<NavigationBarItem>,
): Int =
navigationItems
.indexOfFirst { item ->
when (currentRoute) {
null -> false
NavigationRoute.AlbumDetail::class.qualifiedName -> item.route is NavigationRoute.AlbumList
NavigationRoute.AboutLibraries::class.qualifiedName -> item.route is NavigationRoute.Settings
else -> item.route::class.qualifiedName == currentRoute
}
}.takeIf { it >= 0 } ?: 0
data class NavigationBarItem(
@StringRes val titleRes: Int,
@DrawableRes val iconRes: Int,
val route: NavigationRoute,
)
@Preview
@Composable
private fun BottomNavigationBarPreview() {
BottomNavigationBar(
navController = rememberNavController(),
)
}
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/presentation/MainShowcaseActivity.kt
================================================
package com.igorwojda.showcase.app.presentation
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
class MainShowcaseActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(colorScheme = getColorScheme()) {
MainShowcaseScreen()
}
}
}
@Composable
private fun getColorScheme(): ColorScheme {
val darkTheme: Boolean = isSystemInDarkTheme()
val dynamicColor: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val context = LocalContext.current
return when {
dynamicColor -> if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
}
}
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/presentation/MainShowcaseScreen.kt
================================================
package com.igorwojda.showcase.app.presentation
import android.os.Bundle
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.createGraph
import androidx.navigation.toRoute
import com.igorwojda.showcase.app.BuildConfig
import com.igorwojda.showcase.app.presentation.util.NavigationDestinationLogger
import com.igorwojda.showcase.feature.album.presentation.screen.albumdetail.AlbumDetailScreen
import com.igorwojda.showcase.feature.album.presentation.screen.albumlist.AlbumListScreen
import com.igorwojda.showcase.feature.favourite.presentation.screen.favourite.FavouriteScreen
import com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries.AboutLibrariesScreen
import com.igorwojda.showcase.feature.settings.presentation.screen.settings.SettingsScreen
@Composable
fun MainShowcaseScreen(modifier: Modifier = Modifier) {
val navController = rememberNavController()
if (BuildConfig.DEBUG) {
addOnDestinationChangedListener(navController)
}
Scaffold(
modifier = modifier.fillMaxSize(),
bottomBar = { BottomNavigationBar(navController) },
) { innerPadding ->
val graph =
navController.createGraph(startDestination = NavigationRoute.AlbumList) {
composable<NavigationRoute.AlbumList> {
AlbumListScreen(
// artistName: String, albumName: String, mbId: String?
onNavigateToAlbumDetail = { artistName, albumName, albumMbId ->
navController.navigate(
NavigationRoute.AlbumDetail(artistName, albumName, albumMbId),
)
},
)
}
composable<NavigationRoute.AlbumDetail> { backStackEntry ->
// Retrieve typed args
val args = backStackEntry.toRoute<NavigationRoute.AlbumDetail>()
AlbumDetailScreen(
albumName = args.albumName,
artistName = args.artistName,
albumMbId = args.albumMbId,
onBackClick = {
navController.popBackStack()
},
)
}
composable<NavigationRoute.Favourites> {
FavouriteScreen()
}
composable<NavigationRoute.Settings> {
SettingsScreen(
onNavigateToAboutLibraries = {
navController.navigate(NavigationRoute.AboutLibraries)
},
)
}
composable<NavigationRoute.AboutLibraries> {
AboutLibrariesScreen(
onBackClick = {
navController.popBackStack()
},
)
}
}
NavHost(
navController = navController,
graph = graph,
modifier = Modifier.padding(innerPadding),
)
}
}
private fun addOnDestinationChangedListener(navController: NavController) {
navController.addOnDestinationChangedListener(
object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?,
) {
NavigationDestinationLogger.logDestinationChange(destination, arguments)
}
},
)
}
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/presentation/NavigationRoute.kt
================================================
package com.igorwojda.showcase.app.presentation
import kotlinx.serialization.Serializable
sealed interface NavigationRoute {
@Serializable
data object AlbumList : NavigationRoute
@Serializable
data class AlbumDetail(
val albumName: String,
val artistName: String,
val albumMbId: String?,
) : NavigationRoute
@Serializable
data object Favourites : NavigationRoute
@Serializable
data object Settings : NavigationRoute
@Serializable
data object AboutLibraries : NavigationRoute
}
================================================
FILE: app/src/main/kotlin/com/igorwojda/showcase/app/presentation/util/NavigationDestinationLogger.kt
================================================
package com.igorwojda.showcase.app.presentation.util
import android.os.Bundle
import androidx.navigation.NavDestination
import com.igorwojda.showcase.app.presentation.NavigationRoute
import com.igorwojda.showcase.feature.base.util.TimberLogTags
import timber.log.Timber
object NavigationDestinationLogger {
fun logDestinationChange(
destination: NavDestination,
arguments: Bundle?,
) {
val className = NavigationRoute::class.simpleName
val destinationRoute = destination.route?.substringAfter("$className.") ?: "Unknown"
val destinationId = destination.id
val destinationLabel = destination.label ?: "No Label"
val logMessage =
buildString {
appendLine("Navigation destination changed:")
appendLine("\tRoute: $destinationRoute")
appendLine("\tID: $destinationId")
appendLine("\tLabel: $destinationLabel")
arguments?.let { bundle ->
if (!bundle.isEmpty) {
appendLine(" Arguments:")
bundle.keySet().forEach { key ->
val value = getValueFromBundle(bundle, key) ?: "null"
appendLine("\t\t$key: $value")
}
}
}
}
Timber.tag(TimberLogTags.NAVIGATION).d(logMessage)
}
/**
* Retrieves a value from Bundle using Android Navigation supported types.
* Navigation supports: String, Int, Long, Float, Boolean, Parcelable, Serializable, and their arrays.
*
* @return String representation of the value, or null if no matching type found
*/
private fun getValueFromBundle(
bundle: Bundle,
key: String,
): String? =
bundle.getString(key)?.let { "\"$it\"" }
?: runCatching { bundle.getInt(key) }.getOrNull()?.toString()
?: runCatching { bundle.getLong(key) }.getOrNull()?.toString()
?: runCatching { bundle.getFloat(key) }.getOrNull()?.toString()
?: runCatching { bundle.getBoolean(key) }.getOrNull()?.toString()
?: bundle.getStringArray(key)?.contentToString()
?: bundle.getIntArray(key)?.contentToString()
?: bundle.getLongArray(key)?.contentToString()
?: bundle.getFloatArray(key)?.contentToString()
?: bundle.getBooleanArray(key)?.contentToString()
}
================================================
FILE: app/src/main/res/drawable/ic_favorite.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="m480,840 l-58,-52q-101,-91 -167,-157T150,512.5Q111,460 95.5,416T80,326q0,-94 63,-157t157,-63q52,0 99,22t81,62q34,-40 81,-62t99,-22q94,0 157,63t63,157q0,46 -15.5,90T810,512.5Q771,565 705,631T538,788l-58,52ZM480,732q96,-86 158,-147.5t98,-107q36,-45.5 50,-81t14,-70.5q0,-60 -40,-100t-100,-40q-47,0 -87,26.5T518,280h-76q-15,-41 -55,-67.5T300,186q-60,0 -100,40t-40,100q0,35 14,70.5t50,81q36,45.5 98,107T480,732ZM480,459Z"
android:fillColor="#e3e3e3"/>
</vector>
================================================
FILE: app/src/main/res/drawable/ic_launcher_foreground.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="45.47177"
android:viewportHeight="45.47177"
android:tint="#FB3430">
<group android:translateX="10.735885"
android:translateY="10.735885">
<path
android:fillColor="#FF000000"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
</group>
</vector>
================================================
FILE: app/src/main/res/drawable/ic_launcher_foreground_themed.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="45.47177"
android:viewportHeight="45.47177">
<group android:translateX="10.735885"
android:translateY="10.735885">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
</group>
</vector>
================================================
FILE: app/src/main/res/drawable/ic_music_library.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M500,600q42,0 71,-29t29,-71v-220h120v-80L560,200v220q-13,-10 -28,-15t-32,-5q-42,0 -71,29t-29,71q0,42 29,71t71,29ZM320,720q-33,0 -56.5,-23.5T240,640v-480q0,-33 23.5,-56.5T320,80h480q33,0 56.5,23.5T880,160v480q0,33 -23.5,56.5T800,720L320,720ZM320,640h480v-480L320,160v480ZM160,880q-33,0 -56.5,-23.5T80,800v-560h80v560h560v80L160,880ZM320,160v480,-480Z"
android:fillColor="#e3e3e3"/>
</vector>
================================================
FILE: app/src/main/res/drawable/ic_settings.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="m370,880 l-16,-128q-13,-5 -24.5,-12T307,725l-119,50L78,585l103,-78q-1,-7 -1,-13.5v-27q0,-6.5 1,-13.5L78,375l110,-190 119,50q11,-8 23,-15t24,-12l16,-128h220l16,128q13,5 24.5,12t22.5,15l119,-50 110,190 -103,78q1,7 1,13.5v27q0,6.5 -2,13.5l103,78 -110,190 -118,-50q-11,8 -23,15t-24,12L590,880L370,880ZM440,800h79l14,-106q31,-8 57.5,-23.5T639,633l99,41 39,-68 -86,-65q5,-14 7,-29.5t2,-31.5q0,-16 -2,-31.5t-7,-29.5l86,-65 -39,-68 -99,42q-22,-23 -48.5,-38.5T533,266l-13,-106h-79l-14,106q-31,8 -57.5,23.5T321,327l-99,-41 -39,68 86,64q-5,15 -7,30t-2,32q0,16 2,31t7,30l-86,65 39,68 99,-42q22,23 48.5,38.5T427,694l13,106ZM482,620q58,0 99,-41t41,-99q0,-58 -41,-99t-99,-41q-59,0 -99.5,41T342,480q0,58 40.5,99t99.5,41ZM480,480Z"
android:fillColor="#e3e3e3"/>
</vector>
================================================
FILE: app/src/main/res/mipmap-anydpi/ic_launcher.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!-- References the base adaptive icon without monochrome support -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
================================================
FILE: app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!-- Base round adaptive icon without monochrome support -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
================================================
FILE: app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!-- Extends the base adaptive icon with monochrome support for Android 13+ -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground_themed"/>
</adaptive-icon>
================================================
FILE: app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<!-- Round adaptive icon with monochrome support for Android 13+ -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground_themed"/>
</adaptive-icon>
================================================
FILE: app/src/main/res/values/colors.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Splash Screen Colors -->
<color name="splash_background">#FFFFFF</color>
<color name="splash_icon_background">#3DDC84</color>
</resources>
================================================
FILE: app/src/main/res/values/ic_launcher_background.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>
================================================
FILE: app/src/main/res/values/strings.xml
================================================
<resources>
<string name="app_name">Showcase</string>
<string name="bottom_navigation_albums">Albums</string>
<string name="bottom_navigation_favorites">Favorites</string>
<string name="bottom_navigation_settings">Settings</string>
</resources>
================================================
FILE: app/src/main/res/values/styles.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Splash Screen theme for pre-Android 12 compatibility -->
<style name="Theme.Showcase.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="windowSplashScreenAnimationDuration">1000</item>
<item name="windowSplashScreenIconBackgroundColor">@color/splash_icon_background</item>
<item name="postSplashScreenTheme">@style/Theme.Showcase</item>
</style>
</resources>
================================================
FILE: app/src/main/res/xml/data_extraction_rules.xml
================================================
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
================================================
FILE: build-logic/build.gradle.kts
================================================
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
`kotlin-dsl`
}
group = "com.igorwojda.showcase.buildlogic"
/*
Configure the build-logic plugins to target JDK from version catalog
This matches the JDK used to build the project, and is not related to what is running on device.
*/
val javaVersion =
libs
.versions
.java
.get()
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(javaVersion)
}
jvmToolchain(javaVersion.toInt())
}
dependencies {
implementation(libs.android.gradlePlugin)
implementation(libs.kotlin.gradlePlugin)
implementation(libs.ksp.gradlePlugin)
implementation(libs.spotless.gradlePlugin)
implementation(libs.detekt.gradlePlugin)
implementation(libs.test.logger.gradlePlugin)
implementation(libs.compose.gradlePlugin)
implementation(libs.junit5.gradlePlugin)
implementation(libs.easy.launcher.gradlePlugin)
implementation(libs.about.libraries.gradlePlugin)
/*
Expose generated type-safe version catalogs accessors accessible from precompiled script plugins
e.g. add("implementation", libs.koin)
https://github.com/gradle/gradle/issues/15383
*/
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
}
tasks {
validatePlugins {
enableStricterValidation = true
failOnWarning = true
}
}
gradlePlugin {
plugins {
register("applicationConvention") {
id = "com.igorwojda.showcase.convention.application"
implementationClass = "com.igorwojda.showcase.buildlogic.ApplicationConventionPlugin"
}
register("featureConvention") {
id = "com.igorwojda.showcase.convention.feature"
implementationClass = "com.igorwojda.showcase.buildlogic.FeatureConventionPlugin"
}
register("libraryConvention") {
id = "com.igorwojda.showcase.convention.library"
implementationClass = "com.igorwojda.showcase.buildlogic.LibraryConventionPlugin"
}
register("kotlinConvention") {
id = "com.igorwojda.showcase.convention.kotlin"
implementationClass = "com.igorwojda.showcase.buildlogic.KotlinConventionPlugin"
}
register("testConvention") {
id = "com.igorwojda.showcase.convention.test"
implementationClass = "com.igorwojda.showcase.buildlogic.TestConventionPlugin"
}
register("testLibraryConvention") {
id = "com.igorwojda.showcase.convention.test.library"
implementationClass = "com.igorwojda.showcase.buildlogic.TestConventionLibraryPlugin"
}
register("spotlessConvention") {
id = "com.igorwojda.showcase.convention.spotless"
implementationClass = "com.igorwojda.showcase.buildlogic.SpotlessConventionPlugin"
}
register("detektConvention") {
id = "com.igorwojda.showcase.convention.detekt"
implementationClass = "com.igorwojda.showcase.buildlogic.DetektConventionPlugin"
}
register("easyLauncherConvention") {
id = "com.igorwojda.showcase.convention.easylauncher"
implementationClass = "com.igorwojda.showcase.buildlogic.EasyLauncherConventionPlugin"
}
register("aboutLibrariesConvention") {
id = "com.igorwojda.showcase.convention.aboutlibraries"
implementationClass = "com.igorwojda.showcase.buildlogic.AboutLibrariesConventionPlugin"
}
}
}
================================================
FILE: build-logic/settings.gradle.kts
================================================
rootProject.name = "build-logic"
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/AboutLibrariesConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.mikepenz.aboutlibraries.plugin.AboutLibrariesExtension
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class AboutLibrariesConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.mikepenz.aboutlibraries.plugin.android")
}
extensions.configure<AboutLibrariesExtension> {
library {
// Avoids duplicate entries in the generated about libraries screen
duplicationMode.set(DuplicateMode.MERGE)
}
collect {
all.set(true)
}
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ApplicationConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.android.build.api.dsl.ApplicationExtension
import com.igorwojda.showcase.buildlogic.config.JavaBuildConfig
import com.igorwojda.showcase.buildlogic.ext.debugImplementation
import com.igorwojda.showcase.buildlogic.ext.excludeLicenseAndMetaFiles
import com.igorwojda.showcase.buildlogic.ext.implementation
import com.igorwojda.showcase.buildlogic.ext.libs
import com.igorwojda.showcase.buildlogic.ext.versions
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
@Suppress("detekt.LongMethod")
class ApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.application")
apply("com.google.devtools.ksp")
apply("org.jetbrains.kotlin.plugin.compose")
apply<KotlinConventionPlugin>()
apply<SpotlessConventionPlugin>()
apply<EasyLauncherConventionPlugin>()
apply<AboutLibrariesConventionPlugin>()
}
extensions.configure<ApplicationExtension> {
compileSdk =
versions
.compile
.sdk
.get()
.toInt()
defaultConfig {
applicationId = "com.igorwojda.showcase"
minSdk =
versions
.min
.sdk
.get()
.toInt()
targetSdk =
versions
.target
.sdk
.get()
.toInt()
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled = true
vectorDrawables {
useSupportLibrary = true
}
}
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
compileOptions {
sourceCompatibility = JavaBuildConfig.JAVA_VERSION
targetCompatibility = JavaBuildConfig.JAVA_VERSION
}
packaging {
excludeLicenseAndMetaFiles()
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
dependencies {
implementation(libs.kotlin.reflect)
implementation(libs.core.ktx)
implementation(libs.timber)
implementation(libs.coroutines)
implementation(libs.material.material)
implementation(libs.compose.material)
implementation(libs.material.icons)
// Compose dependencies
implementation(platform(libs.compose.bom))
implementation(libs.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
implementation(libs.navigation.compose)
// Koin
implementation(platform(libs.koin.bom))
implementation(libs.bundles.koin)
implementation(libs.bundles.retrofit)
implementation(libs.viewmodel.ktx)
implementation(libs.core.splashscreen)
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/DetektConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import io.gitlab.arturbosch.detekt.Detekt
import org.gradle.api.Plugin
import org.gradle.api.Project
class DetektConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("io.gitlab.arturbosch.detekt")
repositories.mavenCentral()
val detektCheck =
tasks.register("detektCheck", Detekt::class.java) {
description = "Checks that sourcecode satisfies detekt rules."
autoCorrect = false
}
val detektApply =
tasks.register("detektApply", Detekt::class.java) {
description = "Applies code formatting rules to sourcecode in-place."
autoCorrect = true
}
listOf(detektCheck, detektApply).forEach { taskProvider ->
taskProvider.configure {
group = "verification"
parallel = true
ignoreFailures = false
setSource(file(rootDir))
// Custom detekt config
config.setFrom("$rootDir/detekt.yml")
// Use default configuration on top of custom config
buildUponDefaultConfig = true
// Runs detekt for all files in the Gradle project and all subprojects
include("**/*.kt", "**/*.kts")
exclude("**/resources/**", "**/build/**", "**/generated/**")
reports {
html.required.set(true)
xml.required.set(true)
}
}
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/EasyLauncherConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.project.starter.easylauncher.filter.ChromeLikeFilter
import com.project.starter.easylauncher.plugin.EasyLauncherExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@Suppress("detekt.LongMethod")
class EasyLauncherConventionPlugin : Plugin<Project> {
override fun apply(project: Project) {
with(project) {
with(pluginManager) {
apply("com.starter.easylauncher")
}
extensions.configure<EasyLauncherExtension> {
defaultFlavorNaming(true)
buildTypes.create("debug") {
setFilters(
chromeLike(
ribbonColor = OVERLAY_COLOR_BACKGROUND_DEBUG,
labelColor = OVERLAY_COLOR_TEXT,
gravity = ChromeLikeFilter.Gravity.BOTTOM,
overlayHeight = OVERLAY_HEIGHT,
textSizeRatio = 0.2F,
),
)
}
}
}
}
companion object {
private const val OVERLAY_COLOR_BACKGROUND_DEBUG = "#99AD0000"
private const val OVERLAY_COLOR_TEXT = "#FFFFFF"
private const val OVERLAY_HEIGHT = 0.25F
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/FeatureConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.android.build.api.dsl.LibraryExtension
import com.igorwojda.showcase.buildlogic.config.JavaBuildConfig
import com.igorwojda.showcase.buildlogic.ext.debugImplementation
import com.igorwojda.showcase.buildlogic.ext.excludeLicenseAndMetaFiles
import com.igorwojda.showcase.buildlogic.ext.implementation
import com.igorwojda.showcase.buildlogic.ext.ksp
import com.igorwojda.showcase.buildlogic.ext.libs
import com.igorwojda.showcase.buildlogic.ext.testImplementation
import com.igorwojda.showcase.buildlogic.ext.testRuntimeOnly
import com.igorwojda.showcase.buildlogic.ext.versions
import com.mikepenz.aboutlibraries.plugin.AboutLibrariesPlugin
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
@Suppress("detekt.LongMethod")
class FeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply<KotlinConventionPlugin>()
apply<TestConventionPlugin>()
apply<AboutLibrariesPlugin>()
apply("com.google.devtools.ksp")
apply("org.jetbrains.kotlin.plugin.compose")
}
extensions.configure<LibraryExtension> {
compileSdk =
versions
.compile
.sdk
.get()
.toInt()
defaultConfig {
minSdk =
versions
.min
.sdk
.get()
.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
compileOptions {
sourceCompatibility = JavaBuildConfig.JAVA_VERSION
targetCompatibility = JavaBuildConfig.JAVA_VERSION
}
testOptions {
unitTests.isReturnDefaultValues = true
}
packaging {
excludeLicenseAndMetaFiles()
}
}
dependencies {
// Add feature:base dependency only for non-base feature modules
if (project.path != ":feature:base") {
implementation(project(":feature:base"))
}
implementation(libs.kotlin.reflect)
implementation(libs.core.ktx)
implementation(libs.timber)
implementation(libs.coroutines)
implementation(libs.material.material)
implementation(libs.compose.material)
implementation(libs.material.icons)
// Compose dependencies
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
// Koin
implementation(platform(libs.koin.bom))
implementation(libs.bundles.koin)
implementation(libs.bundles.retrofit)
implementation(libs.viewmodel.ktx)
// Room
implementation(libs.bundles.room)
ksp(libs.room.compiler)
// Test dependencies
testImplementation(project(":library:test-utils"))
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.jupiter.engine)
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/KotlinConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.igorwojda.showcase.buildlogic.config.JavaBuildConfig
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
class KotlinConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.plugin.serialization")
}
kotlinExtension.jvmToolchain(JavaBuildConfig.JVM_TOOLCHAIN_VERSION)
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll(
/*
This ensures annotations on data class constructor parameters are applied to both
the parameter and the backing field, preventing future breaking changes.
See https://youtrack.jetbrains.com/issue/KT-73255: for more details.
*/
"-Xannotation-default-target=param-property",
)
}
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/LibraryConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.android.build.api.dsl.LibraryExtension
import com.igorwojda.showcase.buildlogic.config.JavaBuildConfig
import com.igorwojda.showcase.buildlogic.ext.excludeLicenseAndMetaFiles
import com.igorwojda.showcase.buildlogic.ext.versions
import com.mikepenz.aboutlibraries.plugin.AboutLibrariesPlugin
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
class LibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply<KotlinConventionPlugin>()
apply<TestConventionPlugin>()
apply<AboutLibrariesPlugin>()
apply("com.google.devtools.ksp")
apply("org.jetbrains.kotlin.plugin.compose")
}
extensions.configure<LibraryExtension> {
compileSdk =
versions
.compile
.sdk
.get()
.toInt()
defaultConfig {
minSdk =
versions
.min
.sdk
.get()
.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
compileOptions {
sourceCompatibility = JavaBuildConfig.JAVA_VERSION
targetCompatibility = JavaBuildConfig.JAVA_VERSION
}
testOptions {
unitTests.isReturnDefaultValues = true
}
packaging {
excludeLicenseAndMetaFiles()
}
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/SpotlessConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.diffplug.gradle.spotless.SpotlessExtension
import com.igorwojda.showcase.buildlogic.ext.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class SpotlessConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.diffplug.spotless")
extensions.configure<SpotlessExtension> {
kotlin {
target("**/*.kt", "**/*.kts")
// Some rules are disabled in .editorconfig to avoid conflicts with detekt
val customRuleSets =
listOf(
libs.ktlint.ruleset.standard,
libs.nlopez.compose.rules,
libs.twitter.compose.rules,
).map {
it.get().toString()
}
ktlint()
.customRuleSets(customRuleSets)
endWithNewline()
}
// Don't add spotless as dependency for the Gradle's check task to facilitate separated codebase checks
isEnforceCheck = false
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/TestConventionLibraryPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.android.build.api.dsl.LibraryExtension
import com.igorwojda.showcase.buildlogic.config.JavaBuildConfig
import com.igorwojda.showcase.buildlogic.ext.versions
import com.mikepenz.aboutlibraries.plugin.AboutLibrariesPlugin
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
class TestConventionLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply<KotlinConventionPlugin>()
apply<TestConventionPlugin>()
apply<AboutLibrariesPlugin>()
apply("com.google.devtools.ksp")
}
extensions.configure<LibraryExtension> {
compileSdk =
versions
.compile
.sdk
.get()
.toInt()
defaultConfig {
minSdk =
versions
.min
.sdk
.get()
.toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
viewBinding = false
buildConfig = false
compose = false
}
compileOptions {
sourceCompatibility = JavaBuildConfig.JAVA_VERSION
targetCompatibility = JavaBuildConfig.JAVA_VERSION
}
testOptions {
unitTests.isReturnDefaultValues = true
}
packaging {
resources.excludes +=
setOf(
"META-INF/AL2.0",
"META-INF/licenses/**",
"**/attach_hotspot_windows.dll",
"META-INF/LGPL2.1",
)
}
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/TestConventionPlugin.kt
================================================
package com.igorwojda.showcase.buildlogic
import com.adarshr.gradle.testlogger.TestLoggerExtension
import com.adarshr.gradle.testlogger.theme.ThemeType
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.withType
class TestConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.adarshr.test-logger")
tasks.withType<Test> {
useJUnitPlatform()
// Enable parallel test execution
systemProperties =
mapOf(
"junit.jupiter.execution.parallel.enabled" to "true",
"junit.jupiter.execution.parallel.mode.default " to "concurrent",
)
}
extensions.configure<TestLoggerExtension> {
theme = ThemeType.MOCHA
}
}
}
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/config/JavaBuildConfig.kt
================================================
package com.igorwojda.showcase.buildlogic.config
import org.gradle.api.JavaVersion
import java.io.File
object JavaBuildConfig {
/**
* Reads the Java version from the `gradle/libs.versions.toml` file.
* (VersionCatalogsExtension is not available at this stage).
*/
private val tomlJavaVersion by lazy {
File(System.getProperty("user.dir"))
.resolve("gradle/libs.versions.toml")
.readLines()
.firstOrNull { it.trim().startsWith("java") }
?.substringAfter("=")
?.trim('"', ' ')
?: error("❌ Could not find 'java' version in libs.versions.toml file")
}
/*
Configure the buildLogic config to target JDK from version catalog
This matches the JDK used to build the project.
*/
val JAVA_VERSION: JavaVersion = JavaVersion.toVersion(tomlJavaVersion)
val JVM_TOOLCHAIN_VERSION: Int = tomlJavaVersion.toInt()
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/BuildConfigExt.kt
================================================
package com.igorwojda.showcase.buildlogic.ext
import com.android.build.api.dsl.ApplicationDefaultConfig
import com.android.build.api.dsl.LibraryDefaultConfig
import org.gradle.api.Project
import java.util.Locale
/**
* Takes value from Gradle project property and sets it as Android build config property.
* Example: apiToken variable present in the settings.gradle file will be accessible
* as BuildConfig.GRADLE_API_TOKEN in the app.
*/
fun ApplicationDefaultConfig.buildConfigFieldFromGradleProperty(
project: Project,
gradlePropertyName: String,
) {
val (androidResourceName, propertyValue) = extractBuildConfigField(project, gradlePropertyName)
buildConfigField("String", androidResourceName, propertyValue)
}
/**
* Takes value from Gradle project property and sets it as Android build config property.
* Example: apiToken variable present in the settings.gradle file will be accessible
* as BuildConfig.GRADLE_API_TOKEN in the library.
*/
fun LibraryDefaultConfig.buildConfigFieldFromGradleProperty(
project: Project,
gradlePropertyName: String,
) {
val (androidResourceName, propertyValue) = extractBuildConfigField(project, gradlePropertyName)
buildConfigField("String", androidResourceName, propertyValue)
}
private fun extractBuildConfigField(
project: Project,
gradlePropertyName: String,
): Pair<String, String> {
val propertyValue = project.properties[gradlePropertyName] as? String
checkNotNull(propertyValue) { "Gradle property $gradlePropertyName is null" }
val androidResourceName = "GRADLE_${gradlePropertyName.toSnakeCase()}".uppercase(Locale.getDefault())
return androidResourceName to propertyValue
}
private fun String.toSnakeCase() = this.split(Regex("(?=[A-Z])")).joinToString("_") { it.lowercase(Locale.getDefault()) }
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/DependencyHandlerExt.kt
================================================
package com.igorwojda.showcase.buildlogic.ext
import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.DependencyHandlerScope
private const val IMPLEMENTATION = "implementation"
private const val DEBUG_IMPLEMENTATION = "debugImplementation"
private const val TEST_IMPLEMENTATION = "testImplementation"
private const val TEST_RUNTIME_ONLY = "testRuntimeOnly"
private const val KSP = "ksp"
fun DependencyHandlerScope.implementation(provider: Provider<out Any>) {
add(IMPLEMENTATION, provider)
}
fun DependencyHandlerScope.implementation(project: Project) {
add(IMPLEMENTATION, project)
}
fun DependencyHandlerScope.implementation(provider: LibrariesForLibs.KotlinLibraryAccessors) {
add(IMPLEMENTATION, provider)
}
fun DependencyHandlerScope.debugImplementation(provider: Provider<out Any>) {
add(DEBUG_IMPLEMENTATION, provider)
}
fun DependencyHandlerScope.ksp(provider: Provider<out Any>) {
add(KSP, provider)
}
fun DependencyHandlerScope.testImplementation(project: Project) {
add(TEST_IMPLEMENTATION, project)
}
fun DependencyHandlerScope.testImplementation(provider: Provider<out Any>) {
add(TEST_IMPLEMENTATION, provider)
}
fun DependencyHandlerScope.testRuntimeOnly(provider: Provider<out Any>) {
add(TEST_RUNTIME_ONLY, provider)
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/PackagingExt.kt
================================================
package com.igorwojda.showcase.buildlogic.ext
import com.android.build.api.dsl.Packaging
fun Packaging.excludeLicenseAndMetaFiles() {
resources.excludes +=
setOf(
"META-INF/AL2.0",
"META-INF/licenses/**",
"**/attach_hotspot_windows.dll",
"META-INF/LGPL2.1",
)
}
================================================
FILE: build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/ProjectExt.kt
================================================
package com.igorwojda.showcase.buildlogic.ext
import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.api.Project
import org.gradle.kotlin.dsl.the
/**
* Returns "libs" from version catalog.
*/
val Project.libs: LibrariesForLibs
get() = the<LibrariesForLibs>()
/**
* Returns "versions" from version catalog.
*/
val Project.versions: LibrariesForLibs.VersionAccessors
get() = the<LibrariesForLibs>().versions
================================================
FILE: build.gradle.kts
================================================
plugins {
// Convention plugins
id("com.igorwojda.showcase.convention.detekt")
id("com.igorwojda.showcase.convention.spotless")
// Core Android and Kotlin plugins using version catalog
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.symbol.processing) apply false
alias(libs.plugins.compose) apply false
alias(libs.plugins.test.logger) apply false
alias(libs.plugins.detekt) apply false
alias(libs.plugins.spotless) apply false
alias(libs.plugins.junit5.android) apply false
}
================================================
FILE: detekt.yml
================================================
build:
maxIssues: 0
excludeCorrectable: false
weights:
# complexity: 2
# LongParameterList: 1
# style: 1
# comments: 1
config:
validation: true
warningsAsErrors: true
checkExhaustiveness: true
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
excludes: ''
processors:
active: true
exclude:
- 'DetektProgressListener'
# - 'KtFileCountProcessor'
# - 'PackageCountProcessor'
# - 'ClassCountProcessor'
# - 'FunctionCountProcessor'
# - 'PropertyCountProcessor'
# - 'ProjectComplexityProcessor'
# - 'ProjectCognitiveComplexityProcessor'
# - 'ProjectLLOCProcessor'
# - 'ProjectCLOCProcessor'
# - 'ProjectLOCProcessor'
# - 'ProjectSLOCProcessor'
# - 'LicenseHeaderLoaderExtension'
console-reports:
active: true
exclude:
- 'ProjectStatisticsReport'
- 'ComplexityReport'
- 'NotificationReport'
- 'FindingsReport'
- 'FileBasedFindingsReport'
# - 'LiteFindingsReport'
output-reports:
active: true
exclude:
# - 'TxtOutputReport'
# - 'XmlOutputReport'
# - 'HtmlOutputReport'
# - 'MdOutputReport'
# - 'SarifOutputReport'
comments:
active: true
AbsentOrWrongFileLicense:
active: false
licenseTemplateFile: 'license.template'
licenseTemplateIsRegex: false
CommentOverPrivateFunction:
active: false
CommentOverPrivateProperty:
active: false
DeprecatedBlockTag:
active: true
EndOfSentenceFormat:
active: true
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
KDocReferencesNonPublicProperty:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
OutdatedDocumentation:
active: true
matchTypeParameters: true
matchDeclarationsOrder: true
allowParamOnConstructorProperties: false
UndocumentedPublicClass:
active: false
excludes: ['**/[Tt]est/**', '**/*Test.kt']
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
searchInProtectedClass: false
ignoreDefaultCompanionObject: false
UndocumentedPublicFunction:
active: false
excludes: ['**/[Tt]est/**', '**/*Test.kt']
searchProtectedFunction: false
UndocumentedPublicProperty:
active: false
excludes: ['**/[Tt]est/**', '**/*Test.kt']
searchProtectedProperty: false
complexity:
active: true
CognitiveComplexMethod:
active: true
threshold: 15
ComplexCondition:
active: true
threshold: 4
ComplexInterface:
active: true
threshold: 10
includeStaticDeclarations: false
includePrivateDeclarations: false
ignoreOverloaded: false
CyclomaticComplexMethod:
active: true
threshold: 15
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
nestingFunctions:
- 'also'
- 'apply'
- 'forEach'
- 'isNotNull'
- 'ifNull'
- 'let'
- 'run'
- 'use'
- 'with'
LabeledExpression:
active: true
ignoredLabels: []
LargeClass:
active: true
threshold: 600
LongMethod:
active: true
threshold: 60
LongParameterList:
active: true
functionThreshold: 6
constructorThreshold: 7
ignoreDefaultParameters: false
ignoreDataClasses: true
ignoreAnnotatedParameter: []
excludes: ['**/[Tt]est/**', '**/*Test.kt']
MethodOverloading:
active: true
threshold: 6
NamedArguments:
active: true
threshold: 3
ignoreArgumentsMatchingNames: false
NestedBlockDepth:
active: true
threshold: 4
NestedScopeFunctions:
active: true
threshold: 1
functions:
- 'kotlin.apply'
- 'kotlin.run'
- 'kotlin.with'
- 'kotlin.let'
- 'kotlin.also'
ReplaceSafeCallChainWithRun:
active: true
StringLiteralDuplication:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
threshold: 3
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignoreOverridden: false
ignoreAnnotatedFunctions: []
coroutines:
active: true
GlobalCoroutineUsage:
active: true
InjectDispatcher:
active: true
dispatcherNames:
- 'IO'
- 'Default'
- 'Unconfined'
RedundantSuspendModifier:
active: true
SleepInsteadOfDelay:
active: true
SuspendFunSwallowedCancellation:
active: true
SuspendFunWithCoroutineScopeReceiver:
active: true
SuspendFunWithFlowReturnType:
active: true
empty-blocks:
active: true
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: '_|(ignore|expected).*'
EmptyClassBlock:
active: true
EmptyDefaultConstructor:
active: true
EmptyDoWhileBlock:
active: true
EmptyElseBlock:
active: true
EmptyFinallyBlock:
active: true
EmptyForBlock:
active: true
EmptyFunctionBlock:
active: true
ignoreOverridden: false
EmptyIfBlock:
active: true
EmptyInitBlock:
active: true
EmptyKtFile:
active: true
EmptySecondaryConstructor:
active: true
EmptyTryBlock:
active: true
EmptyWhenBlock:
active: true
EmptyWhileBlock:
active: true
exceptions:
active: true
ExceptionRaisedInUnexpectedLocation:
active: true
methodNames:
- 'equals'
- 'finalize'
- 'hashCode'
- 'toString'
InstanceOfCheckForException:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
NotImplementedDeclaration:
active: true
ObjectExtendsThrowable:
active: true
PrintStackTrace:
active: true
RethrowCaughtException:
active: true
ReturnFromFinally:
active: true
ignoreLabeled: false
SwallowedException:
active: true
ignoredExceptionTypes:
- 'InterruptedException'
- 'MalformedURLException'
- 'NumberFormatException'
- 'ParseException'
allowedExceptionNameRegex: '_|(ignore|expected).*'
ThrowingExceptionFromFinally:
active: true
ThrowingExceptionInMain:
active: true
ThrowingExceptionsWithoutMessageOrCause:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
exceptions:
- 'ArrayIndexOutOfBoundsException'
- 'Exception'
- 'IllegalArgumentException'
- 'IllegalMonitorStateException'
- 'IllegalStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
ThrowingNewInstanceOfSameException:
active: true
TooGenericExceptionCaught:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
exceptionNames:
- 'ArrayIndexOutOfBoundsException'
- 'Error'
- 'Exception'
- 'IllegalMonitorStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
allowedExceptionNameRegex: '_|(ignore|expected).*'
TooGenericExceptionThrown:
active: true
exceptionNames:
- 'Error'
- 'Exception'
- 'RuntimeException'
- 'Throwable'
naming:
active: true
BooleanPropertyNaming:
active: true
allowedPattern: '^(is|has|are)'
ClassNaming:
active: true
classPattern: '[A-Z][a-zA-Z0-9]*'
ConstructorParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
EnumNaming:
active: true
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: true
forbiddenName: []
FunctionMaxLength:
active: false
maximumFunctionNameLength: 50
excludes: ['**/[Tt]est/**', '**/*Test.kt']
FunctionMinLength:
active: true
minimumFunctionNameLength: 3
FunctionNaming:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
functionPattern: '[a-z][a-zA-Z0-9]*'
excludeClassPattern: '$^'
ignoreAnnotated: ['Composable']
FunctionParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
InvalidPackageDeclaration:
active: true
rootPackage: ''
requireRootInDeclaration: false
LambdaParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*|_'
MatchingDeclarationName:
active: true
mustBeFirst: true
multiplatformTargets:
- 'ios'
- 'android'
- 'js'
- 'jvm'
- 'native'
- 'iosArm64'
- 'iosX64'
- 'macosX64'
- 'mingwX64'
- 'linuxX64'
MemberNameEqualsClassName:
active: true
ignoreOverridden: true
NoNameShadowing:
active: true
NonBooleanPropertyPrefixedWithIs:
active: true
ObjectPropertyNaming:
active: true
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
TopLevelPropertyNaming:
active: true
constantPattern: '[A-Z][_A-Z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
VariableMaxLength:
active: true
maximumVariableNameLength: 64
VariableMinLength:
active: true
minimumVariableNameLength: 1
VariableNaming:
active: true
variablePattern: '[a-z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
performance:
active: true
ArrayPrimitive:
active: true
CouldBeSequence:
active: true
threshold: 3
ForEachOnRange:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
SpreadOperator:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
UnnecessaryPartOfBinaryExpression:
active: true
UnnecessaryTemporaryInstantiation:
active: true
potential-bugs:
active: true
AvoidReferentialEquality:
active: true
forbiddenTypePatterns:
- 'kotlin.String'
CastNullableToNonNullableType:
active: true
CastToNullableType:
active: true
Deprecation:
active: true
DontDowncastCollectionTypes:
active: true
DoubleMutabilityForCollection:
active: true
mutableTypes:
- 'kotlin.collections.MutableList'
- 'kotlin.collections.MutableMap'
- 'kotlin.collections.MutableSet'
- 'java.util.ArrayList'
- 'java.util.LinkedHashSet'
- 'java.util.HashSet'
- 'java.util.LinkedHashMap'
- 'java.util.HashMap'
ElseCaseInsteadOfExhaustiveWhen:
active: true
ignoredSubjectTypes: []
EqualsAlwaysReturnsTrueOrFalse:
active: true
EqualsWithHashCodeExist:
active: true
ExitOutsideMain:
active: true
ExplicitGarbageCollectionCall:
active: true
HasPlatformType:
active: true
IgnoredReturnValue:
active: true
restrictToConfig: true
returnValueAnnotations:
- 'CheckResult'
- '*.CheckResult'
- 'CheckReturnValue'
- '*.CheckReturnValue'
ignoreReturnValueAnnotations:
- 'CanIgnoreReturnValue'
- '*.CanIgnoreReturnValue'
returnValueTypes:
- 'kotlin.sequences.Sequence'
- 'kotlinx.coroutines.flow.*Flow'
- 'java.util.stream.*Stream'
ignoreFunctionCall: []
ImplicitDefaultLocale:
active: true
ImplicitUnitReturnType:
active: true
allowExplicitReturnType: true
InvalidRange:
active: true
IteratorHasNextCallsNextMethod:
active: true
IteratorNotThrowingNoSuchElementException:
active: true
LateinitUsage:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
ignoreOnClassesPattern: ''
MapGetWithNotNullAssertionOperator:
active: true
MissingPackageDeclaration:
active: true
excludes: ['**/*.kts']
NullCheckOnMutableProperty:
active: true
NullableToStringCall:
active: true
PropertyUsedBeforeDeclaration:
active: true
UnconditionalJumpStatementInLoop:
active: true
UnnecessaryNotNullCheck:
active: true
UnnecessaryNotNullOperator:
active: true
UnnecessarySafeCall:
active: true
UnreachableCatchBlock:
active: true
UnreachableCode:
active: true
UnsafeCallOnNullableType:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
UnsafeCast:
active: true
UnusedUnaryOperator:
active: true
UselessPostfixExpression:
active: true
WrongEqualsTypeParameter:
active: true
style:
active: true
AlsoCouldBeApply:
active: true
BracesOnIfStatements:
active: true
singleLine: 'never'
multiLine: 'always'
BracesOnWhenStatements:
active: true
singleLine: 'necessary'
multiLine: 'consistent'
CanBeNonNullable:
active: true
CascadingCallWrapping:
active: false
includeElvis: true
ClassOrdering:
active: true
CollapsibleIfStatements:
active: true
DataClassContainsFunctions:
active: false
conversionFunctionPrefix:
- 'to'
allowOperators: false
DataClassShouldBeImmutable:
active: true
DestructuringDeclarationWithTooManyEntries:
active: true
maxDestructuringEntries: 3
DoubleNegativeLambda:
active: true
negativeFunctions:
- reason: 'Use `takeIf` instead.'
value: 'takeUnless'
- reason: 'Use `all` instead.'
value: 'none'
negativeFunctionNameParts:
- 'not'
- 'non'
EqualsNullCall:
active: true
EqualsOnSignatureLine:
active: true
ExplicitCollectionElementAccessMethod:
active: true
ExplicitItLambdaParameter:
active: true
ExpressionBodySyntax:
active: true
includeLineWrapping: false
ForbiddenAnnotation:
active: true
annotations:
- reason: 'it is a java annotation. Use `Suppress` instead.'
value: 'java.lang.SuppressWarnings'
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
value: 'java.lang.Deprecated'
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
value: 'java.lang.annotation.Documented'
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
value: 'java.lang.annotation.Target'
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
value: 'java.lang.annotation.Retention'
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
value: 'java.lang.annotation.Repeatable'
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
value: 'java.lang.annotation.Inherited'
ForbiddenComment:
active: true
comments:
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
value: 'FIXME:'
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
value: 'STOPSHIP:'
- reason: 'Forbidden TODO todo marker in comment, please do the changes.'
value: 'TODO:'
allowedPatterns: ''
ForbiddenImport:
active: true
imports: []
forbiddenPatterns: ''
ForbiddenMethodCall:
active: true
methods:
- reason: 'print does not allow you to configure the output stream. Use a logger instead.'
value: 'kotlin.io.print'
- reason: 'println does not allow you to configure the output stream. Use a logger instead.'
value: 'kotlin.io.println'
ForbiddenSuppress:
active: true
rules: []
ForbiddenVoid:
active: true
ignoreOverridden: false
ignoreUsageInGenerics: false
FunctionOnlyReturningConstant:
active: true
ignoreOverridableFunction: true
ignoreActualFunction: true
excludedFunctions: []
LoopWithTooManyJumpStatements:
active: true
maxJumpCount: 1
MagicNumber:
active: true
excludes: ['**/[Tt]est/**', '**/*Test.kt']
ignoreNumbers:
- '-1'
- '0'
- '1'
- '2'
- '60' # Common for seconds/minutes
- '300' # Common for milliseconds delay
- '1000' # Common for milliseconds
- '3600' # Seconds in hour
ignoreHashCodeFunction: true
ignorePropertyDeclaration: false
ignoreLocalVariableDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
ignoreRanges: false
ignoreExtensionFunctions: true
MandatoryBracesLoops:
active: true
MaxChainedCallsOnSameLine:
active: true
maxChainedCalls: 5
MaxLineLength:
active: true
maxLineLength: 140
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: false
excludeRawStrings: true
MayBeConst:
active: true
ModifierOrder:
active: true
MultilineLambdaItParameter:
active: true
MultilineRawStringIndentation:
active: true
indentSize: 4
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
NestedClassesVisibility:
active: true
NewLineAtEndOfFile:
active: true
NoTabs:
active: true
NullableBooleanCheck:
active: true
ObjectLiteralToLambda:
active: true
OptionalAbstractKeyword:
active: true
OptionalUnit:
active: true
PreferToOverPairSyntax:
active: true
ProtectedMemberInFinalClass:
active: true
RedundantExplicitType:
active: true
RedundantHigherOrderMapUsage:
active: true
RedundantVisibilityModifierRule:
active: true
ReturnCount:
active: true
max: 3
excludedFunctions:
- 'equals'
excludeLabeled: false
excludeReturnFromLambda: true
excludeGuardClauses: false
SafeCast:
active: true
SerialVersionUIDInSerializableClass:
active: true
SpacingBetweenPackageAndImports:
active: true
StringShouldBeRawString:
active: true
maxEscapedCharacterCount: 2
ignoredCharacters: []
ThrowsCount:
active: true
max: 2
excludeGuardClauses: false
TrailingWhitespace:
active: true
TrimMultilineRawString:
active: true
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
UnderscoresInNumericLiterals:
active: true
acceptableLength: 4
allowNonStandardGrouping: false
UnnecessaryAbstractClass:
active: true
UnnecessaryAnnotationUseSiteTarget:
active: true
UnnecessaryApply:
active: true
UnnecessaryBackticks:
active: true
UnnecessaryBracesAroundTrailingLambda:
active: true
UnnecessaryFilter:
active: true
UnnecessaryInheritance:
active: true
UnnecessaryInnerClass:
active: true
UnnecessaryLet:
active: true
UnnecessaryParentheses:
active: false
allowForUnclearPrecedence: false
UntilInsteadOfRangeTo:
active: true
UnusedImports:
active: true
UnusedParameter:
active: true
allowedNames: 'ignored|expected'
UnusedPrivateClass:
active: true
UnusedPrivateMember:
active: true
allowedNames: ''
ignoreAnnotated: ['Preview']
UnusedPrivateProperty:
active: true
allowedNames: '_|ignored|expected|serialVersionUID'
UseAnyOrNoneInsteadOfFind:
active: true
UseArrayLiteralsInAnnotations:
active: true
UseCheckNotNull:
active: true
UseCheckOrError:
active: true
UseDataClass:
active: true
allowVars: false
UseEmptyCounterpart:
active: true
UseIfEmptyOrIfBlank:
active: true
UseIfInsteadOfWhen:
active: true
ignoreWhenContainingVariableDeclaration: false
UseIsNullOrEmpty:
active: true
UseLet:
active: true
UseOrEmpty:
active: true
UseRequire:
active: true
UseRequireNotNull:
active: true
UseSumOfInsteadOfFlatMapSize:
active: true
UselessCallOnNotNull:
active: true
UtilityClassWithPublicConstructor:
active: true
VarCouldBeVal:
active: true
ignoreLateinitVar: false
WildcardImport:
active: true
excludeImports:
- 'java.util.*'
================================================
FILE: feature/album/build.gradle.kts
================================================
plugins {
id("com.igorwojda.showcase.convention.feature")
}
android {
namespace = "com.igorwojda.showcase.feature.album"
}
================================================
FILE: feature/album/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: feature/album/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest />
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/AlbumKoinModule.kt
================================================
package com.igorwojda.showcase.feature.album
import com.igorwojda.showcase.feature.album.data.dataModule
import com.igorwojda.showcase.feature.album.domain.domainModule
import com.igorwojda.showcase.feature.album.presentation.presentationModule
val featureAlbumModules =
listOf(
presentationModule,
domainModule,
dataModule,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/DataModule.kt
================================================
package com.igorwojda.showcase.feature.album.data
import androidx.room.Room
import com.igorwojda.showcase.feature.album.data.datasource.api.service.AlbumRetrofitService
import com.igorwojda.showcase.feature.album.data.datasource.database.AlbumDatabase
import com.igorwojda.showcase.feature.album.data.mapper.AlbumMapper
import com.igorwojda.showcase.feature.album.data.mapper.ImageMapper
import com.igorwojda.showcase.feature.album.data.mapper.ImageSizeMapper
import com.igorwojda.showcase.feature.album.data.mapper.TagMapper
import com.igorwojda.showcase.feature.album.data.mapper.TrackMapper
import com.igorwojda.showcase.feature.album.data.repository.AlbumRepositoryImpl
import com.igorwojda.showcase.feature.album.domain.repository.AlbumRepository
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import retrofit2.Retrofit
internal val dataModule =
module {
singleOf(::AlbumRepositoryImpl) { bind<AlbumRepository>() }
single { get<Retrofit>().create(AlbumRetrofitService::class.java) }
single {
Room
.databaseBuilder(
get(),
AlbumDatabase::class.java,
"Albums.db",
).build()
}
single { get<AlbumDatabase>().albums() }
singleOf(::ImageSizeMapper)
singleOf(::ImageMapper)
singleOf(::TrackMapper)
singleOf(::TagMapper)
singleOf(::AlbumMapper)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel
import com.igorwojda.showcase.feature.album.domain.model.Album
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class AlbumApiModel(
@SerialName("mbid") val mbId: String? = null,
@SerialName("name") val name: String,
@SerialName("artist") val artist: String,
@SerialName("image") val images: List<ImageApiModel>? = null,
@SerialName("tracks") val tracks: TrackListApiModel? = null,
@SerialName("tags") val tags: TagListApiModel? = null,
)
internal fun AlbumApiModel.toRoomModel() =
AlbumRoomModel(
mbId = this.mbId ?: "",
name = this.name,
artist = this.artist,
images = this.images?.mapNotNull { it.toRoomModel() } ?: listOf(),
tracks = this.tracks?.track?.map { it.toRoomModel() },
tags = this.tags?.tag?.map { it.toRoomModel() },
)
internal fun AlbumApiModel.toDomainModel(): Album {
val images =
this.images
?.filterNot { it.size == ImageSizeApiModel.UNKNOWN || it.url.isBlank() }
?.map { it.toDomainModel() }
return Album(
mbId = this.mbId,
name = this.name,
artist = this.artist,
images = images ?: listOf(),
tracks = this.tracks?.track?.map { it.toDomainModel() },
tags = this.tags?.tag?.map { it.toDomainModel() },
)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumListApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class AlbumListApiModel(
@SerialName("album") val album: List<AlbumApiModel>,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageRoomModel
import com.igorwojda.showcase.feature.album.domain.model.Image
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class ImageApiModel(
@SerialName("#text") val url: String,
@SerialName("size") val size: ImageSizeApiModel,
)
internal fun ImageApiModel.toDomainModel() =
Image(
url = this.url,
size = this.size.toDomainModel(),
)
internal fun ImageApiModel.toRoomModel() = this.size.toRoomModel()?.let { ImageRoomModel(this.url, it) }
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageSizeApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageSizeRoomModel
import com.igorwojda.showcase.feature.album.domain.enum.ImageSize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal enum class ImageSizeApiModel {
@SerialName("medium")
MEDIUM,
@SerialName("small")
SMALL,
@SerialName("large")
LARGE,
@SerialName("extralarge")
EXTRA_LARGE,
@SerialName("mega")
MEGA,
@SerialName("")
UNKNOWN,
}
internal fun ImageSizeApiModel.toDomainModel() = ImageSize.valueOf(this.name)
internal fun ImageSizeApiModel.toRoomModel(): ImageSizeRoomModel? =
when (this) {
ImageSizeApiModel.MEDIUM -> ImageSizeRoomModel.MEDIUM
ImageSizeApiModel.SMALL -> ImageSizeRoomModel.SMALL
ImageSizeApiModel.LARGE -> ImageSizeRoomModel.LARGE
ImageSizeApiModel.EXTRA_LARGE -> ImageSizeRoomModel.EXTRA_LARGE
ImageSizeApiModel.MEGA -> ImageSizeRoomModel.MEGA
ImageSizeApiModel.UNKNOWN -> null
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/SearchAlbumResultsApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class SearchAlbumResultsApiModel(
@SerialName("albummatches") val albumMatches: AlbumListApiModel,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TagApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import com.igorwojda.showcase.feature.album.data.datasource.database.model.TagRoomModel
import com.igorwojda.showcase.feature.album.domain.model.Tag
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class TagApiModel(
@SerialName("name") val name: String,
)
internal fun TagApiModel.toDomainModel() =
Tag(
name = this.name,
)
internal fun TagApiModel.toRoomModel() =
TagRoomModel(
name = this.name,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TagListApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class TagListApiModel(
@SerialName("tag") val tag: List<TagApiModel>,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TrackApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import com.igorwojda.showcase.feature.album.data.datasource.database.model.TrackRoomModel
import com.igorwojda.showcase.feature.album.domain.model.Track
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class TrackApiModel(
@SerialName("name") val name: String,
@SerialName("duration") val duration: Int? = null,
)
internal fun TrackApiModel.toDomainModel() =
Track(
name = this.name,
duration = this.duration,
)
internal fun TrackApiModel.toRoomModel() =
TrackRoomModel(
name = this.name,
duration = this.duration,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TrackListApiModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class TrackListApiModel(
@SerialName("track") val track: List<TrackApiModel>,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/response/GetAlbumInfoResponse.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.response
import com.igorwojda.showcase.feature.album.data.datasource.api.model.AlbumApiModel
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class GetAlbumInfoResponse(
@SerialName("album") val album: AlbumApiModel,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/response/SearchAlbumResponse.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.response
import com.igorwojda.showcase.feature.album.data.datasource.api.model.SearchAlbumResultsApiModel
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class SearchAlbumResponse(
@SerialName("results") val results: SearchAlbumResultsApiModel,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/service/AlbumRetrofitService.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.service
import com.igorwojda.showcase.feature.album.data.datasource.api.response.GetAlbumInfoResponse
import com.igorwojda.showcase.feature.album.data.datasource.api.response.SearchAlbumResponse
import com.igorwojda.showcase.feature.base.data.retrofit.ApiResult
import retrofit2.http.POST
import retrofit2.http.Query
internal interface AlbumRetrofitService {
@POST("./?method=album.search")
suspend fun searchAlbumAsync(
@Query("album") phrase: String?,
@Query("limit") limit: Int = 60,
): ApiResult<SearchAlbumResponse>
@POST("./?method=album.getInfo")
suspend fun getAlbumInfoAsync(
@Query("artist") artistName: String,
@Query("album") albumName: String,
@Query("mbid") mbId: String?,
): ApiResult<GetAlbumInfoResponse>
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/AlbumDao.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel
@Dao
internal interface AlbumDao {
@Query("SELECT * FROM albums")
suspend fun getAll(): List<AlbumRoomModel>
@Query("SELECT * FROM albums where artist = :artistName and name = :albumName and mbId = :mbId")
suspend fun getAlbum(
artistName: String,
albumName: String,
mbId: String?,
): AlbumRoomModel
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAlbums(albums: List<AlbumRoomModel>)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/AlbumDatabase.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel
@Database(entities = [AlbumRoomModel::class], version = 1, exportSchema = false)
internal abstract class AlbumDatabase : RoomDatabase() {
abstract fun albums(): AlbumDao
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/AlbumRoomModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.database.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import com.igorwojda.showcase.feature.album.domain.model.Album
import kotlinx.serialization.json.Json
@Entity(tableName = "albums")
@TypeConverters(
AlbumImageRoomTypeConverter::class,
AlbumTrackRoomTypeConverter::class,
AlbumTagRoomTypeConverter::class,
)
internal data class AlbumRoomModel(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val mbId: String,
val name: String,
val artist: String,
val images: List<ImageRoomModel> = listOf(),
val tracks: List<TrackRoomModel>?,
val tags: List<TagRoomModel>?,
)
internal fun AlbumRoomModel.toDomainModel() =
Album(
this.name,
this.artist,
this.mbId,
this.images.mapNotNull { it.toDomainModel() },
this.tracks?.map { it.toDomainModel() },
this.tags?.map { it.toDomainModel() },
)
internal class AlbumImageRoomTypeConverter {
@TypeConverter
fun stringToList(data: String?) = data?.let { Json.decodeFromString<List<ImageRoomModel>>(it) } ?: listOf()
@TypeConverter
fun listToString(someObjects: List<ImageRoomModel>): String = Json.encodeToString(someObjects)
}
internal class AlbumTrackRoomTypeConverter {
@TypeConverter
fun stringToList(data: String?) = data?.let { Json.decodeFromString<List<TrackRoomModel>>(it) } ?: listOf()
@TypeConverter
fun listToString(someObjects: List<TrackRoomModel>): String = Json.encodeToString(someObjects)
}
internal class AlbumTagRoomTypeConverter {
@TypeConverter
fun stringToList(data: String?) = data?.let { Json.decodeFromString<List<TagRoomModel>>(it) } ?: listOf()
@TypeConverter
fun listToString(someObjects: List<TagRoomModel>): String = Json.encodeToString(someObjects)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/ImageRoomModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.database.model
import com.igorwojda.showcase.feature.album.domain.model.Image
import kotlinx.serialization.Serializable
@Serializable
internal data class ImageRoomModel(
val url: String,
val size: ImageSizeRoomModel,
)
internal fun ImageRoomModel.toDomainModel() = this.size.toDomainModel()?.let { Image(this.url, it) }
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/ImageSizeRoomModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.database.model
import com.igorwojda.showcase.feature.album.domain.enum.ImageSize
internal enum class ImageSizeRoomModel {
MEDIUM,
SMALL,
LARGE,
EXTRA_LARGE,
MEGA,
}
internal fun ImageSizeRoomModel.toDomainModel(): ImageSize? =
when (this) {
ImageSizeRoomModel.MEDIUM -> ImageSize.MEDIUM
ImageSizeRoomModel.SMALL -> ImageSize.SMALL
ImageSizeRoomModel.LARGE -> ImageSize.LARGE
ImageSizeRoomModel.EXTRA_LARGE -> ImageSize.EXTRA_LARGE
ImageSizeRoomModel.MEGA -> ImageSize.MEGA
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/TagRoomModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.database.model
import com.igorwojda.showcase.feature.album.domain.model.Tag
import kotlinx.serialization.Serializable
@Serializable
internal data class TagRoomModel(
val name: String,
)
internal fun TagRoomModel.toDomainModel() =
Tag(
name = this.name,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/TrackRoomModel.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.database.model
import com.igorwojda.showcase.feature.album.domain.model.Track
import kotlinx.serialization.Serializable
@Serializable
internal data class TrackRoomModel(
val name: String,
val duration: Int? = null,
)
internal fun TrackRoomModel.toDomainModel() =
Track(
name = this.name,
duration = this.duration,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/AlbumMapper.kt
================================================
package com.igorwojda.showcase.feature.album.data.mapper
import com.igorwojda.showcase.feature.album.data.datasource.api.model.AlbumApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageSizeApiModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel
import com.igorwojda.showcase.feature.album.domain.model.Album
internal class AlbumMapper(
private val imageMapper: ImageMapper,
private val trackMapper: TrackMapper,
private val tagMapper: TagMapper,
) {
fun apiToRoom(apiModel: AlbumApiModel) =
AlbumRoomModel(
mbId = apiModel.mbId ?: "",
name = apiModel.name,
artist = apiModel.artist,
images = apiModel.images?.mapNotNull { imageMapper.apiToRoom(it) } ?: listOf(),
tracks = apiModel.tracks?.track?.map { trackMapper.apiToRoom(it) },
tags = apiModel.tags?.tag?.map { tagMapper.apiToRoom(it) },
)
fun apiToDomain(apiModel: AlbumApiModel): Album {
val images =
apiModel.images
?.filterNot { it.size == ImageSizeApiModel.UNKNOWN || it.url.isBlank() }
?.map { imageMapper.apiToDomain(it) }
return Album(
mbId = apiModel.mbId,
name = apiModel.name,
artist = apiModel.artist,
images = images ?: listOf(),
tracks = apiModel.tracks?.track?.map { trackMapper.apiToDomain(it) },
tags = apiModel.tags?.tag?.map { tagMapper.apiToDomain(it) },
)
}
fun roomToDomain(roomModel: AlbumRoomModel) =
Album(
roomModel.name,
roomModel.artist,
roomModel.mbId,
roomModel.images.mapNotNull { imageMapper.roomToDomain(it) },
roomModel.tracks?.map { trackMapper.roomToDomain(it) },
roomModel.tags?.map { tagMapper.roomToDomain(it) },
)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageMapper.kt
================================================
package com.igorwojda.showcase.feature.album.data.mapper
import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageApiModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageRoomModel
import com.igorwojda.showcase.feature.album.domain.model.Image
internal class ImageMapper(
private val imageSizeMapper: ImageSizeMapper,
) {
fun apiToDomain(apiModel: ImageApiModel) =
Image(
url = apiModel.url,
size = imageSizeMapper.apiToDomain(apiModel.size),
)
fun apiToRoom(apiModel: ImageApiModel) =
imageSizeMapper.apiToRoom(apiModel.size)?.let {
ImageRoomModel(apiModel.url, it)
}
fun roomToDomain(roomModel: ImageRoomModel) =
imageSizeMapper.roomToDomain(roomModel.size)?.let {
Image(roomModel.url, it)
}
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageSizeMapper.kt
================================================
package com.igorwojda.showcase.feature.album.data.mapper
import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageSizeApiModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageSizeRoomModel
import com.igorwojda.showcase.feature.album.domain.enum.ImageSize
internal class ImageSizeMapper {
fun apiToDomain(apiModel: ImageSizeApiModel): ImageSize = ImageSize.valueOf(apiModel.name)
fun apiToRoom(apiModel: ImageSizeApiModel): ImageSizeRoomModel? =
when (apiModel) {
ImageSizeApiModel.MEDIUM -> ImageSizeRoomModel.MEDIUM
ImageSizeApiModel.SMALL -> ImageSizeRoomModel.SMALL
ImageSizeApiModel.LARGE -> ImageSizeRoomModel.LARGE
ImageSizeApiModel.EXTRA_LARGE -> ImageSizeRoomModel.EXTRA_LARGE
ImageSizeApiModel.MEGA -> ImageSizeRoomModel.MEGA
ImageSizeApiModel.UNKNOWN -> null
}
fun roomToDomain(roomModel: ImageSizeRoomModel): ImageSize? =
when (roomModel) {
ImageSizeRoomModel.MEDIUM -> ImageSize.MEDIUM
ImageSizeRoomModel.SMALL -> ImageSize.SMALL
ImageSizeRoomModel.LARGE -> ImageSize.LARGE
ImageSizeRoomModel.EXTRA_LARGE -> ImageSize.EXTRA_LARGE
ImageSizeRoomModel.MEGA -> ImageSize.MEGA
}
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TagMapper.kt
================================================
package com.igorwojda.showcase.feature.album.data.mapper
import com.igorwojda.showcase.feature.album.data.datasource.api.model.TagApiModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.TagRoomModel
import com.igorwojda.showcase.feature.album.domain.model.Tag
internal class TagMapper {
fun apiToDomain(apiModel: TagApiModel): Tag =
Tag(
name = apiModel.name,
)
fun apiToRoom(apiModel: TagApiModel): TagRoomModel =
TagRoomModel(
name = apiModel.name,
)
fun roomToDomain(roomModel: TagRoomModel): Tag =
Tag(
name = roomModel.name,
)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TrackMapper.kt
================================================
package com.igorwojda.showcase.feature.album.data.mapper
import com.igorwojda.showcase.feature.album.data.datasource.api.model.TrackApiModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.TrackRoomModel
import com.igorwojda.showcase.feature.album.domain.model.Track
internal class TrackMapper {
fun apiToDomain(apiModel: TrackApiModel): Track =
Track(
name = apiModel.name,
duration = apiModel.duration,
)
fun apiToRoom(apiModel: TrackApiModel): TrackRoomModel =
TrackRoomModel(
name = apiModel.name,
duration = apiModel.duration,
)
fun roomToDomain(roomModel: TrackRoomModel): Track =
Track(
name = roomModel.name,
duration = roomModel.duration,
)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/repository/AlbumRepositoryImpl.kt
================================================
package com.igorwojda.showcase.feature.album.data.repository
import com.igorwojda.showcase.feature.album.data.datasource.api.service.AlbumRetrofitService
import com.igorwojda.showcase.feature.album.data.datasource.database.AlbumDao
import com.igorwojda.showcase.feature.album.data.mapper.AlbumMapper
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.album.domain.repository.AlbumRepository
import com.igorwojda.showcase.feature.base.data.retrofit.ApiResult
import com.igorwojda.showcase.feature.base.domain.result.Result
import timber.log.Timber
internal class AlbumRepositoryImpl(
private val albumRetrofitService: AlbumRetrofitService,
private val albumDao: AlbumDao,
private val albumMapper: AlbumMapper,
) : AlbumRepository {
override suspend fun searchAlbum(phrase: String?): Result<List<Album>> =
when (val apiResult = albumRetrofitService.searchAlbumAsync(phrase)) {
is ApiResult.Success -> {
val albums =
apiResult
.data
.results
.albumMatches
.album
.also { albumsApiModels ->
val albumsRoomModels = albumsApiModels.map { albumMapper.apiToRoom(it) }
albumDao.insertAlbums(albumsRoomModels)
}.map { albumMapper.apiToDomain(it) }
Result.Success(albums)
}
is ApiResult.Error -> {
Result.Failure()
}
is ApiResult.Exception -> {
Timber.e(apiResult.throwable)
val albums =
albumDao
.getAll()
.map { albumMapper.roomToDomain(it) }
Result.Success(albums)
}
}
override suspend fun getAlbumInfo(
artistName: String,
albumName: String,
mbId: String?,
): Result<Album> =
when (val apiResult = albumRetrofitService.getAlbumInfoAsync(artistName, albumName, mbId)) {
is ApiResult.Success -> {
val album =
apiResult
.data
.album
.let { albumMapper.apiToDomain(it) }
Result.Success(album)
}
is ApiResult.Error -> {
Result.Failure()
}
is ApiResult.Exception -> {
Timber.e(apiResult.throwable)
val album =
albumDao
.getAlbum(artistName, albumName, mbId)
.let { albumMapper.roomToDomain(it) }
Result.Success(album)
}
}
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/DomainModule.kt
================================================
package com.igorwojda.showcase.feature.album.domain
import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumListUseCase
import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumUseCase
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
internal val domainModule =
module {
singleOf(::GetAlbumListUseCase)
singleOf(::GetAlbumUseCase)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/enum/ImageSize.kt
================================================
package com.igorwojda.showcase.feature.album.domain.enum
internal enum class ImageSize {
SMALL,
MEDIUM,
LARGE,
EXTRA_LARGE,
MEGA,
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Album.kt
================================================
package com.igorwojda.showcase.feature.album.domain.model
import com.igorwojda.showcase.feature.album.domain.enum.ImageSize
// Images are loaded for both album list and album detail instance
// Tracks and Tags are only loaded for album detail instance (not album list instance)
internal data class Album(
val name: String,
val artist: String,
val mbId: String? = null,
val images: List<Image> = emptyList(),
val tracks: List<Track>? = null,
val tags: List<Tag>? = null,
) {
val id: String = "$artist - $name"
fun getDefaultImageUrl() = images.firstOrNull { it.size == ImageSize.EXTRA_LARGE }?.url
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Image.kt
================================================
package com.igorwojda.showcase.feature.album.domain.model
import com.igorwojda.showcase.feature.album.domain.enum.ImageSize
internal data class Image(
val url: String,
val size: ImageSize,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Tag.kt
================================================
package com.igorwojda.showcase.feature.album.domain.model
internal data class Tag(
val name: String,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Track.kt
================================================
package com.igorwojda.showcase.feature.album.domain.model
internal data class Track(
val name: String,
val duration: Int? = null,
)
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/repository/AlbumRepository.kt
================================================
package com.igorwojda.showcase.feature.album.domain.repository
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.base.domain.result.Result
internal interface AlbumRepository {
suspend fun getAlbumInfo(
artistName: String,
albumName: String,
mbId: String?,
): Result<Album>
suspend fun searchAlbum(phrase: String?): Result<List<Album>>
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumListUseCase.kt
================================================
package com.igorwojda.showcase.feature.album.domain.usecase
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.album.domain.repository.AlbumRepository
import com.igorwojda.showcase.feature.base.domain.result.Result
import com.igorwojda.showcase.feature.base.domain.result.mapSuccess
internal class GetAlbumListUseCase(
private val albumRepository: AlbumRepository,
) {
suspend operator fun invoke(query: String?): Result<List<Album>> {
val result =
albumRepository
.searchAlbum(query)
.mapSuccess {
val albumsWithImages = value.filter { it.getDefaultImageUrl() != null }
copy(value = albumsWithImages)
}
return result
}
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumUseCase.kt
================================================
package com.igorwojda.showcase.feature.album.domain.usecase
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.album.domain.repository.AlbumRepository
import com.igorwojda.showcase.feature.base.domain.result.Result
internal class GetAlbumUseCase(
private val albumRepository: AlbumRepository,
) {
suspend operator fun invoke(
artistName: String,
albumName: String,
mbId: String?,
): Result<Album> = albumRepository.getAlbumInfo(artistName, albumName, mbId)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/PresentationModule.kt
================================================
package com.igorwojda.showcase.feature.album.presentation
import coil.ImageLoader
import com.igorwojda.showcase.feature.album.presentation.screen.albumdetail.AlbumDetailViewModel
import com.igorwojda.showcase.feature.album.presentation.screen.albumlist.AlbumListViewModel
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
internal val presentationModule =
module {
// AlbumList
viewModelOf(::AlbumListViewModel)
singleOf(::ImageLoader)
// AlbumDetails
viewModelOf(::AlbumDetailViewModel)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/composable/SearchBarComposable.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.composable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import com.igorwojda.showcase.feature.album.R
import com.igorwojda.showcase.feature.base.common.res.Dimen
import kotlinx.coroutines.delay
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val minimumProductQuerySize = 1
val delayBeforeSubmittingQuery = 300L
var textFieldValue by remember(query) { mutableStateOf(TextFieldValue(query)) }
// Debounce search - only trigger search after user stops typing
LaunchedEffect(textFieldValue.text, onSearch, onQueryChange) {
if (textFieldValue.text.length >= minimumProductQuerySize) {
delay(delayBeforeSubmittingQuery)
onSearch(textFieldValue.text)
onQueryChange(textFieldValue.text)
} else if (textFieldValue.text.isEmpty()) {
// Immediately search when query is cleared
onSearch("")
onQueryChange("")
}
}
OutlinedTextField(
value = textFieldValue,
modifier =
modifier
.fillMaxWidth()
.padding(Dimen.spaceM),
onValueChange = { newValue ->
textFieldValue = newValue
},
placeholder = {
Text(stringResource(R.string.album_list_search_placeholder))
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
)
},
trailingIcon =
if (textFieldValue.text.isNotEmpty()) {
{
IconButton(
onClick = {
textFieldValue = TextFieldValue("")
onSearch("")
onQueryChange("")
},
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear search",
)
}
}
} else {
null
},
singleLine = true,
colors =
OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
focusedBorderColor = MaterialTheme.colorScheme.primary,
),
)
}
@Preview
@Composable
private fun SearchBarPreview() {
SearchBar(
query = "Sample query",
onQueryChange = { },
onSearch = { },
)
}
@Preview
@Composable
private fun SearchBarEmptyPreview() {
SearchBar(
query = "",
onQueryChange = { },
onSearch = { },
)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailAction.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseAction
internal sealed interface AlbumDetailAction : BaseAction<AlbumDetailUiState> {
object AlbumLoadStart : AlbumDetailAction {
override fun reduce(state: AlbumDetailUiState) = AlbumDetailUiState.Loading
}
class AlbumLoadSuccess(
private val album: Album,
) : AlbumDetailAction {
override fun reduce(state: AlbumDetailUiState) =
AlbumDetailUiState.Content(
artistName = album.artist,
albumName = album.name,
coverImageUrl = album.getDefaultImageUrl() ?: "",
tracks = album.tracks,
tags = album.tags,
)
}
object AlbumLoadFailure : AlbumDetailAction {
override fun reduce(state: AlbumDetailUiState) = AlbumDetailUiState.Error
}
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailScreen.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.igorwojda.showcase.feature.album.R
import com.igorwojda.showcase.feature.album.domain.model.Tag
import com.igorwojda.showcase.feature.album.domain.model.Track
import com.igorwojda.showcase.feature.album.presentation.util.TimeUtil
import com.igorwojda.showcase.feature.base.common.res.Dimen
import com.igorwojda.showcase.feature.base.presentation.compose.composable.ErrorAnim
import com.igorwojda.showcase.feature.base.presentation.compose.composable.LoadingIndicator
import com.igorwojda.showcase.feature.base.presentation.compose.composable.PlaceholderImage
import com.igorwojda.showcase.feature.base.presentation.compose.composable.TextTitleLarge
import com.igorwojda.showcase.feature.base.presentation.compose.composable.TextTitleMedium
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumDetailScreen(
albumName: String,
artistName: String,
albumMbId: String?,
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
) {
val viewModel: AlbumDetailViewModel = koinViewModel()
// Initialize the viewModel with args when the composable enters composition
LaunchedEffect(Unit) {
viewModel.onInit(albumName, artistName, albumMbId)
}
val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = albumName) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.album_detail_back),
)
}
},
)
},
) { innerPadding ->
when (val currentUiState = uiState) {
AlbumDetailUiState.Error -> {
ErrorAnim()
}
AlbumDetailUiState.Loading -> {
LoadingIndicator()
}
is AlbumDetailUiState.Content -> {
AlbumDetailContent(
content = currentUiState,
modifier = Modifier.padding(innerPadding),
)
}
}
}
}
@Composable
private fun AlbumDetailContent(
content: AlbumDetailUiState.Content,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.padding(horizontal = Dimen.screenContentPadding)
.verticalScroll(rememberScrollState()),
) {
ElevatedCard(
modifier =
Modifier
.padding(Dimen.spaceM)
.wrapContentSize()
.size(320.dp)
.align(CenterHorizontally),
) {
PlaceholderImage(
url = content.coverImageUrl,
contentDescription = stringResource(id = R.string.album_detail_cover_content_description),
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.height(Dimen.spaceL))
TextTitleLarge(text = content.albumName)
TextTitleMedium(text = content.artistName)
Spacer(modifier = Modifier.height(Dimen.spaceL))
if (content.tags?.isNotEmpty() == true) {
Tags(content.tags)
Spacer(modifier = Modifier.height(Dimen.spaceL))
}
if (content.tracks?.isNotEmpty() == true) {
TextTitleMedium(text = stringResource(id = R.string.album_detail_tracks))
Spacer(modifier = Modifier.height(Dimen.spaceS))
Tracks(content.tracks)
}
}
}
@Composable
private fun Tags(tags: List<Tag>?) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Dimen.spaceS),
verticalArrangement = Arrangement.spacedBy(Dimen.spaceS),
) {
tags?.forEach { tag ->
SuggestionChip(
onClick = { },
label = { Text(tag.name) },
)
}
}
}
@Composable
internal fun Tracks(tracks: List<Track>?) {
tracks?.forEach { track ->
TrackItem(track)
}
}
@Composable
internal fun TrackItem(track: Track) {
Row {
Icon(Icons.Outlined.Star, contentDescription = null)
Spacer(modifier = Modifier.width(Dimen.spaceS))
val text =
buildString {
append(track.name)
track.duration?.let { duration ->
append(" ${TimeUtil.formatTime(duration)}")
}
}
Text(text = text)
}
}
@Preview
@Composable
private fun TrackItemPreview() {
TrackItem(
track =
Track(
name = "Sample Track",
duration = 180, // 3 minutes in seconds
),
)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailUiState.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail
import androidx.compose.runtime.Immutable
import com.igorwojda.showcase.feature.album.domain.model.Tag
import com.igorwojda.showcase.feature.album.domain.model.Track
import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseState
@Immutable
internal sealed interface AlbumDetailUiState : BaseState {
@Immutable
data class Content(
val albumName: String = "",
val artistName: String = "",
val coverImageUrl: String = "",
val tracks: List<Track>? = emptyList(),
val tags: List<Tag>? = emptyList(),
) : AlbumDetailUiState
@Immutable
data object Loading : AlbumDetailUiState
@Immutable
data object Error : AlbumDetailUiState
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailViewModel.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail
import androidx.lifecycle.viewModelScope
import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumUseCase
import com.igorwojda.showcase.feature.base.domain.result.Result.Failure
import com.igorwojda.showcase.feature.base.domain.result.Result.Success
import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseViewModel
import kotlinx.coroutines.launch
internal class AlbumDetailViewModel(
private val getAlbumUseCase: GetAlbumUseCase,
) : BaseViewModel<AlbumDetailUiState, AlbumDetailAction>(AlbumDetailUiState.Loading) {
fun onInit(
albumName: String,
artistName: String,
albumMbId: String?,
) {
getAlbum(albumName, artistName, albumMbId)
}
private fun getAlbum(
albumName: String,
artistName: String,
albumMbId: String?,
) {
sendAction(AlbumDetailAction.AlbumLoadStart)
viewModelScope.launch {
getAlbumUseCase(artistName, albumName, albumMbId).also {
when (it) {
is Success -> {
sendAction(AlbumDetailAction.AlbumLoadSuccess(it.value))
}
is Failure -> {
sendAction(AlbumDetailAction.AlbumLoadFailure)
}
}
}
}
}
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListAction.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.screen.albumlist
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseAction
internal sealed interface AlbumListAction : BaseAction<AlbumListUiState> {
object AlbumListLoadStart : AlbumListAction {
override fun reduce(state: AlbumListUiState) = AlbumListUiState.Loading
}
class AlbumListLoadSuccess(
private val albums: List<Album>,
) : AlbumListAction {
override fun reduce(state: AlbumListUiState) = AlbumListUiState.Content(albums)
}
object AlbumListLoadFailure : AlbumListAction {
override fun reduce(state: AlbumListUiState) = AlbumListUiState.Error
}
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListScreen.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.screen.albumlist
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ElevatedCard
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.igorwojda.showcase.feature.album.R
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.album.presentation.composable.SearchBar
import com.igorwojda.showcase.feature.base.common.res.Dimen
import com.igorwojda.showcase.feature.base.presentation.compose.composable.ErrorAnim
import com.igorwojda.showcase.feature.base.presentation.compose.composable.LoadingIndicator
import com.igorwojda.showcase.feature.base.presentation.compose.composable.PlaceholderImage
import org.koin.androidx.compose.koinViewModel
@Composable
fun AlbumListScreen(
modifier: Modifier = Modifier,
onNavigateToAlbumDetail: ((artistName: String, albumName: String, albumMbId: String?) -> Unit)? = null,
) {
val viewModel: AlbumListViewModel = koinViewModel()
val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle()
var searchQuery by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.onInit()
}
Column(modifier = modifier.fillMaxSize()) {
// Search bar
SearchBar(
query = searchQuery,
onQueryChange = { newQuery ->
searchQuery = newQuery
},
onSearch = { query ->
if (query.isNotEmpty()) {
viewModel.onInit(query)
} else {
viewModel.onInit()
}
},
)
// Content
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when (val currentUiState = uiState) { // Extract to local variable for smart casting
AlbumListUiState.Error -> ErrorAnim()
AlbumListUiState.Loading -> LoadingIndicator()
is AlbumListUiState.Content -> AlbumListContent(currentUiState, onNavigateToAlbumDetail)
}
}
}
}
@Composable
private fun AlbumListContent(
uiState: AlbumListUiState.Content,
onNavigateToAlbumDetail: ((String, String, String?) -> Unit)?,
) {
AlbumGrid(
albums = uiState.albums,
onAlbumClick = { album ->
onNavigateToAlbumDetail?.invoke(album.artist, album.name, album.mbId)
},
)
}
@Composable
private fun AlbumGrid(
albums: List<Album>,
onAlbumClick: (Album) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(Dimen.imageSize),
) {
items(items = albums, key = { it.id }) { album ->
ElevatedCard(
modifier =
Modifier
.padding(Dimen.spaceS)
.wrapContentSize(),
onClick = { onAlbumClick(album) },
) {
PlaceholderImage(
url = album.getDefaultImageUrl(),
contentDescription = stringResource(id = R.string.album_detail_cover_content_description),
modifier = Modifier.size(Dimen.imageSize),
)
}
}
}
}
@Preview
@Composable
private fun AlbumGridPreview() {
val sampleAlbums =
listOf(
Album(
name = "Sample Album 1",
artist = "Sample Artist",
mbId = null,
images = emptyList(),
),
Album(
name = "Sample Album 2",
artist = "Sample Artist 2",
mbId = null,
images = emptyList(),
),
)
AlbumGrid(
albums = sampleAlbums,
onAlbumClick = { },
)
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListUiState.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.screen.albumlist
import androidx.compose.runtime.Immutable
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseState
@Immutable
internal sealed interface AlbumListUiState : BaseState {
@Immutable
data class Content(
val albums: List<Album>,
) : AlbumListUiState
@Immutable
data object Loading : AlbumListUiState
@Immutable
data object Error : AlbumListUiState
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListViewModel.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.screen.albumlist
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.igorwojda.showcase.feature.album.domain.usecase.GetAlbumListUseCase
import com.igorwojda.showcase.feature.base.domain.result.Result
import com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
internal class AlbumListViewModel(
private val savedStateHandle: SavedStateHandle,
private val getAlbumListUseCase: GetAlbumListUseCase,
) : BaseViewModel<AlbumListUiState, AlbumListAction>(AlbumListUiState.Loading) {
private var job: Job? = null
fun onInit(query: String? = (savedStateHandle[SAVED_QUERY_KEY] as? String) ?: DEFAULT_QUERY_NAME) {
getAlbumList(query)
}
private fun getAlbumList(query: String?) {
if (job != null) {
job?.cancel()
job = null
}
savedStateHandle[SAVED_QUERY_KEY] = query
sendAction(AlbumListAction.AlbumListLoadStart)
job =
viewModelScope.launch {
getAlbumListUseCase(query).also { result ->
val albumListAction =
when (result) {
is Result.Success -> {
AlbumListAction.AlbumListLoadSuccess(result.value)
}
is Result.Failure -> {
AlbumListAction.AlbumListLoadFailure
}
}
sendAction(albumListAction)
}
}
}
companion object {
const val DEFAULT_QUERY_NAME = "Jackson"
private const val SAVED_QUERY_KEY = "query"
}
}
================================================
FILE: feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/util/TimeUtil.kt
================================================
package com.igorwojda.showcase.feature.album.presentation.util
object TimeUtil {
/**
* provides a String representation of the given time.
* @return `seconds` in mm:ss format
*/
internal fun formatTime(seconds: Int): String {
val secondsInMinute = 60
val secondsInHour = 3600
@Suppress("detekt.ImplicitDefaultLocale")
return String.format("%02d:%02d", seconds % secondsInHour / secondsInMinute, seconds % secondsInMinute)
}
}
================================================
FILE: feature/album/src/main/res/values/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="album_list_search_hint">Search Album</string>
<string name="album_list_title">Albums</string>
<string name="album_list_search_placeholder">Search albums…</string>
<string name="album_detail_cover_content_description">Album Cover</string>
<string name="album_detail_tracks">Tracks</string>
<string name="album_detail_back">Back</string>
</resources>
================================================
FILE: feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/DataFixtures.kt
================================================
package com.igorwojda.showcase.feature.album.data
import com.igorwojda.showcase.feature.album.data.datasource.api.model.AlbumApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.AlbumListApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.ImageSizeApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.SearchAlbumResultsApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.TagApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.TagListApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.TrackApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.model.TrackListApiModel
import com.igorwojda.showcase.feature.album.data.datasource.api.response.SearchAlbumResponse
import com.igorwojda.showcase.feature.album.data.datasource.database.model.AlbumRoomModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageRoomModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.ImageSizeRoomModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.TagRoomModel
import com.igorwojda.showcase.feature.album.data.datasource.database.model.TrackRoomModel
object DataFixtures {
internal fun getAlbumsApiModel() =
listOf(
getAlbumApiModel("mbid1", "album1", "artist1"),
)
internal fun getAlbumsRoomModels() =
listOf(
getAlbumRoomModel(1, "mbid1", "album1", "artist1"),
getAlbumRoomModel(2, "mbid2", "album2", "artist2"),
)
internal fun getAlbumApiModel(
mbId: String = "mbId",
name: String = "album",
artist: String = "artist",
images: List<ImageApiModel>? = listOf(getImageModelApiModel()),
tracks: TrackListApiModel = TrackListApiModel(getTrackModelApiModel()),
tags: TagListApiModel = TagListApiModel(getTagModelApiModel()),
): AlbumApiModel =
AlbumApiModel(
mbId,
name,
artist,
images,
tracks,
tags,
)
internal fun getImageModelApiModel(
url: String = "url_${ImageSizeApiModel.EXTRA_LARGE}",
size: ImageSizeApiModel = ImageSizeApiModel.EXTRA_LARGE,
) = ImageApiModel(url, size)
private fun getTrackModelApiModel(
name: String = "track",
duration: Int? = 12,
) = listOf(TrackApiModel(name, duration))
private fun getTagModelApiModel(name: String = "tag") = listOf(TagApiModel(name))
private fun getAlbumRoomModel(
id: Int = 0,
mbId: String = "mbId",
name: String = "album",
artist: String = "artist",
images: List<ImageRoomModel> = listOf(getImageRoomModel()),
tracks: List<TrackRoomModel> = listOf(getTrackRoomModel()),
tags: List<TagRoomModel> = listOf(getTagRoomModel()),
): AlbumRoomModel =
AlbumRoomModel(
id,
mbId,
name,
artist,
images,
tracks,
tags,
)
private fun getImageRoomModel(
url: String = "url_${ImageSizeApiModel.EXTRA_LARGE}",
size: ImageSizeRoomModel = ImageSizeRoomModel.EXTRA_LARGE,
) = ImageRoomModel(url, size)
private fun getTrackRoomModel(
name: String = "track",
duration: Int = 12,
) = TrackRoomModel(name, duration)
private fun getTagRoomModel(name: String = "tag") = TagRoomModel(name)
object ApiResponse {
internal fun getSearchAlbum() =
SearchAlbumResponse(
SearchAlbumResultsApiModel(
AlbumListApiModel(getAlbumsApiModel()),
),
)
}
}
================================================
FILE: feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumApiModelTest.kt
================================================
package com.igorwojda.showcase.feature.album.data.datasource.api.model
import com.igorwojda.showcase.feature.album.data.DataFixtures
import com.igorwojda.showcase.feature.album.domain.enum.ImageSize
import com.igorwojda.showcase.feature.album.domain.model.Album
import com.igorwojda.showcase.feature.album.domain.model.Tag
import com.igorwojda.showcase.feature.album.domain.model.Track
import org.amshove.kluent.shouldBeEqualTo
import org.junit.jupiter.api.Test
class AlbumApiModelTest {
@Test
fun `data model with full data maps to AlbumDomainModel`() {
// given
val sut = DataFixtures.getAlbumApiModel()
// when
val domainModel = sut.toDomainModel()
// then
domainModel shouldBeEqualTo
Album(
sut.name,
sut.artist,
sut.mbId,
sut.images?.map { it.toDomainModel() } ?: listOf(),
sut.tracks?.track?.map { it.toDomainModel() },
sut.tags?.tag?.map { it.toDomainModel() },
)
}
@Test
fun `data model with missing data maps
gitextract_jwuhl568/ ├── .editorconfig ├── .github/ │ ├── stale.yml │ └── workflows/ │ ├── auto-approve.yml │ ├── check.yml │ ├── claude-code-review.yml │ └── claude.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DeveloperReadme.md ├── README.md ├── app/ │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ ├── debug/ │ │ ├── AndroidManifest.xml │ │ └── res/ │ │ └── xml/ │ │ └── network_security_config.xml │ └── main/ │ ├── AndroidManifest.xml │ ├── kotlin/ │ │ └── com/ │ │ └── igorwojda/ │ │ └── showcase/ │ │ └── app/ │ │ ├── AppKoinModule.kt │ │ ├── ShowcaseApplication.kt │ │ ├── data/ │ │ │ └── api/ │ │ │ ├── AuthenticationInterceptor.kt │ │ │ └── UserAgentInterceptor.kt │ │ └── presentation/ │ │ ├── BottomNavigationBar.kt │ │ ├── MainShowcaseActivity.kt │ │ ├── MainShowcaseScreen.kt │ │ ├── NavigationRoute.kt │ │ └── util/ │ │ └── NavigationDestinationLogger.kt │ └── res/ │ ├── drawable/ │ │ ├── ic_favorite.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_foreground_themed.xml │ │ ├── ic_music_library.xml │ │ └── ic_settings.xml │ ├── mipmap-anydpi/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── mipmap-anydpi-v33/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── xml/ │ └── data_extraction_rules.xml ├── build-logic/ │ ├── build.gradle.kts │ ├── settings.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── com/ │ └── igorwojda/ │ └── showcase/ │ └── buildlogic/ │ ├── AboutLibrariesConventionPlugin.kt │ ├── ApplicationConventionPlugin.kt │ ├── DetektConventionPlugin.kt │ ├── EasyLauncherConventionPlugin.kt │ ├── FeatureConventionPlugin.kt │ ├── KotlinConventionPlugin.kt │ ├── LibraryConventionPlugin.kt │ ├── SpotlessConventionPlugin.kt │ ├── TestConventionLibraryPlugin.kt │ ├── TestConventionPlugin.kt │ ├── config/ │ │ └── JavaBuildConfig.kt │ └── ext/ │ ├── BuildConfigExt.kt │ ├── DependencyHandlerExt.kt │ ├── PackagingExt.kt │ └── ProjectExt.kt ├── build.gradle.kts ├── detekt.yml ├── feature/ │ ├── album/ │ │ ├── build.gradle.kts │ │ ├── proguard-rules.pro │ │ └── src/ │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── igorwojda/ │ │ │ │ └── showcase/ │ │ │ │ └── feature/ │ │ │ │ └── album/ │ │ │ │ ├── AlbumKoinModule.kt │ │ │ │ ├── data/ │ │ │ │ │ ├── DataModule.kt │ │ │ │ │ ├── datasource/ │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ ├── model/ │ │ │ │ │ │ │ │ ├── AlbumApiModel.kt │ │ │ │ │ │ │ │ ├── AlbumListApiModel.kt │ │ │ │ │ │ │ │ ├── ImageApiModel.kt │ │ │ │ │ │ │ │ ├── ImageSizeApiModel.kt │ │ │ │ │ │ │ │ ├── SearchAlbumResultsApiModel.kt │ │ │ │ │ │ │ │ ├── TagApiModel.kt │ │ │ │ │ │ │ │ ├── TagListApiModel.kt │ │ │ │ │ │ │ │ ├── TrackApiModel.kt │ │ │ │ │ │ │ │ └── TrackListApiModel.kt │ │ │ │ │ │ │ ├── response/ │ │ │ │ │ │ │ │ ├── GetAlbumInfoResponse.kt │ │ │ │ │ │ │ │ └── SearchAlbumResponse.kt │ │ │ │ │ │ │ └── service/ │ │ │ │ │ │ │ └── AlbumRetrofitService.kt │ │ │ │ │ │ └── database/ │ │ │ │ │ │ ├── AlbumDao.kt │ │ │ │ │ │ ├── AlbumDatabase.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── AlbumRoomModel.kt │ │ │ │ │ │ ├── ImageRoomModel.kt │ │ │ │ │ │ ├── ImageSizeRoomModel.kt │ │ │ │ │ │ ├── TagRoomModel.kt │ │ │ │ │ │ └── TrackRoomModel.kt │ │ │ │ │ ├── mapper/ │ │ │ │ │ │ ├── AlbumMapper.kt │ │ │ │ │ │ ├── ImageMapper.kt │ │ │ │ │ │ ├── ImageSizeMapper.kt │ │ │ │ │ │ ├── TagMapper.kt │ │ │ │ │ │ └── TrackMapper.kt │ │ │ │ │ └── repository/ │ │ │ │ │ └── AlbumRepositoryImpl.kt │ │ │ │ ├── domain/ │ │ │ │ │ ├── DomainModule.kt │ │ │ │ │ ├── enum/ │ │ │ │ │ │ └── ImageSize.kt │ │ │ │ │ ├── model/ │ │ │ │ │ │ ├── Album.kt │ │ │ │ │ │ ├── Image.kt │ │ │ │ │ │ ├── Tag.kt │ │ │ │ │ │ └── Track.kt │ │ │ │ │ ├── repository/ │ │ │ │ │ │ └── AlbumRepository.kt │ │ │ │ │ └── usecase/ │ │ │ │ │ ├── GetAlbumListUseCase.kt │ │ │ │ │ └── GetAlbumUseCase.kt │ │ │ │ └── presentation/ │ │ │ │ ├── PresentationModule.kt │ │ │ │ ├── composable/ │ │ │ │ │ └── SearchBarComposable.kt │ │ │ │ ├── screen/ │ │ │ │ │ ├── albumdetail/ │ │ │ │ │ │ ├── AlbumDetailAction.kt │ │ │ │ │ │ ├── AlbumDetailScreen.kt │ │ │ │ │ │ ├── AlbumDetailUiState.kt │ │ │ │ │ │ └── AlbumDetailViewModel.kt │ │ │ │ │ └── albumlist/ │ │ │ │ │ ├── AlbumListAction.kt │ │ │ │ │ ├── AlbumListScreen.kt │ │ │ │ │ ├── AlbumListUiState.kt │ │ │ │ │ └── AlbumListViewModel.kt │ │ │ │ └── util/ │ │ │ │ └── TimeUtil.kt │ │ │ └── res/ │ │ │ └── values/ │ │ │ └── strings.xml │ │ └── test/ │ │ └── kotlin/ │ │ └── com/ │ │ └── igorwojda/ │ │ └── showcase/ │ │ └── feature/ │ │ └── album/ │ │ ├── data/ │ │ │ ├── DataFixtures.kt │ │ │ ├── datasource/ │ │ │ │ └── api/ │ │ │ │ └── model/ │ │ │ │ ├── AlbumApiModelTest.kt │ │ │ │ ├── ImageApiModelTest.kt │ │ │ │ └── ImageSizeApiModelTest.kt │ │ │ ├── mapper/ │ │ │ │ ├── AlbumMapperTest.kt │ │ │ │ ├── ImageMapperTest.kt │ │ │ │ ├── ImageSizeMapperTest.kt │ │ │ │ ├── TagMapperTest.kt │ │ │ │ └── TrackMapperTest.kt │ │ │ └── repository/ │ │ │ └── AlbumRepositoryImplTest.kt │ │ ├── domain/ │ │ │ ├── DomainFixtures.kt │ │ │ ├── model/ │ │ │ │ └── AlbumTest.kt │ │ │ └── usecase/ │ │ │ ├── GetAlbumListUseCaseTest.kt │ │ │ └── GetAlbumUseCaseTest.kt │ │ └── presentation/ │ │ └── screen/ │ │ ├── albumdetail/ │ │ │ └── AlbumDetailViewModelTest.kt │ │ └── albumlist/ │ │ └── AlbumListViewModelTest.kt │ ├── base/ │ │ ├── build.gradle.kts │ │ ├── consumer-rules.pro │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── igorwojda/ │ │ │ └── showcase/ │ │ │ └── feature/ │ │ │ └── base/ │ │ │ ├── common/ │ │ │ │ ├── delegate/ │ │ │ │ │ └── Observer.kt │ │ │ │ └── res/ │ │ │ │ └── Dimen.kt │ │ │ ├── data/ │ │ │ │ └── retrofit/ │ │ │ │ ├── ApiResult.kt │ │ │ │ ├── ApiResultAdapterFactory.kt │ │ │ │ ├── ApiResultCall.kt │ │ │ │ └── ApiResultCallAdapter.kt │ │ │ ├── domain/ │ │ │ │ └── result/ │ │ │ │ ├── Result.kt │ │ │ │ └── ResultExt.kt │ │ │ ├── presentation/ │ │ │ │ ├── compose/ │ │ │ │ │ └── composable/ │ │ │ │ │ ├── ErrorAnim.kt │ │ │ │ │ ├── Loading.kt │ │ │ │ │ ├── Lottie.kt │ │ │ │ │ ├── PlaceholderImage.kt │ │ │ │ │ ├── TextTitleLarge.kt │ │ │ │ │ ├── TextTitleMedium.kt │ │ │ │ │ └── UnderConstructionAnim.kt │ │ │ │ └── viewmodel/ │ │ │ │ ├── BaseAction.kt │ │ │ │ ├── BaseState.kt │ │ │ │ ├── BaseViewModel.kt │ │ │ │ └── StateTimeTravelDebugger.kt │ │ │ └── util/ │ │ │ └── TimberLogTags.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── ic_search.xml │ │ │ ├── image_placeholder_1.xml │ │ │ ├── image_placeholder_2.xml │ │ │ └── image_placeholder_3.xml │ │ ├── raw/ │ │ │ ├── lottie_building_screen.json │ │ │ └── lottie_error_screen.json │ │ └── values/ │ │ ├── color_palete.xml │ │ ├── ids.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── favourite/ │ │ ├── build.gradle.kts │ │ ├── proguard-rules.pro │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── kotlin/ │ │ └── com/ │ │ └── igorwojda/ │ │ └── showcase/ │ │ └── feature/ │ │ └── favourite/ │ │ ├── FavouriteKoinModule.kt │ │ ├── data/ │ │ │ └── DataModule.kt │ │ ├── domain/ │ │ │ └── DomainModule.kt │ │ └── presentation/ │ │ ├── PresentationModule.kt │ │ └── screen/ │ │ └── favourite/ │ │ └── FavouriteScreen.kt │ └── settings/ │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── kotlin/ │ │ │ └── com/ │ │ │ └── igorwojda/ │ │ │ └── showcase/ │ │ │ └── feature/ │ │ │ └── settings/ │ │ │ ├── SettingsKoinModule.kt │ │ │ ├── data/ │ │ │ │ └── DataModule.kt │ │ │ ├── domain/ │ │ │ │ └── DomainModule.kt │ │ │ └── presentation/ │ │ │ ├── PresentationModule.kt │ │ │ └── screen/ │ │ │ ├── aboutlibraries/ │ │ │ │ ├── AboutLibrariesAction.kt │ │ │ │ ├── AboutLibrariesScreen.kt │ │ │ │ ├── AboutLibrariesUiState.kt │ │ │ │ └── AboutLibrariesViewModel.kt │ │ │ └── settings/ │ │ │ ├── SettingsAction.kt │ │ │ ├── SettingsScreen.kt │ │ │ ├── SettingsUiState.kt │ │ │ └── SettingsViewModel.kt │ │ └── res/ │ │ └── values/ │ │ └── strings.xml │ └── test/ │ └── kotlin/ │ └── com/ │ └── igorwojda/ │ └── showcase/ │ └── feature/ │ └── settings/ │ └── presentation/ │ └── screen/ │ ├── aboutlibraries/ │ │ └── AboutLibrariesViewModelTest.kt │ └── settings/ │ └── SettingsViewModelTest.kt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── konsist-test/ │ ├── build.gradle.kts │ └── src/ │ └── test/ │ └── kotlin/ │ └── com/ │ └── igorwojda/ │ └── showcase/ │ └── konsisttest/ │ ├── AndroidKonsistTest.kt │ ├── CleanArchitectureKonsistTest.kt │ ├── GeneralKonsistTest.kt │ ├── ModuleKonsistTest.kt │ ├── TestKonsistTest.kt │ ├── UseCaseKonsistTest.kt │ └── ViewModelKonsistTest.kt ├── library/ │ └── test-utils/ │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── igorwojda/ │ └── showcase/ │ └── library/ │ └── testutils/ │ ├── CoroutinesTestDispatcherExtension.kt │ └── InstantTaskExecutorExtension.kt ├── renovate.json └── settings.gradle.kts
Condensed preview — 206 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,703K chars).
[
{
"path": ".editorconfig",
"chars": 255,
"preview": "root = true\n\n[*.{kt,kts}]\nend_of_line = lf\ninsert_final_newline = true\nmax_line_length = 140\nktlint_function_naming_igno"
},
{
"path": ".github/stale.yml",
"chars": 699,
"preview": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 60\n# Number of days of inactivity before a "
},
{
"path": ".github/workflows/auto-approve.yml",
"chars": 413,
"preview": "name: Auto Approve\n\non: pull_request_target\n\njobs:\n auto-approve:\n runs-on: ubuntu-latest\n permissions:\n pul"
},
{
"path": ".github/workflows/check.yml",
"chars": 4959,
"preview": "name: Check\n\non:\n push:\n branches: [ main ] # Just in case main was not up to date while merging PR\n pull_request:\n"
},
{
"path": ".github/workflows/claude-code-review.yml",
"chars": 1979,
"preview": "name: Claude Code Review\n\non:\n pull_request:\n types: [opened, synchronize]\n # Optional: Only run on specific file"
},
{
"path": ".github/workflows/claude.yml",
"chars": 1947,
"preview": "name: Claude Code\n\non:\n issue_comment:\n types: [created]\n pull_request_review_comment:\n types: [created]\n issue"
},
{
"path": ".gitignore",
"chars": 632,
"preview": "# Built application files\n*.apk\n*.ap_\n*.aab\n\n# Files for the ART/Dalvik VM\n*.dex\n\n# Java class files\n*.class\n\n# Generate"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 3219,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, w"
},
{
"path": "CONTRIBUTING.md",
"chars": 1516,
"preview": "# Contributing\n\nWe appreciate contributions of any kind - new contributions\nare welcome whether it's through bug reports"
},
{
"path": "DeveloperReadme.md",
"chars": 1675,
"preview": "# Developer Readme\n\n## Detekt\n\n- [Detekt configuration](https://detekt.dev/docs/introduction/configurations/) contains l"
},
{
"path": "README.md",
"chars": 28609,
"preview": "# 💎 Android Showcase 2.0\n\n[](https://kotlinlang.org)\n"
},
{
"path": "app/build.gradle.kts",
"chars": 945,
"preview": "import com.igorwojda.showcase.buildlogic.ext.buildConfigFieldFromGradleProperty\n\nplugins {\n id(\"com.igorwojda.showcas"
},
{
"path": "app/proguard-rules.pro",
"chars": 750,
"preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
},
{
"path": "app/src/debug/AndroidManifest.xml",
"chars": 204,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n <appli"
},
{
"path": "app/src/debug/res/xml/network_security_config.xml",
"chars": 573,
"preview": "<network-security-config>\n <domain-config cleartextTrafficPermitted=\"true\">\n <domain includeSubdomains=\"true\">"
},
{
"path": "app/src/main/AndroidManifest.xml",
"chars": 1342,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n <uses-"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/AppKoinModule.kt",
"chars": 3574,
"preview": "package com.igorwojda.showcase.app\n\nimport com.igorwojda.showcase.app.data.api.AuthenticationInterceptor\nimport com.igor"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/ShowcaseApplication.kt",
"chars": 1034,
"preview": "package com.igorwojda.showcase.app\n\nimport android.app.Application\nimport com.igorwojda.showcase.feature.album.featureAl"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/data/api/AuthenticationInterceptor.kt",
"chars": 691,
"preview": "package com.igorwojda.showcase.app.data.api\n\nimport okhttp3.Interceptor\nimport okhttp3.Response\n\nclass AuthenticationInt"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/data/api/UserAgentInterceptor.kt",
"chars": 897,
"preview": "package com.igorwojda.showcase.app.data.api\n\nimport com.igorwojda.showcase.app.BuildConfig\nimport okhttp3.Interceptor\nim"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/presentation/BottomNavigationBar.kt",
"chars": 3942,
"preview": "package com.igorwojda.showcase.app.presentation\n\nimport androidx.annotation.DrawableRes\nimport androidx.annotation.Strin"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/presentation/MainShowcaseActivity.kt",
"chars": 1533,
"preview": "package com.igorwojda.showcase.app.presentation\n\nimport android.os.Build\nimport android.os.Bundle\nimport androidx.activi"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/presentation/MainShowcaseScreen.kt",
"chars": 4078,
"preview": "package com.igorwojda.showcase.app.presentation\n\nimport android.os.Bundle\nimport androidx.compose.foundation.layout.fill"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/presentation/NavigationRoute.kt",
"chars": 551,
"preview": "package com.igorwojda.showcase.app.presentation\n\nimport kotlinx.serialization.Serializable\n\nsealed interface NavigationR"
},
{
"path": "app/src/main/kotlin/com/igorwojda/showcase/app/presentation/util/NavigationDestinationLogger.kt",
"chars": 2478,
"preview": "package com.igorwojda.showcase.app.presentation.util\n\nimport android.os.Bundle\nimport androidx.navigation.NavDestination"
},
{
"path": "app/src/main/res/drawable/ic_favorite.xml",
"chars": 679,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_launcher_foreground.xml",
"chars": 540,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"108dp\"\n android:height="
},
{
"path": "app/src/main/res/drawable/ic_launcher_foreground_themed.xml",
"chars": 506,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"108dp\"\n android:height="
},
{
"path": "app/src/main/res/drawable/ic_music_library.xml",
"chars": 613,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/drawable/ic_settings.xml",
"chars": 977,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"24dp\"\n android:height=\"24dp\"\n "
},
{
"path": "app/src/main/res/mipmap-anydpi/ic_launcher.xml",
"chars": 338,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- References the base adaptive icon without monochrome support -->\n<adaptive-i"
},
{
"path": "app/src/main/res/mipmap-anydpi/ic_launcher_round.xml",
"chars": 329,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Base round adaptive icon without monochrome support -->\n<adaptive-icon xmlns"
},
{
"path": "app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml",
"chars": 424,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Extends the base adaptive icon with monochrome support for Android 13+ -->\n<"
},
{
"path": "app/src/main/res/mipmap-anydpi-v33/ic_launcher_round.xml",
"chars": 413,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Round adaptive icon with monochrome support for Android 13+ -->\n<adaptive-ic"
},
{
"path": "app/src/main/res/values/colors.xml",
"chars": 206,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <!-- Splash Screen Colors -->\n <color name=\"splash_background\""
},
{
"path": "app/src/main/res/values/ic_launcher_background.xml",
"chars": 120,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <color name=\"ic_launcher_background\">#000000</color>\n</resources>"
},
{
"path": "app/src/main/res/values/strings.xml",
"chars": 262,
"preview": "<resources>\n <string name=\"app_name\">Showcase</string>\n\n <string name=\"bottom_navigation_albums\">Albums</string>\n "
},
{
"path": "app/src/main/res/values/styles.xml",
"chars": 623,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <!-- Splash Screen theme for pre-Android 12 compatibility -->\n "
},
{
"path": "app/src/main/res/xml/data_extraction_rules.xml",
"chars": 722,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n Sample data extraction rules file; uncomment and customize as necessary.\n "
},
{
"path": "build-logic/build.gradle.kts",
"chars": 3547,
"preview": "import org.jetbrains.kotlin.gradle.dsl.JvmTarget\n\nplugins {\n `kotlin-dsl`\n}\n\ngroup = \"com.igorwojda.showcase.buildlog"
},
{
"path": "build-logic/settings.gradle.kts",
"chars": 379,
"preview": "rootProject.name = \"build-logic\"\n\n@Suppress(\"UnstableApiUsage\")\ndependencyResolutionManagement {\n repositoriesMode.se"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/AboutLibrariesConventionPlugin.kt",
"chars": 867,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.mikepenz.aboutlibraries.plugin.AboutLibrariesExtension\nimport com."
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ApplicationConventionPlugin.kt",
"chars": 3901,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.android.build.api.dsl.ApplicationExtension\nimport com.igorwojda.sh"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/DetektConventionPlugin.kt",
"chars": 1778,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport io.gitlab.arturbosch.detekt.Detekt\nimport org.gradle.api.Plugin\nimport"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/EasyLauncherConventionPlugin.kt",
"chars": 1363,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.project.starter.easylauncher.filter.ChromeLikeFilter\nimport com.pr"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/FeatureConventionPlugin.kt",
"chars": 4058,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.android.build.api.dsl.LibraryExtension\nimport com.igorwojda.showca"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/KotlinConventionPlugin.kt",
"chars": 1312,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.igorwojda.showcase.buildlogic.config.JavaBuildConfig\nimport org.gr"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/LibraryConventionPlugin.kt",
"chars": 2176,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.android.build.api.dsl.LibraryExtension\nimport com.igorwojda.showca"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/SpotlessConventionPlugin.kt",
"chars": 1327,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.diffplug.gradle.spotless.SpotlessExtension\nimport com.igorwojda.sh"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/TestConventionLibraryPlugin.kt",
"chars": 2307,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.android.build.api.dsl.LibraryExtension\nimport com.igorwojda.showca"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/TestConventionPlugin.kt",
"chars": 1019,
"preview": "package com.igorwojda.showcase.buildlogic\n\nimport com.adarshr.gradle.testlogger.TestLoggerExtension\nimport com.adarshr.g"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/config/JavaBuildConfig.kt",
"chars": 934,
"preview": "package com.igorwojda.showcase.buildlogic.config\n\nimport org.gradle.api.JavaVersion\nimport java.io.File\n\nobject JavaBuil"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/BuildConfigExt.kt",
"chars": 1813,
"preview": "package com.igorwojda.showcase.buildlogic.ext\n\nimport com.android.build.api.dsl.ApplicationDefaultConfig\nimport com.andr"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/DependencyHandlerExt.kt",
"chars": 1369,
"preview": "package com.igorwojda.showcase.buildlogic.ext\n\nimport org.gradle.accessors.dm.LibrariesForLibs\nimport org.gradle.api.Pro"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/PackagingExt.kt",
"chars": 332,
"preview": "package com.igorwojda.showcase.buildlogic.ext\n\nimport com.android.build.api.dsl.Packaging\n\nfun Packaging.excludeLicenseA"
},
{
"path": "build-logic/src/main/kotlin/com/igorwojda/showcase/buildlogic/ext/ProjectExt.kt",
"chars": 432,
"preview": "package com.igorwojda.showcase.buildlogic.ext\n\nimport org.gradle.accessors.dm.LibrariesForLibs\nimport org.gradle.api.Pro"
},
{
"path": "build.gradle.kts",
"chars": 712,
"preview": "plugins {\n // Convention plugins\n id(\"com.igorwojda.showcase.convention.detekt\")\n id(\"com.igorwojda.showcase.co"
},
{
"path": "detekt.yml",
"chars": 20056,
"preview": "build:\n maxIssues: 0\n excludeCorrectable: false\n weights:\n # complexity: 2\n # LongParameterList: 1\n # style:"
},
{
"path": "feature/album/build.gradle.kts",
"chars": 132,
"preview": "plugins {\n id(\"com.igorwojda.showcase.convention.feature\")\n}\n\nandroid {\n namespace = \"com.igorwojda.showcase.featu"
},
{
"path": "feature/album/proguard-rules.pro",
"chars": 751,
"preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
},
{
"path": "feature/album/src/main/AndroidManifest.xml",
"chars": 52,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest />\n"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/AlbumKoinModule.kt",
"chars": 361,
"preview": "package com.igorwojda.showcase.feature.album\n\nimport com.igorwojda.showcase.feature.album.data.dataModule\nimport com.igo"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/DataModule.kt",
"chars": 1501,
"preview": "package com.igorwojda.showcase.feature.album.data\n\nimport androidx.room.Room\nimport com.igorwojda.showcase.feature.album"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumApiModel.kt",
"chars": 1526,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport com.igorwojda.showcase.feature.album.data"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumListApiModel.kt",
"chars": 267,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport kotlinx.serialization.SerialName\nimport k"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageApiModel.kt",
"chars": 689,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport com.igorwojda.showcase.feature.album.data"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageSizeApiModel.kt",
"chars": 1126,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport com.igorwojda.showcase.feature.album.data"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/SearchAlbumResultsApiModel.kt",
"chars": 288,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport kotlinx.serialization.SerialName\nimport k"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TagApiModel.kt",
"chars": 572,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport com.igorwojda.showcase.feature.album.data"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TagListApiModel.kt",
"chars": 259,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport kotlinx.serialization.SerialName\nimport k"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TrackApiModel.kt",
"chars": 709,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport com.igorwojda.showcase.feature.album.data"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/TrackListApiModel.kt",
"chars": 267,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport kotlinx.serialization.SerialName\nimport k"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/response/GetAlbumInfoResponse.kt",
"chars": 351,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.response\n\nimport com.igorwojda.showcase.feature.album.d"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/response/SearchAlbumResponse.kt",
"chars": 380,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.response\n\nimport com.igorwojda.showcase.feature.album.d"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/service/AlbumRetrofitService.kt",
"chars": 856,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.service\n\nimport com.igorwojda.showcase.feature.album.da"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/AlbumDao.kt",
"chars": 742,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.database\n\nimport androidx.room.Dao\nimport androidx.room.Ins"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/AlbumDatabase.kt",
"chars": 402,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.database\n\nimport androidx.room.Database\nimport androidx.roo"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/AlbumRoomModel.kt",
"chars": 1923,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.database.model\n\nimport androidx.room.Entity\nimport androidx"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/ImageRoomModel.kt",
"chars": 392,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.database.model\n\nimport com.igorwojda.showcase.feature.album"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/ImageSizeRoomModel.kt",
"chars": 607,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.database.model\n\nimport com.igorwojda.showcase.feature.album"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/TagRoomModel.kt",
"chars": 339,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.database.model\n\nimport com.igorwojda.showcase.feature.album"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/datasource/database/model/TrackRoomModel.kt",
"chars": 412,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.database.model\n\nimport com.igorwojda.showcase.feature.album"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/AlbumMapper.kt",
"chars": 1932,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageMapper.kt",
"chars": 862,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageSizeMapper.kt",
"chars": 1318,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TagMapper.kt",
"chars": 662,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TrackMapper.kt",
"chars": 815,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/data/repository/AlbumRepositoryImpl.kt",
"chars": 2834,
"preview": "package com.igorwojda.showcase.feature.album.data.repository\n\nimport com.igorwojda.showcase.feature.album.data.datasourc"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/DomainModule.kt",
"chars": 401,
"preview": "package com.igorwojda.showcase.feature.album.domain\n\nimport com.igorwojda.showcase.feature.album.domain.usecase.GetAlbum"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/enum/ImageSize.kt",
"chars": 153,
"preview": "package com.igorwojda.showcase.feature.album.domain.enum\n\ninternal enum class ImageSize {\n SMALL,\n MEDIUM,\n LAR"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Album.kt",
"chars": 634,
"preview": "package com.igorwojda.showcase.feature.album.domain.model\n\nimport com.igorwojda.showcase.feature.album.domain.enum.Image"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Image.kt",
"chars": 201,
"preview": "package com.igorwojda.showcase.feature.album.domain.model\n\nimport com.igorwojda.showcase.feature.album.domain.enum.Image"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Tag.kt",
"chars": 108,
"preview": "package com.igorwojda.showcase.feature.album.domain.model\n\ninternal data class Tag(\n val name: String,\n)\n"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/model/Track.kt",
"chars": 141,
"preview": "package com.igorwojda.showcase.feature.album.domain.model\n\ninternal data class Track(\n val name: String,\n val dura"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/repository/AlbumRepository.kt",
"chars": 427,
"preview": "package com.igorwojda.showcase.feature.album.domain.repository\n\nimport com.igorwojda.showcase.feature.album.domain.model"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumListUseCase.kt",
"chars": 804,
"preview": "package com.igorwojda.showcase.feature.album.domain.usecase\n\nimport com.igorwojda.showcase.feature.album.domain.model.Al"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumUseCase.kt",
"chars": 547,
"preview": "package com.igorwojda.showcase.feature.album.domain.usecase\n\nimport com.igorwojda.showcase.feature.album.domain.model.Al"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/PresentationModule.kt",
"chars": 605,
"preview": "package com.igorwojda.showcase.feature.album.presentation\n\nimport coil.ImageLoader\nimport com.igorwojda.showcase.feature"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/composable/SearchBarComposable.kt",
"chars": 3800,
"preview": "package com.igorwojda.showcase.feature.album.presentation.composable\n\nimport androidx.compose.foundation.layout.fillMaxW"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailAction.kt",
"chars": 1008,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail\n\nimport com.igorwojda.showcase.feature.albu"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailScreen.kt",
"chars": 6617,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail\n\nimport androidx.compose.foundation.layout."
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailUiState.kt",
"chars": 784,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail\n\nimport androidx.compose.runtime.Immutable\n"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailViewModel.kt",
"chars": 1409,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail\n\nimport androidx.lifecycle.viewModelScope\ni"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListAction.kt",
"chars": 761,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumlist\n\nimport com.igorwojda.showcase.feature.album."
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListScreen.kt",
"chars": 4702,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumlist\n\nimport androidx.compose.foundation.layout.Bo"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListUiState.kt",
"chars": 541,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumlist\n\nimport androidx.compose.runtime.Immutable\nim"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListViewModel.kt",
"chars": 1839,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumlist\n\nimport androidx.lifecycle.SavedStateHandle\ni"
},
{
"path": "feature/album/src/main/kotlin/com/igorwojda/showcase/feature/album/presentation/util/TimeUtil.kt",
"chars": 487,
"preview": "package com.igorwojda.showcase.feature.album.presentation.util\n\nobject TimeUtil {\n /**\n * provides a String repre"
},
{
"path": "feature/album/src/main/res/values/strings.xml",
"chars": 439,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"album_list_search_hint\">Search Album</string>\n <"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/DataFixtures.kt",
"chars": 3879,
"preview": "package com.igorwojda.showcase.feature.album.data\n\nimport com.igorwojda.showcase.feature.album.data.datasource.api.model"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/AlbumApiModelTest.kt",
"chars": 2538,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport com.igorwojda.showcase.feature.album.data"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageApiModelTest.kt",
"chars": 1064,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport com.igorwojda.showcase.feature.album.data"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/datasource/api/model/ImageSizeApiModelTest.kt",
"chars": 583,
"preview": "package com.igorwojda.showcase.feature.album.data.datasource.api.model\n\nimport org.junit.jupiter.api.Test\n\nclass ImageSi"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/AlbumMapperTest.kt",
"chars": 5978,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageMapperTest.kt",
"chars": 2146,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/ImageSizeMapperTest.kt",
"chars": 1490,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TagMapperTest.kt",
"chars": 1172,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/mapper/TrackMapperTest.kt",
"chars": 1298,
"preview": "package com.igorwojda.showcase.feature.album.data.mapper\n\nimport com.igorwojda.showcase.feature.album.data.datasource.ap"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/data/repository/AlbumRepositoryImplTest.kt",
"chars": 5639,
"preview": "package com.igorwojda.showcase.feature.album.data.repository\n\nimport com.igorwojda.showcase.feature.album.data.DataFixtu"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/domain/DomainFixtures.kt",
"chars": 1068,
"preview": "package com.igorwojda.showcase.feature.album.domain\n\nimport com.igorwojda.showcase.feature.album.domain.enum.ImageSize\ni"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/domain/model/AlbumTest.kt",
"chars": 732,
"preview": "package com.igorwojda.showcase.feature.album.domain.model\n\nimport com.igorwojda.showcase.feature.album.domain.DomainFixt"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumListUseCaseTest.kt",
"chars": 2294,
"preview": "package com.igorwojda.showcase.feature.album.domain.usecase\n\nimport com.igorwojda.showcase.feature.album.data.repository"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/domain/usecase/GetAlbumUseCaseTest.kt",
"chars": 1649,
"preview": "package com.igorwojda.showcase.feature.album.domain.usecase\n\nimport com.igorwojda.showcase.feature.album.data.repository"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumdetail/AlbumDetailViewModelTest.kt",
"chars": 2468,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumdetail\n\nimport com.igorwojda.showcase.feature.albu"
},
{
"path": "feature/album/src/test/kotlin/com/igorwojda/showcase/feature/album/presentation/screen/albumlist/AlbumListViewModelTest.kt",
"chars": 2156,
"preview": "package com.igorwojda.showcase.feature.album.presentation.screen.albumlist\n\nimport androidx.lifecycle.SavedStateHandle\ni"
},
{
"path": "feature/base/build.gradle.kts",
"chars": 131,
"preview": "plugins {\n id(\"com.igorwojda.showcase.convention.feature\")\n}\n\nandroid {\n namespace = \"com.igorwojda.showcase.featu"
},
{
"path": "feature/base/consumer-rules.pro",
"chars": 0,
"preview": ""
},
{
"path": "feature/base/proguard-rules.pro",
"chars": 750,
"preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
},
{
"path": "feature/base/src/main/AndroidManifest.xml",
"chars": 52,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest />\n"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/common/delegate/Observer.kt",
"chars": 516,
"preview": "package com.igorwojda.showcase.feature.base.common.delegate\n\nimport kotlin.properties.ObservableProperty\nimport kotlin.p"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/common/res/Dimen.kt",
"chars": 290,
"preview": "package com.igorwojda.showcase.feature.base.common.res\n\nimport androidx.compose.ui.unit.dp\n\nobject Dimen {\n val space"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/data/retrofit/ApiResult.kt",
"chars": 758,
"preview": "package com.igorwojda.showcase.feature.base.data.retrofit\n\nsealed interface ApiResult<T> {\n /**\n * Represents a n"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/data/retrofit/ApiResultAdapterFactory.kt",
"chars": 849,
"preview": "package com.igorwojda.showcase.feature.base.data.retrofit\n\nimport retrofit2.Call\nimport retrofit2.CallAdapter\nimport ret"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/data/retrofit/ApiResultCall.kt",
"chars": 2203,
"preview": "package com.igorwojda.showcase.feature.base.data.retrofit\n\nimport okhttp3.Request\nimport okio.Timeout\nimport retrofit2.C"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/data/retrofit/ApiResultCallAdapter.kt",
"chars": 392,
"preview": "package com.igorwojda.showcase.feature.base.data.retrofit\n\nimport retrofit2.Call\nimport retrofit2.CallAdapter\nimport jav"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/domain/result/Result.kt",
"chars": 252,
"preview": "package com.igorwojda.showcase.feature.base.domain.result\n\nsealed interface Result<out T> {\n data class Success<T>(\n "
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/domain/result/ResultExt.kt",
"chars": 253,
"preview": "package com.igorwojda.showcase.feature.base.domain.result\n\ninline fun <T> Result<T>.mapSuccess(crossinline onResult: Res"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/ErrorAnim.kt",
"chars": 719,
"preview": "package com.igorwojda.showcase.feature.base.presentation.compose.composable\n\nimport androidx.compose.foundation.layout.B"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/Loading.kt",
"chars": 914,
"preview": "package com.igorwojda.showcase.feature.base.presentation.compose.composable\n\nimport androidx.compose.foundation.layout.B"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/Lottie.kt",
"chars": 2115,
"preview": "package com.igorwojda.showcase.feature.base.presentation.compose.composable\n\nimport androidx.annotation.RawRes\nimport an"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/PlaceholderImage.kt",
"chars": 1659,
"preview": "package com.igorwojda.showcase.feature.base.presentation.compose.composable\n\nimport androidx.compose.material3.Surface\ni"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/TextTitleLarge.kt",
"chars": 615,
"preview": "package com.igorwojda.showcase.feature.base.presentation.compose.composable\n\nimport androidx.compose.material3.MaterialT"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/TextTitleMedium.kt",
"chars": 620,
"preview": "package com.igorwojda.showcase.feature.base.presentation.compose.composable\n\nimport androidx.compose.material3.MaterialT"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/compose/composable/UnderConstructionAnim.kt",
"chars": 445,
"preview": "package com.igorwojda.showcase.feature.base.presentation.compose.composable\n\nimport androidx.compose.runtime.Composable\n"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/viewmodel/BaseAction.kt",
"chars": 136,
"preview": "package com.igorwojda.showcase.feature.base.presentation.viewmodel\n\ninterface BaseAction<State> {\n fun reduce(state: "
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/viewmodel/BaseState.kt",
"chars": 88,
"preview": "package com.igorwojda.showcase.feature.base.presentation.viewmodel\n\ninterface BaseState\n"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/viewmodel/BaseViewModel.kt",
"chars": 1330,
"preview": "package com.igorwojda.showcase.feature.base.presentation.viewmodel\n\nimport androidx.lifecycle.ViewModel\nimport com.igorw"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/presentation/viewmodel/StateTimeTravelDebugger.kt",
"chars": 3206,
"preview": "package com.igorwojda.showcase.feature.base.presentation.viewmodel\n\nimport com.igorwojda.showcase.feature.base.util.Timb"
},
{
"path": "feature/base/src/main/kotlin/com/igorwojda/showcase/feature/base/util/TimberLogTags.kt",
"chars": 693,
"preview": "package com.igorwojda.showcase.feature.base.util\n\n/**\n * Centralized log tags for consistent logging throughout the appl"
},
{
"path": "feature/base/src/main/res/drawable/ic_search.xml",
"chars": 528,
"preview": "<vector android:height=\"24dp\" android:tint=\"#FFFFFF\"\n android:viewportHeight=\"24\" android:viewportWidth=\"24\"\n andr"
},
{
"path": "feature/base/src/main/res/drawable/image_placeholder_1.xml",
"chars": 1100,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"88dp\"\n android:height=\"88dp\"\n "
},
{
"path": "feature/base/src/main/res/drawable/image_placeholder_2.xml",
"chars": 1086,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"88dp\"\n android:height=\"88dp\"\n "
},
{
"path": "feature/base/src/main/res/drawable/image_placeholder_3.xml",
"chars": 988,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"88dp\"\n android:height=\"88dp\"\n "
},
{
"path": "feature/base/src/main/res/raw/lottie_building_screen.json",
"chars": 142018,
"preview": "{\n \"v\": \"4.11.0\",\n \"fr\": 29.9700012207031,\n \"ip\": 0,\n \"op\": 150.000006109625,\n \"w\": 800,\n \"h\": 800,\n "
},
{
"path": "feature/base/src/main/res/raw/lottie_error_screen.json",
"chars": 1177782,
"preview": "{\n \"v\": \"5.2.1\",\n \"fr\": 30,\n \"ip\": 0,\n \"op\": 30,\n \"w\": 296,\n \"h\": 296,\n \"nm\": \"error screen\",\n \""
},
{
"path": "feature/base/src/main/res/values/color_palete.xml",
"chars": 280,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <color name=\"outer_space\">#2B2E30</color>\n <color name=\"shark\""
},
{
"path": "feature/base/src/main/res/values/ids.xml",
"chars": 123,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <item name=\"tag_navigation_destination_id\" type=\"id\"/>\n</resource"
},
{
"path": "feature/base/src/main/res/values/strings.xml",
"chars": 163,
"preview": "<resources>\n <string name=\"common_under_construction\">Under construction</string>\n <string name=\"common_data_not_f"
},
{
"path": "feature/base/src/main/res/values/styles.xml",
"chars": 217,
"preview": "<resources>\n <style name=\"Theme.Showcase\" parent=\"Theme.Material3.DynamicColors.DayNight\">\n <item name=\"window"
},
{
"path": "feature/favourite/build.gradle.kts",
"chars": 136,
"preview": "plugins {\n id(\"com.igorwojda.showcase.convention.feature\")\n}\n\nandroid {\n namespace = \"com.igorwojda.showcase.featu"
},
{
"path": "feature/favourite/proguard-rules.pro",
"chars": 751,
"preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
},
{
"path": "feature/favourite/src/main/AndroidManifest.xml",
"chars": 52,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest />\n"
},
{
"path": "feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/FavouriteKoinModule.kt",
"chars": 381,
"preview": "package com.igorwojda.showcase.feature.favourite\n\nimport com.igorwojda.showcase.feature.favourite.data.dataModule\nimport"
},
{
"path": "feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/data/DataModule.kt",
"chars": 120,
"preview": "package com.igorwojda.showcase.feature.favourite.data\n\nimport org.koin.dsl.module\n\ninternal val dataModule = module { }\n"
},
{
"path": "feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/domain/DomainModule.kt",
"chars": 124,
"preview": "package com.igorwojda.showcase.feature.favourite.domain\n\nimport org.koin.dsl.module\n\ninternal val domainModule = module "
},
{
"path": "feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/presentation/PresentationModule.kt",
"chars": 136,
"preview": "package com.igorwojda.showcase.feature.favourite.presentation\n\nimport org.koin.dsl.module\n\ninternal val presentationModu"
},
{
"path": "feature/favourite/src/main/kotlin/com/igorwojda/showcase/feature/favourite/presentation/screen/favourite/FavouriteScreen.kt",
"chars": 740,
"preview": "package com.igorwojda.showcase.feature.favourite.presentation.screen.favourite\n\nimport androidx.compose.foundation.layou"
},
{
"path": "feature/settings/build.gradle.kts",
"chars": 201,
"preview": "plugins {\n id(\"com.igorwojda.showcase.convention.feature\")\n}\n\nandroid {\n namespace = \"com.igorwojda.showcase.featu"
},
{
"path": "feature/settings/proguard-rules.pro",
"chars": 751,
"preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
},
{
"path": "feature/settings/src/main/AndroidManifest.xml",
"chars": 52,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest />\n"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/SettingsKoinModule.kt",
"chars": 376,
"preview": "package com.igorwojda.showcase.feature.settings\n\nimport com.igorwojda.showcase.feature.settings.data.dataModule\nimport c"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/data/DataModule.kt",
"chars": 119,
"preview": "package com.igorwojda.showcase.feature.settings.data\n\nimport org.koin.dsl.module\n\ninternal val dataModule = module { }\n"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/domain/DomainModule.kt",
"chars": 123,
"preview": "package com.igorwojda.showcase.feature.settings.domain\n\nimport org.koin.dsl.module\n\ninternal val domainModule = module {"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/PresentationModule.kt",
"chars": 475,
"preview": "package com.igorwojda.showcase.feature.settings.presentation\n\nimport com.igorwojda.showcase.feature.settings.presentatio"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesAction.kt",
"chars": 241,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries\n\nimport com.igorwojda.showcase.featur"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesScreen.kt",
"chars": 2560,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries\n\nimport androidx.compose.foundation.l"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesUiState.kt",
"chars": 341,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries\n\nimport androidx.compose.runtime.Immu"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesViewModel.kt",
"chars": 296,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries\n\nimport com.igorwojda.showcase.featur"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsAction.kt",
"chars": 223,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.settings\n\nimport com.igorwojda.showcase.feature.base"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsScreen.kt",
"chars": 4284,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.settings\n\nimport androidx.compose.foundation.clickab"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsUiState.kt",
"chars": 323,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.settings\n\nimport androidx.compose.runtime.Immutable\n"
},
{
"path": "feature/settings/src/main/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsViewModel.kt",
"chars": 266,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.settings\n\nimport com.igorwojda.showcase.feature.base"
},
{
"path": "feature/settings/src/main/res/values/strings.xml",
"chars": 638,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <string name=\"settings_screen_title\">Settings</string>\n <strin"
},
{
"path": "feature/settings/src/test/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/aboutlibraries/AboutLibrariesViewModelTest.kt",
"chars": 864,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.aboutlibraries\n\nimport com.igorwojda.showcase.librar"
},
{
"path": "feature/settings/src/test/kotlin/com/igorwojda/showcase/feature/settings/presentation/screen/settings/SettingsViewModelTest.kt",
"chars": 840,
"preview": "package com.igorwojda.showcase.feature.settings.presentation.screen.settings\n\nimport com.igorwojda.showcase.library.test"
},
{
"path": "gradle/libs.versions.toml",
"chars": 8296,
"preview": "[versions]\nkotlin = \"2.3.20\"\n\n# KSP depends on specific Kotlin version, so it must be upgraded together with Kotlin (dis"
},
{
"path": "gradle/wrapper/gradle-wrapper.properties",
"chars": 252,
"preview": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributi"
},
{
"path": "gradle.properties",
"chars": 1661,
"preview": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will ov"
},
{
"path": "gradlew",
"chars": 8739,
"preview": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"Lice"
},
{
"path": "gradlew.bat",
"chars": 2966,
"preview": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (th"
},
{
"path": "konsist-test/build.gradle.kts",
"chars": 370,
"preview": "plugins {\n id(\"com.igorwojda.showcase.convention.test.library\")\n}\n\nandroid {\n namespace = \"com.igorwojda.showcase."
},
{
"path": "konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/AndroidKonsistTest.kt",
"chars": 611,
"preview": "package com.igorwojda.showcase.konsisttest\n\nimport androidx.lifecycle.ViewModel\nimport com.lemonappdev.konsist.api.Konsi"
},
{
"path": "konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/CleanArchitectureKonsistTest.kt",
"chars": 1025,
"preview": "package com.igorwojda.showcase.konsisttest\n\nimport com.lemonappdev.konsist.api.Konsist\nimport com.lemonappdev.konsist.ap"
},
{
"path": "konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/GeneralKonsistTest.kt",
"chars": 1267,
"preview": "package com.igorwojda.showcase.konsisttest\n\nimport com.lemonappdev.konsist.api.Konsist\nimport com.lemonappdev.konsist.ap"
},
{
"path": "konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/ModuleKonsistTest.kt",
"chars": 794,
"preview": "package com.igorwojda.showcase.konsisttest\n\nimport com.lemonappdev.konsist.api.Konsist\nimport com.lemonappdev.konsist.ap"
},
{
"path": "konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/TestKonsistTest.kt",
"chars": 565,
"preview": "package com.igorwojda.showcase.konsisttest\n\nimport com.lemonappdev.konsist.api.Konsist\nimport com.lemonappdev.konsist.ap"
},
{
"path": "konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/UseCaseKonsistTest.kt",
"chars": 2424,
"preview": "package com.igorwojda.showcase.konsisttest\n\nimport com.lemonappdev.konsist.api.Konsist\nimport com.lemonappdev.konsist.ap"
},
{
"path": "konsist-test/src/test/kotlin/com/igorwojda/showcase/konsisttest/ViewModelKonsistTest.kt",
"chars": 1556,
"preview": "package com.igorwojda.showcase.konsisttest\n\nimport com.igorwojda.showcase.feature.base.presentation.viewmodel.BaseViewMo"
},
{
"path": "library/test-utils/build.gradle.kts",
"chars": 552,
"preview": "plugins {\n id(\"com.igorwojda.showcase.convention.library\")\n}\n\nandroid {\n namespace = \"com.igorwojda.showcase.libra"
},
{
"path": "library/test-utils/proguard-rules.pro",
"chars": 751,
"preview": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguar"
}
]
// ... and 6 more files (download for full content)
About this extraction
This page contains the full source code of the igorwojda/android-showcase GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 206 files (1.5 MB), approximately 232.8k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.