├── .editorconfig
├── .gitignore
├── AndroidRealCA-Modules.png
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ ├── App.kt
│ │ ├── MainActivity.kt
│ │ ├── di
│ │ ├── AndroidCacheProvider.kt
│ │ └── Injector.kt
│ │ └── navigation
│ │ └── RootNavigation.kt
│ └── res
│ ├── drawable
│ ├── ic_launcher_background.xml
│ └── ic_launcher_foreground.xml
│ ├── mipmap-anydpi-v26
│ ├── ic_launcher.xml
│ └── ic_launcher_round.xml
│ ├── mipmap-hdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── mipmap-mdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── mipmap-xhdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── mipmap-xxhdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── mipmap-xxxhdpi
│ ├── ic_launcher.webp
│ └── ic_launcher_round.webp
│ ├── values
│ ├── colors.xml
│ ├── strings.xml
│ └── themes.xml
│ └── xml
│ ├── backup_rules.xml
│ └── data_extraction_rules.xml
├── build.gradle.kts
├── cache-test
├── .gitignore
├── build.gradle.kts
└── src
│ └── commonMain
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── cache
│ └── test
│ ├── InMemoryCachedObject.kt
│ └── TestCacheProvider.kt
├── cache
├── .gitignore
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── cache
│ │ ├── CacheProvider.kt
│ │ ├── CachedObject.kt
│ │ ├── FlowCachedObject.kt
│ │ ├── RealCachedObject.kt
│ │ └── RealFlowCachedObject.kt
│ └── commonTest
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── cache
│ ├── RealCachedObjectTest.kt
│ ├── RealFlowCachedObjectTest.kt
│ └── fixture
│ └── TestModel.kt
├── cart-component
├── .gitignore
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── cart
│ │ ├── data
│ │ ├── model
│ │ │ └── JsonCartCacheDto.kt
│ │ └── repository
│ │ │ └── RealCartRepository.kt
│ │ ├── di
│ │ └── CartComponentDI.kt
│ │ └── domain
│ │ ├── model
│ │ ├── Cart.kt
│ │ └── CartItem.kt
│ │ ├── repository
│ │ └── CartRepository.kt
│ │ └── usecase
│ │ ├── AddCartItemUseCase.kt
│ │ ├── CartUseCases.kt
│ │ ├── ObserveUserCartUseCase.kt
│ │ └── UpdateCartItemUseCase.kt
│ └── commonTest
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── cart
│ ├── data
│ └── repository
│ │ └── RealCartRepositoryTest.kt
│ └── domain
│ ├── model
│ ├── CartItemFixture.kt
│ └── CartTest.kt
│ ├── repository
│ └── TestCartRepository.kt
│ └── usecase
│ ├── AddCartItemUseCaseTest.kt
│ ├── ObserveUserCartUseCaseTest.kt
│ └── UpdateCartItemUseCaseTest.kt
├── cart-ui
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── debug
│ └── screenshotTest
│ │ └── reference
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── cart
│ │ └── presentation
│ │ └── view
│ │ └── CartScreenPreviewsKt
│ │ ├── PreviewPLPEmptyState_0.png
│ │ └── PreviewPLPProductsState_0.png
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── denisbrandi
│ │ │ └── androidrealca
│ │ │ └── cart
│ │ │ ├── di
│ │ │ └── CartUIDI.kt
│ │ │ └── presentation
│ │ │ ├── view
│ │ │ └── CartScreen.kt
│ │ │ └── viewmodel
│ │ │ ├── CartViewModel.kt
│ │ │ └── RealCartViewModel.kt
│ └── res
│ │ ├── drawable
│ │ ├── baseline_add_24.xml
│ │ └── baseline_remove_24.xml
│ │ └── values
│ │ └── strings.xml
│ ├── screenshotTest
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── cart
│ │ └── presentation
│ │ └── view
│ │ ├── CartScreenPreviews.kt
│ │ └── PreviewFixtures.kt
│ └── test
│ └── java
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── cart
│ └── presentation
│ └── viewmodel
│ └── RealCartViewModelTest.kt
├── coroutines-test-dispatcher
├── .gitignore
├── build.gradle.kts
└── src
│ └── main
│ └── java
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── coroutines
│ └── testdispatcher
│ └── MainCoroutineRule.kt
├── coverage
├── androidCoverageReport.gradle
├── kmpCoverageReport.gradle
└── overallCoverageReport.gradle
├── designsystem
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── designsystem
│ │ ├── Buttons.kt
│ │ ├── Colors.kt
│ │ ├── Dimens.kt
│ │ ├── EmptyContents.kt
│ │ ├── ErrorDialogs.kt
│ │ ├── ErrorViews.kt
│ │ ├── Labels.kt
│ │ ├── Loadings.kt
│ │ ├── ModalEvent.kt
│ │ ├── Theme.kt
│ │ ├── TopBar.kt
│ │ └── Typography.kt
│ └── res
│ ├── drawable
│ ├── baseline_add_shopping_cart_24.xml
│ └── baseline_image_24.xml
│ └── values
│ └── string.xml
├── flow-test-observer
├── .gitignore
├── build.gradle.kts
└── src
│ └── commonMain
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── flow
│ └── testobserver
│ └── FlowTestObserver.kt
├── foundations
├── .gitignore
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── foundations
│ │ └── Answer.kt
│ └── commonTest
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── foundations
│ └── AnswerTest.kt
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── httpclient
├── .gitignore
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── httpclient
│ │ ├── AccessTokenProvider.kt
│ │ ├── HttpClientProvider.kt
│ │ └── RealHttpClientProvider.kt
│ └── commonTest
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── httpclient
│ └── AccessTokenProviderTest.kt
├── main-ui
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── debug
│ └── screenshotTest
│ │ └── reference
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── main
│ │ └── presentation
│ │ └── view
│ │ └── MainScreenPreviewsKt
│ │ ├── PreviewCartBadgeState_0.png
│ │ └── PreviewNoBadgesState_0.png
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── denisbrandi
│ │ │ └── androidrealca
│ │ │ └── main
│ │ │ ├── di
│ │ │ └── MainUIDI.kt
│ │ │ └── presentation
│ │ │ ├── view
│ │ │ └── MainScreen.kt
│ │ │ └── viewmodel
│ │ │ ├── MainViewModel.kt
│ │ │ └── RealMainViewModel.kt
│ └── res
│ │ └── values
│ │ └── strings.xml
│ ├── screenshotTest
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── main
│ │ └── presentation
│ │ └── view
│ │ └── MainScreenPreviews.kt
│ └── test
│ └── java
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── main
│ └── presentation
│ └── viewmodel
│ └── RealMainViewModelTest.kt
├── money-component
├── .gitignore
├── build.gradle.kts
└── src
│ └── commonMain
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── money
│ └── domain
│ └── model
│ └── Money.kt
├── money-ui
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ └── java
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── money
│ │ └── presentation
│ │ ├── presenter
│ │ └── MoneyPresenter.kt
│ │ └── view
│ │ └── PriceText.kt
│ └── test
│ └── java
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── money
│ └── presentation
│ └── presenter
│ └── MoneyPresenterTest.kt
├── onboarding-ui
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── debug
│ └── screenshotTest
│ │ └── reference
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── onboarding
│ │ └── presentation
│ │ └── view
│ │ └── LoginScreenPreviewsKt
│ │ ├── PreviewLoginScreenFormState_0.png
│ │ └── PreviewLoginScreenLoggingInState_0.png
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── denisbrandi
│ │ │ └── androidrealca
│ │ │ └── onboarding
│ │ │ ├── di
│ │ │ └── OnboardingUIDI.kt
│ │ │ └── presentation
│ │ │ ├── view
│ │ │ └── LoginScreen.kt
│ │ │ └── viewmodel
│ │ │ ├── LoginViewModel.kt
│ │ │ └── RealLoginViewModel.kt
│ └── res
│ │ └── values
│ │ └── strings.xml
│ ├── screenshotTest
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── onboarding
│ │ └── presentation
│ │ └── view
│ │ └── LoginScreenPreviews.kt
│ └── test
│ └── java
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── onboarding
│ └── presentation
│ └── viewmodel
│ └── RealLoginViewModelTest.kt
├── plp-ui
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── debug
│ └── screenshotTest
│ │ └── reference
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── plp
│ │ └── presentation
│ │ └── view
│ │ └── PLPScreenPreviewsKt
│ │ ├── PreviewPLPDefaultState_0.png
│ │ ├── PreviewPLPEmptyState_0.png
│ │ ├── PreviewPLPErrorState_0.png
│ │ ├── PreviewPLPLoadingState_0.png
│ │ └── PreviewPLPProductsState_0.png
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── denisbrandi
│ │ │ └── androidrealca
│ │ │ └── plp
│ │ │ ├── di
│ │ │ └── PLPUIDI.kt
│ │ │ └── presentation
│ │ │ ├── view
│ │ │ └── PLPScreen.kt
│ │ │ └── viewmodel
│ │ │ ├── PLPViewModel.kt
│ │ │ └── RealPLPViewModel.kt
│ └── res
│ │ ├── drawable
│ │ ├── baseline_favorite_24.xml
│ │ └── baseline_favorite_border_24.xml
│ │ └── values
│ │ └── strings.xml
│ ├── screenshotTest
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── plp
│ │ └── presentation
│ │ └── view
│ │ ├── PLPScreenPreviews.kt
│ │ └── PreviewFixtures.kt
│ └── test
│ └── java
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── plp
│ └── presentation
│ └── viewmodel
│ └── RealPLPViewModelTest.kt
├── product-component
├── .gitignore
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── product
│ │ ├── data
│ │ ├── model
│ │ │ └── JsonProductResponseDTO.kt
│ │ └── repository
│ │ │ └── RealProductRepository.kt
│ │ ├── di
│ │ └── ProductComponentDI.kt
│ │ └── domain
│ │ ├── model
│ │ └── Product.kt
│ │ ├── repository
│ │ └── ProductRepository.kt
│ │ └── usecase
│ │ └── GetProducts.kt
│ └── commonTest
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── product
│ └── data
│ └── repository
│ └── RealProductRepositoryTest.kt
├── scripts
├── installKtlint.sh
├── ktlintCheck.sh
├── ktlintFormat.sh
└── runAllTests.sh
├── settings.gradle.kts
├── user-component
├── .gitignore
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── user
│ │ ├── data
│ │ ├── model
│ │ │ ├── JsonLoginRequestDTO.kt
│ │ │ ├── JsonLoginResponseDTO.kt
│ │ │ └── JsonUserCacheDTO.kt
│ │ └── repository
│ │ │ └── RealUserRepository.kt
│ │ ├── di
│ │ └── UserComponentDI.kt
│ │ └── domain
│ │ ├── model
│ │ ├── Email.kt
│ │ ├── LoginError.kt
│ │ ├── LoginRequest.kt
│ │ ├── Password.kt
│ │ └── User.kt
│ │ ├── repository
│ │ └── UserRepository.kt
│ │ └── usecase
│ │ ├── LoginUseCase.kt
│ │ └── UserUseCases.kt
│ └── commonTest
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── user
│ ├── data
│ └── repository
│ │ └── RealUserRepositoryTest.kt
│ └── domain
│ ├── model
│ ├── EmailTest.kt
│ └── PasswordTest.kt
│ ├── repository
│ └── TestUserRepository.kt
│ └── usecase
│ └── LoginUseCaseTest.kt
├── viewmodel
├── .gitignore
├── build.gradle.kts
└── src
│ └── commonMain
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── viewmodel
│ └── ViewModel.kt
├── wishlist-component
├── .gitignore
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── wishlist
│ │ ├── data
│ │ ├── model
│ │ │ └── JsonWishlistCacheDto.kt
│ │ └── repository
│ │ │ └── RealWishlistRepository.kt
│ │ ├── di
│ │ └── WishlistComponentDI.kt
│ │ └── domain
│ │ ├── model
│ │ └── WishlistItem.kt
│ │ ├── repository
│ │ └── WishlistRepository.kt
│ │ └── usecase
│ │ ├── AddToWishlistUseCase.kt
│ │ ├── ObserveUserWishlistIdsUseCase.kt
│ │ ├── ObserveUserWishlistUseCase.kt
│ │ ├── RemoveFromWishlistUseCase.kt
│ │ └── WishlistUseCases.kt
│ └── commonTest
│ └── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── wishlist
│ ├── data
│ └── repository
│ │ └── RealWishlistRepositoryTest.kt
│ └── domain
│ ├── model
│ └── WishlistItemFixtures.kt
│ ├── repository
│ └── TestWishlistRepository.kt
│ └── usecase
│ ├── AddToWishlistUseCaseTest.kt
│ ├── ObserveUserWishlistIdsUseCaseTest.kt
│ ├── ObserveUserWishlistTest.kt
│ └── RemoveFromWishlistUseCaseTest.kt
└── wishlist-ui
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
├── debug
└── screenshotTest
│ └── reference
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── wishlist
│ └── presentation
│ └── view
│ └── WishlistScreenPreviewsKt
│ ├── PreviewPLPEmptyState_0.png
│ └── PreviewPLPProductsState_0.png
├── main
├── AndroidManifest.xml
├── java
│ └── com
│ │ └── denisbrandi
│ │ └── androidrealca
│ │ └── wishlist
│ │ ├── di
│ │ └── WishlistUIDI.kt
│ │ └── presentation
│ │ ├── view
│ │ └── WishlistScreen.kt
│ │ └── viewmodel
│ │ ├── RealWishlistViewModel.kt
│ │ └── WishlistViewModel.kt
└── res
│ ├── drawable
│ └── baseline_delete_24.xml
│ └── values
│ └── strings.xml
├── screenshotTest
└── kotlin
│ └── com
│ └── denisbrandi
│ └── androidrealca
│ └── wishlist
│ └── presentation
│ └── view
│ ├── PreviewFixtures.kt
│ └── WishlistScreenPreviews.kt
└── test
└── java
└── com
└── denisbrandi
└── androidrealca
└── wishlist
└── presentation
└── viewmodel
└── RealWishlistViewModelTest.kt
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org
2 | root = true
3 | [*]
4 | indent_style = space
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 | [*.{java,kt,kts,scala,rs,xml,kt.spec,kts.spec}]
10 | indent_size = 4
11 | [*.{kt,kts}]
12 | ktlint_code_style = android_studio
13 | ij_kotlin_imports_layout = *
14 | ij_kotlin_allow_trailing_comma = false
15 | ij_kotlin_allow_trailing_comma_on_call_site = false
16 | # General
17 | ktlint_standard_no-wildcard-imports = disabled
18 | ktlint_standard_trailing-comma-on-declaration-site = disabled
19 | ktlint_standard_trailing-comma-on-call-site = disabled
20 | ktlint_standard_max-line-length = disabled
21 | ktlint_standard_function-signature = disabled
22 | ktlint_standard_function-expression-body = disabled
23 | ktlint_standard_function-literal = disabled
24 | ktlint_standard_class-signature = disabled
25 | ktlint_standard_block-comment-initial-star-alignment = disabled
26 | ktlint_standard_indent = disabled
27 | ktlint_standard_no-semi = disabled
28 | ktlint_standard_unnecessary-parentheses-before-trailing-lambda = disabled
29 | # Wrapping
30 | ktlint_standard_argument-list-wrapping = disabled
31 | ktlint_standard_parameter-list-wrapping = disabled
32 | ktlint_standard_binary-expression-wrapping = disabled
33 | ktlint_standard_property-wrapping = disabled
34 | ktlint_standard_parameter-wrapping = disabled
35 | ktlint_standard_enum-wrapping = disabled
36 | ktlint_standard_statement-wrapping = disabled
37 | ktlint_standard_condition-wrapping = disabled
38 | ktlint_standard_wrapping = disabled
39 | # Naming
40 | ktlint_standard_function-naming = disabled
41 | ktlint_standard_property-naming = disabled
42 | ktlint_standard_backing-property-naming = disabled
43 | # Spacing
44 | ktlint_standard_spacing-between-declarations-with-comments = disabled
45 | ktlint_standard_function-type-modifier-spacing = disabled
46 | [*.md]
47 | trim_trailing_whitespace = false
48 | [gradle/verification-metadata.xml]
49 | indent_size = 3
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea
5 | .DS_Store
6 | /build
7 | /captures
8 | .externalNativeBuild
9 | .cxx
10 | local.properties
11 | /.kotlin
12 |
--------------------------------------------------------------------------------
/AndroidRealCA-Modules.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/AndroidRealCA-Modules.png
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | alias(libs.plugins.kotlin.serialization)
6 | }
7 |
8 | android {
9 | namespace = "com.denisbrandi.androidrealca"
10 | compileSdk = 35
11 |
12 | defaultConfig {
13 | applicationId = "com.denisbrandi.androidrealca"
14 | minSdk = 24
15 | targetSdk = 35
16 | versionCode = 1
17 | versionName = "1.0"
18 |
19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
20 | vectorDrawables {
21 | useSupportLibrary = true
22 | }
23 | }
24 |
25 | buildTypes {
26 | release {
27 | isMinifyEnabled = false
28 | proguardFiles(
29 | getDefaultProguardFile("proguard-android-optimize.txt"),
30 | "proguard-rules.pro"
31 | )
32 | }
33 | }
34 | compileOptions {
35 | sourceCompatibility = JavaVersion.VERSION_17
36 | targetCompatibility = JavaVersion.VERSION_17
37 | }
38 | kotlinOptions {
39 | jvmTarget = "17"
40 | }
41 | buildFeatures {
42 | compose = true
43 | }
44 | composeOptions {
45 | kotlinCompilerExtensionVersion = libs.versions.kotlin.compiler.extension.get()
46 | }
47 | packaging {
48 | resources {
49 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
50 | }
51 | }
52 | }
53 |
54 | dependencies {
55 | implementation(project(":designsystem"))
56 | implementation(project(":cache"))
57 | implementation(project(":httpclient"))
58 | implementation(project(":user-component"))
59 | implementation(project(":product-component"))
60 | implementation(project(":wishlist-component"))
61 | implementation(project(":cart-component"))
62 | implementation(project(":onboarding-ui"))
63 | implementation(project(":plp-ui"))
64 | implementation(project(":wishlist-ui"))
65 | implementation(project(":cart-ui"))
66 | implementation(project(":main-ui"))
67 |
68 | implementation(libs.multiplatform.settings)
69 | implementation(libs.preferences.ktx)
70 | implementation(libs.androidx.core.ktx)
71 | implementation(libs.androidx.lifecycle.runtime.ktx)
72 | implementation(libs.androidx.activity.compose)
73 | implementation(platform(libs.androidx.compose.bom))
74 | implementation(libs.compose.navigation)
75 | implementation(libs.androidx.ui)
76 | implementation(libs.androidx.ui.graphics)
77 | implementation(libs.androidx.ui.tooling.preview)
78 | implementation(libs.androidx.material3)
79 | testImplementation(libs.junit)
80 | debugImplementation(libs.androidx.ui.tooling)
81 | debugImplementation(libs.androidx.ui.test.manifest)
82 | }
83 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
18 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/java/com/denisbrandi/androidrealca/App.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca
2 |
3 | import android.app.Application
4 | import com.denisbrandi.androidrealca.di.Injector
5 |
6 | class App : Application() {
7 | override fun onCreate() {
8 | super.onCreate()
9 | Injector.start(this)
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/denisbrandi/androidrealca/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca
2 |
3 | import android.os.Bundle
4 | import androidx.activity.*
5 | import androidx.activity.compose.setContent
6 | import com.denisbrandi.androidrealca.designsystem.RealCleanArchitectureInAndroidTheme
7 | import com.denisbrandi.androidrealca.navigation.RootNavigation
8 |
9 | class MainActivity : ComponentActivity() {
10 | override fun onCreate(savedInstanceState: Bundle?) {
11 | super.onCreate(savedInstanceState)
12 | enableEdgeToEdge()
13 | setContent {
14 | RealCleanArchitectureInAndroidTheme {
15 | RootNavigation()
16 | }
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/denisbrandi/androidrealca/di/AndroidCacheProvider.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.di
2 |
3 | import android.content.Context
4 | import androidx.preference.PreferenceManager
5 | import com.denisbrandi.androidrealca.cache.*
6 | import com.russhwolf.settings.SharedPreferencesSettings
7 | import kotlinx.serialization.KSerializer
8 |
9 | class AndroidCacheProvider(
10 | private val applicationContext: Context
11 | ) : CacheProvider {
12 |
13 | private val settings by lazy {
14 | val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
15 | SharedPreferencesSettings(sharedPrefs)
16 | }
17 |
18 | override fun getCachedObject(
19 | fileName: String,
20 | serializer: KSerializer,
21 | defaultValue: T
22 | ): CachedObject {
23 | return RealCachedObject(fileName, settings, serializer, defaultValue)
24 | }
25 |
26 | override fun getFlowCachedObject(
27 | fileName: String,
28 | serializer: KSerializer,
29 | defaultValue: T
30 | ): FlowCachedObject {
31 | return RealFlowCachedObject(getCachedObject(fileName, serializer, defaultValue))
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/com/denisbrandi/androidrealca/di/Injector.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.di
2 |
3 | import android.content.Context
4 | import com.denisbrandi.androidrealca.cart.di.*
5 | import com.denisbrandi.androidrealca.httpclient.RealHttpClientProvider
6 | import com.denisbrandi.androidrealca.main.di.MainUIDI
7 | import com.denisbrandi.androidrealca.onboarding.di.OnboardingUIDI
8 | import com.denisbrandi.androidrealca.plp.di.PLPUIDI
9 | import com.denisbrandi.androidrealca.product.di.ProductComponentDI
10 | import com.denisbrandi.androidrealca.user.di.UserComponentDI
11 | import com.denisbrandi.androidrealca.wishlist.di.*
12 |
13 | class Injector private constructor(
14 | applicationContext: Context
15 | ) {
16 | private val httpClient = RealHttpClientProvider.getClient()
17 | private val cacheProvider = AndroidCacheProvider(applicationContext)
18 | private val userComponentDI = UserComponentDI(httpClient, cacheProvider)
19 | private val productComponentDI = ProductComponentDI(httpClient)
20 | private val wishlistComponentDI = WishlistComponentDI(cacheProvider, userComponentDI.getUser)
21 | private val cartComponentDI = CartComponentDI(cacheProvider, userComponentDI.getUser)
22 | val isUserLoggedIn = userComponentDI.isUserLoggedIn
23 | val onboardingUIDI = OnboardingUIDI(userComponentDI.login)
24 | val plpUIDI = PLPUIDI(
25 | userComponentDI.getUser,
26 | productComponentDI.getProducts,
27 | wishlistComponentDI,
28 | cartComponentDI.addCartItem
29 | )
30 | val wishlistUIDI = WishlistUIDI(wishlistComponentDI, cartComponentDI.addCartItem)
31 | val cartUIDI = CartUIDI(cartComponentDI)
32 | val mainUIDI = MainUIDI(
33 | wishlistComponentDI.observeUserWishlistIds,
34 | cartComponentDI.observeUserCart
35 | )
36 |
37 | companion object {
38 | lateinit var INSTANCE: Injector
39 |
40 | fun start(applicationContext: Context) {
41 | INSTANCE = Injector(applicationContext)
42 | }
43 | }
44 | }
45 |
46 | val injector = Injector.INSTANCE
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/denisbrandi/androidrealca/navigation/RootNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.navigation
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.navigation.compose.*
5 | import com.denisbrandi.androidrealca.di.injector
6 | import kotlinx.serialization.Serializable
7 |
8 | @Serializable
9 | object NavSplash
10 |
11 | @Serializable
12 | object NavLogin
13 |
14 | @Serializable
15 | object NavMain
16 |
17 | @Composable
18 | fun RootNavigation() {
19 | val navController = rememberNavController()
20 | NavHost(navController, startDestination = NavSplash) {
21 | composable {
22 | val destination: Any = if (injector.isUserLoggedIn()) {
23 | NavMain
24 | } else {
25 | NavLogin
26 | }
27 | navController.navigate(route = destination)
28 | }
29 | composable {
30 | injector.onboardingUIDI.LoginScreenDI {
31 | navController.navigate(route = NavMain)
32 | }
33 | }
34 | composable {
35 | injector.mainUIDI.MainScreenDI(
36 | makePLPScreen = { injector.plpUIDI.PLPScreenDI() },
37 | makeWishlistScreen = { injector.wishlistUIDI.WishlistScreenDI() },
38 | makeCartScreen = { injector.cartUIDI.CartScreenDI() }
39 | )
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Real Clean Architecture in Android
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.kotlin.android) apply false
5 | alias(libs.plugins.kotlin.multiplatform) apply false
6 | alias(libs.plugins.jetbrains.kotlin.jvm) apply false
7 | alias(libs.plugins.compose.compiler) apply false
8 | alias(libs.plugins.android.library) apply false
9 | alias(libs.plugins.kover) apply false
10 | }
11 |
12 | apply(from = "coverage/overallCoverageReport.gradle")
13 |
--------------------------------------------------------------------------------
/cache-test/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/cache-test/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | alias(libs.plugins.kotlin.serialization)
4 | }
5 |
6 | kotlin {
7 | jvmToolchain(17)
8 | jvm()
9 | iosX64()
10 | iosArm64()
11 | iosSimulatorArm64()
12 | sourceSets {
13 | commonMain {
14 | dependencies {
15 | implementation(project(":cache"))
16 | implementation(libs.kotlin.serialization)
17 | }
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/cache-test/src/commonMain/kotlin/com/denisbrandi/androidrealca/cache/test/InMemoryCachedObject.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache.test
2 |
3 | import com.denisbrandi.androidrealca.cache.CachedObject
4 |
5 | class InMemoryCachedObject(
6 | defaultValue: T
7 | ) : CachedObject {
8 |
9 | private var cachedValue = defaultValue
10 |
11 | override fun put(value: T) {
12 | cachedValue = value
13 | }
14 |
15 | override fun get(): T {
16 | return cachedValue
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/cache-test/src/commonMain/kotlin/com/denisbrandi/androidrealca/cache/test/TestCacheProvider.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache.test
2 |
3 | import com.denisbrandi.androidrealca.cache.*
4 | import kotlinx.serialization.KSerializer
5 |
6 | class TestCacheProvider(
7 | private val expectedFileName: String,
8 | private val expectedDefaultValue: Any
9 | ) : CacheProvider {
10 |
11 | lateinit var providedCachedObject: InMemoryCachedObject<*>
12 |
13 | override fun getCachedObject(
14 | fileName: String,
15 | serializer: KSerializer,
16 | defaultValue: T
17 | ): CachedObject {
18 | return if (expectedFileName == fileName && expectedDefaultValue == defaultValue) {
19 | InMemoryCachedObject(defaultValue).also {
20 | providedCachedObject = it
21 | }
22 | } else {
23 | throw IllegalStateException("getCachedObject not stubbed")
24 | }
25 | }
26 |
27 | override fun getFlowCachedObject(
28 | fileName: String,
29 | serializer: KSerializer,
30 | defaultValue: T
31 | ): FlowCachedObject {
32 | return RealFlowCachedObject(getCachedObject(fileName, serializer, defaultValue))
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/cache/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/cache/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | alias(libs.plugins.kotlin.serialization)
4 | }
5 |
6 | apply(from = "../coverage/kmpCoverageReport.gradle")
7 |
8 | kotlin {
9 | jvmToolchain(17)
10 | jvm()
11 | iosX64()
12 | iosArm64()
13 | iosSimulatorArm64()
14 | sourceSets {
15 | commonMain {
16 | dependencies {
17 | implementation(libs.multiplatform.settings)
18 | implementation(libs.multiplatform.settings.serialization)
19 | implementation(libs.kotlin.serialization)
20 | implementation(libs.coroutines.core)
21 | }
22 | }
23 | commonTest {
24 | dependencies {
25 | implementation(libs.kotlin.test)
26 | implementation(libs.multiplatform.settings.test)
27 | implementation(project(":flow-test-observer"))
28 | }
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/cache/src/commonMain/kotlin/com/denisbrandi/androidrealca/cache/CacheProvider.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache
2 |
3 | import kotlinx.serialization.KSerializer
4 |
5 | interface CacheProvider {
6 | fun getCachedObject(
7 | fileName: String,
8 | serializer: KSerializer,
9 | defaultValue: T
10 | ): CachedObject
11 |
12 | fun getFlowCachedObject(
13 | fileName: String,
14 | serializer: KSerializer,
15 | defaultValue: T
16 | ): FlowCachedObject
17 | }
18 |
--------------------------------------------------------------------------------
/cache/src/commonMain/kotlin/com/denisbrandi/androidrealca/cache/CachedObject.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache
2 |
3 | interface CachedObject {
4 | fun put(value: T)
5 | fun get(): T
6 | }
7 |
--------------------------------------------------------------------------------
/cache/src/commonMain/kotlin/com/denisbrandi/androidrealca/cache/FlowCachedObject.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface FlowCachedObject : CachedObject {
6 | fun observe(): Flow
7 | }
8 |
--------------------------------------------------------------------------------
/cache/src/commonMain/kotlin/com/denisbrandi/androidrealca/cache/RealCachedObject.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalSettingsApi::class, ExperimentalSerializationApi::class)
2 |
3 | package com.denisbrandi.androidrealca.cache
4 |
5 | import com.russhwolf.settings.*
6 | import com.russhwolf.settings.serialization.*
7 | import kotlinx.serialization.*
8 |
9 | class RealCachedObject(
10 | private val fileName: String,
11 | private val settings: Settings,
12 | private val serializer: KSerializer,
13 | private val defaultValue: T
14 | ) : CachedObject {
15 | override fun put(value: T) {
16 | settings.encodeValue(serializer, fileName, value)
17 | }
18 |
19 | override fun get(): T {
20 | return settings.decodeValue(serializer, fileName, defaultValue)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/cache/src/commonMain/kotlin/com/denisbrandi/androidrealca/cache/RealFlowCachedObject.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache
2 |
3 | import kotlinx.coroutines.flow.*
4 |
5 | class RealFlowCachedObject(
6 | private val cachedObject: CachedObject
7 | ) : FlowCachedObject, CachedObject by cachedObject {
8 |
9 | private val cacheFlow = MutableStateFlow(get())
10 |
11 | override fun put(value: T) {
12 | cachedObject.put(value)
13 | cacheFlow.value = value
14 | }
15 |
16 | override fun observe(): Flow {
17 | return cacheFlow.asStateFlow()
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/cache/src/commonTest/kotlin/com/denisbrandi/androidrealca/cache/RealCachedObjectTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache
2 |
3 | import com.denisbrandi.androidrealca.cache.fixture.TestModel
4 | import com.russhwolf.settings.MapSettings
5 | import kotlin.test.*
6 |
7 | class RealCachedObjectTest {
8 |
9 | private val sut = RealCachedObject(
10 | fileName = "fileName",
11 | settings = MapSettings(),
12 | serializer = TestModel.serializer(),
13 | defaultValue = TestModel("")
14 | )
15 |
16 | @Test
17 | fun `EXPECT default value WHEN nothing is stored`() {
18 | assertEquals(TestModel(""), sut.get())
19 | }
20 |
21 | @Test
22 | fun `EXPECT value stored`() {
23 | sut.put(TestModel("id"))
24 |
25 | assertEquals(TestModel("id"), sut.get())
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/cache/src/commonTest/kotlin/com/denisbrandi/androidrealca/cache/RealFlowCachedObjectTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache
2 |
3 | import com.denisbrandi.androidrealca.cache.fixture.TestModel
4 | import com.denisbrandi.androidrealca.flow.testobserver.test
5 | import com.russhwolf.settings.MapSettings
6 | import kotlin.test.*
7 |
8 | class RealFlowCachedObjectTest {
9 |
10 | private val cachedObject = RealCachedObject(
11 | fileName = "fileName",
12 | settings = MapSettings(),
13 | serializer = TestModel.serializer(),
14 | defaultValue = TestModel("")
15 | )
16 | private val sut = RealFlowCachedObject(cachedObject)
17 |
18 | @Test
19 | fun `EXPECT default value WHEN nothing is stored`() {
20 | assertEquals(TestModel(""), sut.get())
21 | }
22 |
23 | @Test
24 | fun `EXPECT value stored`() {
25 | sut.put(TestModel("id"))
26 |
27 | assertEquals(TestModel("id"), sut.get())
28 | }
29 |
30 | @Test
31 | fun `EXPECT default value WHEN observing and nothing is stored`() {
32 | val testObserver = sut.observe().test()
33 |
34 | assertEquals(listOf(TestModel("")), testObserver.getValues())
35 | }
36 |
37 | @Test
38 | fun `EXPECT cache updated WHEN observing and updating the cache`() {
39 | val testObserver = sut.observe().test()
40 |
41 | sut.put(TestModel("updated"))
42 |
43 | assertEquals(listOf(TestModel(""), TestModel("updated")), testObserver.getValues())
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/cache/src/commonTest/kotlin/com/denisbrandi/androidrealca/cache/fixture/TestModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cache.fixture
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class TestModel(val id: String)
7 |
--------------------------------------------------------------------------------
/cart-component/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/cart-component/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | alias(libs.plugins.kotlin.serialization)
4 | }
5 |
6 | apply(from = "../coverage/kmpCoverageReport.gradle")
7 |
8 | kotlin {
9 | jvmToolchain(17)
10 | jvm()
11 | iosX64()
12 | iosArm64()
13 | iosSimulatorArm64()
14 | sourceSets {
15 | commonMain {
16 | dependencies {
17 | implementation(libs.coroutines.core)
18 | implementation(libs.kotlin.serialization)
19 | implementation(project(":cache"))
20 | implementation(project(":money-component"))
21 | implementation(project(":user-component"))
22 | }
23 | }
24 | commonTest {
25 | dependencies {
26 | implementation(libs.kotlin.test)
27 | implementation(libs.coroutines.test)
28 | implementation(project(":cache-test"))
29 | implementation(project(":flow-test-observer"))
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/data/model/JsonCartCacheDto.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.data.model
2 |
3 | import kotlinx.serialization.*
4 |
5 | @Serializable
6 | data class JsonCartCacheDto(
7 | @SerialName("usersWishlist") val usersCart: Map>
8 | )
9 |
10 | @Serializable
11 | data class JsonCartItemCacheDto(
12 | @SerialName("id") val id: String,
13 | @SerialName("name") val name: String,
14 | @SerialName("price") val price: Double,
15 | @SerialName("currency") val currency: String,
16 | @SerialName("imageUrl") val imageUrl: String,
17 | @SerialName("quantity") val quantity: Int
18 | )
19 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/data/repository/RealCartRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.data.repository
2 |
3 | import com.denisbrandi.androidrealca.cache.*
4 | import com.denisbrandi.androidrealca.cart.data.model.*
5 | import com.denisbrandi.androidrealca.cart.domain.model.*
6 | import com.denisbrandi.androidrealca.cart.domain.repository.CartRepository
7 | import com.denisbrandi.androidrealca.money.domain.model.Money
8 | import kotlinx.coroutines.flow.*
9 |
10 | internal class RealCartRepository(
11 | private val cacheProvider: CacheProvider
12 | ) : CartRepository {
13 |
14 | private val flowCachedObject: FlowCachedObject by lazy {
15 | cacheProvider.getFlowCachedObject(
16 | fileName = "cart-cache",
17 | serializer = JsonCartCacheDto.serializer(),
18 | defaultValue = JsonCartCacheDto(emptyMap())
19 | )
20 | }
21 |
22 | override fun updateCartItem(userId: String, cartItem: CartItem) {
23 | val updatedCache = getUpdatedCacheForUser(userId) { userCart ->
24 | val cartItemInCache = userCart.find { it.id == cartItem.id }
25 | val cartItemDto = mapToDto(cartItem)
26 | if (cartItemInCache != null) {
27 | if (cartItem.quantity == 0) {
28 | userCart.remove(cartItemInCache)
29 | } else {
30 | val index = userCart.indexOf(cartItemInCache)
31 | userCart[index] = cartItemDto
32 | }
33 | } else {
34 | userCart.add(cartItemDto)
35 | }
36 | }
37 | flowCachedObject.put(updatedCache)
38 | }
39 |
40 | private fun getUpdatedCacheForUser(
41 | userId: String,
42 | onUserCart: (MutableList) -> Unit
43 | ): JsonCartCacheDto {
44 | val usersCart = flowCachedObject.get().usersCart
45 | val userCart = usersCart[userId].orEmpty().toMutableList()
46 | return JsonCartCacheDto(
47 | usersCart = usersCart.toMutableMap().apply {
48 | put(
49 | userId,
50 | userCart.apply {
51 | onUserCart(this)
52 | }.toList()
53 | )
54 | }
55 | )
56 | }
57 |
58 | override fun observeCart(userId: String): Flow {
59 | return flowCachedObject.observe().map { cachedDto ->
60 | mapToCart(userId, cachedDto)
61 | }
62 | }
63 |
64 | override fun getCart(userId: String): Cart {
65 | return mapToCart(userId, flowCachedObject.get())
66 | }
67 |
68 | private fun mapToCart(userId: String, cachedDto: JsonCartCacheDto): Cart {
69 | return Cart(
70 | mapToCartItems(cachedDto.usersCart[userId] ?: emptyList())
71 | )
72 | }
73 |
74 | private fun mapToCartItems(dtos: List): List {
75 | return dtos.map { dto ->
76 | CartItem(
77 | id = dto.id,
78 | name = dto.name,
79 | money = Money(dto.price, dto.currency),
80 | imageUrl = dto.imageUrl,
81 | quantity = dto.quantity
82 | )
83 | }
84 | }
85 |
86 | private fun mapToDto(cartItem: CartItem): JsonCartItemCacheDto {
87 | return JsonCartItemCacheDto(
88 | id = cartItem.id,
89 | name = cartItem.name,
90 | price = cartItem.money.amount,
91 | currency = cartItem.money.currencySymbol,
92 | imageUrl = cartItem.imageUrl,
93 | quantity = cartItem.quantity
94 | )
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/di/CartComponentDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.di
2 |
3 | import com.denisbrandi.androidrealca.cache.CacheProvider
4 | import com.denisbrandi.androidrealca.cart.data.repository.RealCartRepository
5 | import com.denisbrandi.androidrealca.cart.domain.usecase.*
6 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
7 |
8 | class CartComponentDI(
9 | private val cacheProvider: CacheProvider,
10 | private val getUser: GetUser
11 | ) {
12 | private val cartRepository by lazy {
13 | RealCartRepository(cacheProvider)
14 | }
15 |
16 | val updateCartItem: UpdateCartItem by lazy {
17 | UpdateCartItemUseCase(getUser, cartRepository)
18 | }
19 |
20 | val observeUserCart: ObserveUserCart by lazy {
21 | ObserveUserCartUseCase(getUser, cartRepository)
22 | }
23 | val addCartItem: AddCartItem by lazy {
24 | AddCartItemUseCase(getUser, cartRepository, updateCartItem)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/domain/model/Cart.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.model
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 |
5 | data class Cart(val cartItems: List) {
6 | fun getSubtotal(): Money? {
7 | return if (cartItems.isNotEmpty()) {
8 | val currency = cartItems[0].money.currencySymbol
9 | var subtotal = 0.0
10 | cartItems.forEach {
11 | subtotal += it.money.amount * it.quantity
12 | }
13 | Money(subtotal, currency)
14 | } else {
15 | null
16 | }
17 | }
18 |
19 | fun getNumberOfItems(): Int {
20 | return cartItems.sumOf { it.quantity }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/domain/model/CartItem.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.model
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 |
5 | data class CartItem(
6 | val id: String,
7 | val name: String,
8 | val money: Money,
9 | val imageUrl: String,
10 | val quantity: Int
11 | )
12 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/domain/repository/CartRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.repository
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.*
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | internal interface CartRepository {
7 | fun updateCartItem(userId: String, cartItem: CartItem)
8 | fun observeCart(userId: String): Flow
9 | fun getCart(userId: String): Cart
10 | }
11 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/domain/usecase/AddCartItemUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.CartItem
4 | import com.denisbrandi.androidrealca.cart.domain.repository.CartRepository
5 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
6 |
7 | internal class AddCartItemUseCase(
8 | private val getUser: GetUser,
9 | private val cartRepository: CartRepository,
10 | private val updateCartItem: UpdateCartItem
11 | ) : AddCartItem {
12 | override fun invoke(cartItem: CartItem) {
13 | val cartItemInCart = cartRepository.getCart(getUser().id).cartItems.find {
14 | it.id == cartItem.id
15 | }
16 | if (cartItemInCart != null) {
17 | updateCartItem(cartItemInCart.copy(quantity = cartItemInCart.quantity + cartItem.quantity))
18 | } else {
19 | updateCartItem(cartItem)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/domain/usecase/CartUseCases.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.*
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | fun interface UpdateCartItem {
7 | operator fun invoke(cartItem: CartItem)
8 | }
9 |
10 | fun interface ObserveUserCart {
11 | operator fun invoke(): Flow
12 | }
13 |
14 | fun interface AddCartItem {
15 | operator fun invoke(cartItem: CartItem)
16 | }
17 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/domain/usecase/ObserveUserCartUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.Cart
4 | import com.denisbrandi.androidrealca.cart.domain.repository.CartRepository
5 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
6 | import kotlinx.coroutines.flow.Flow
7 |
8 | internal class ObserveUserCartUseCase(
9 | private val getUser: GetUser,
10 | private val cartRepository: CartRepository
11 | ) : ObserveUserCart {
12 | override fun invoke(): Flow {
13 | return cartRepository.observeCart(getUser().id)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/cart-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/cart/domain/usecase/UpdateCartItemUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.CartItem
4 | import com.denisbrandi.androidrealca.cart.domain.repository.CartRepository
5 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
6 |
7 | internal class UpdateCartItemUseCase(
8 | private val getUser: GetUser,
9 | private val cartRepository: CartRepository
10 | ) : UpdateCartItem {
11 | override fun invoke(cartItem: CartItem) {
12 | cartRepository.updateCartItem(getUser().id, cartItem)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/cart-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/cart/data/repository/RealCartRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.data.repository
2 |
3 | import com.denisbrandi.androidrealca.cache.test.TestCacheProvider
4 | import com.denisbrandi.androidrealca.cart.data.model.JsonCartCacheDto
5 | import com.denisbrandi.androidrealca.cart.domain.model.*
6 | import com.denisbrandi.androidrealca.flow.testobserver.test
7 | import kotlin.test.*
8 |
9 | class RealCartRepositoryTest {
10 |
11 | private val cacheProvider = TestCacheProvider(
12 | "cart-cache",
13 | JsonCartCacheDto(emptyMap())
14 | )
15 | private val sut = RealCartRepository(cacheProvider)
16 |
17 | @Test
18 | fun `EXPECT data saved and cart updates emitted`() {
19 | val cartObserver = sut.observeCart(USER_ID).test()
20 |
21 | sut.updateCartItem(USER_ID, CART_ITEM)
22 |
23 | val finalCart = Cart(listOf(CART_ITEM))
24 | assertEquals(finalCart, sut.getCart(USER_ID))
25 | assertEquals(
26 | listOf(Cart(emptyList()), finalCart),
27 | cartObserver.getValues()
28 | )
29 | }
30 |
31 | @Test
32 | fun `EXPECT data added and cart updates emitted`() {
33 | val cartObserver = sut.observeCart(USER_ID).test()
34 | sut.updateCartItem(USER_ID, CART_ITEM)
35 |
36 | sut.updateCartItem(USER_ID, CART_ITEM.copy(id = "2"))
37 |
38 | val finalCart = Cart(listOf(CART_ITEM, CART_ITEM.copy(id = "2")))
39 | assertEquals(finalCart, sut.getCart(USER_ID))
40 | assertEquals(
41 | listOf(
42 | Cart(emptyList()),
43 | Cart(listOf(CART_ITEM)),
44 | finalCart
45 | ),
46 | cartObserver.getValues()
47 | )
48 | }
49 |
50 | @Test
51 | fun `EXPECT quantity updated and cart updates emitted WHEN quantity is different`() {
52 | val cartObserver = sut.observeCart(USER_ID).test()
53 | sut.updateCartItem(USER_ID, CART_ITEM)
54 |
55 | sut.updateCartItem(USER_ID, CART_ITEM.copy(quantity = 2))
56 |
57 | val finalCart = Cart(listOf(CART_ITEM.copy(quantity = 2)))
58 | assertEquals(finalCart, sut.getCart(USER_ID))
59 | assertEquals(
60 | listOf(
61 | Cart(emptyList()),
62 | Cart(listOf(CART_ITEM)),
63 | finalCart
64 | ),
65 | cartObserver.getValues()
66 | )
67 | }
68 |
69 | @Test
70 | fun `EXPECT no updates WHEN quantity is the same`() {
71 | val cartObserver = sut.observeCart(USER_ID).test()
72 | sut.updateCartItem(USER_ID, CART_ITEM)
73 |
74 | sut.updateCartItem(USER_ID, CART_ITEM)
75 |
76 | val finalCart = Cart(listOf(CART_ITEM))
77 | assertEquals(finalCart, sut.getCart(USER_ID))
78 | assertEquals(
79 | listOf(
80 | Cart(emptyList()),
81 | finalCart
82 | ),
83 | cartObserver.getValues()
84 | )
85 | }
86 |
87 | @Test
88 | fun `EXPECT data removed and cart updates emitted WHEN quantity is 0`() {
89 | val cartObserver = sut.observeCart(USER_ID).test()
90 | sut.updateCartItem(USER_ID, CART_ITEM)
91 |
92 | sut.updateCartItem(USER_ID, CART_ITEM.copy(quantity = 0))
93 |
94 | val finalCart = Cart(emptyList())
95 | assertEquals(finalCart, sut.getCart(USER_ID))
96 | assertEquals(
97 | listOf(
98 | Cart(emptyList()),
99 | Cart(listOf(CART_ITEM)),
100 | finalCart
101 | ),
102 | cartObserver.getValues()
103 | )
104 | }
105 |
106 | private companion object {
107 | const val USER_ID = "1234"
108 | val CART_ITEM = makeCartItem()
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/cart-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/cart/domain/model/CartItemFixture.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.model
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 |
5 | fun makeCartItem(
6 | amount: Double = 99.99,
7 | quantity: Int = 1
8 | ): CartItem {
9 | return CartItem(
10 | id = "1",
11 | name = "Wireless Headphones",
12 | money = Money(amount, "$"),
13 | imageUrl = "https://example.com/images/wireless-headphones.jpg",
14 | quantity = quantity
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/cart-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/cart/domain/model/CartTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.model
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 | import kotlin.test.*
5 |
6 | class CartTest {
7 |
8 | @Test
9 | fun `EXPECT null subtotal and currency WHEN cart is empty`() {
10 | val sut = Cart(emptyList())
11 |
12 | val result = sut.getSubtotal()
13 |
14 | assertNull(result)
15 | }
16 |
17 | @Test
18 | fun `EXPECT sum of product per their quantity WHEN cart is not empty`() {
19 | val sut = Cart(
20 | listOf(
21 | makeCartItem(0.99, 1),
22 | makeCartItem(25.00, 3),
23 | makeCartItem(30.00, 2),
24 | makeCartItem(15.00, 1)
25 | )
26 | )
27 |
28 | val result = sut.getSubtotal()
29 |
30 | assertEquals(Money(150.99, "$"), result)
31 | }
32 |
33 | @Test
34 | fun `EXPECT 0 items WHEN cart is empty`() {
35 | val sut = Cart(emptyList())
36 |
37 | val result = sut.getNumberOfItems()
38 |
39 | assertEquals(0, result)
40 | }
41 |
42 | @Test
43 | fun `EXPECT number of items WHEN all items have 1 quantity`() {
44 | val sut = Cart(listOf(makeCartItem(), makeCartItem()))
45 |
46 | val result = sut.getNumberOfItems()
47 |
48 | assertEquals(2, result)
49 | }
50 |
51 | @Test
52 | fun `EXPECT number of items x their quantity WHEN items have quantity 1+`() {
53 | val sut = Cart(listOf(makeCartItem(quantity = 4), makeCartItem(quantity = 5)))
54 |
55 | val result = sut.getNumberOfItems()
56 |
57 | assertEquals(9, result)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/cart-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/cart/domain/repository/TestCartRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.repository
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.*
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | class TestCartRepository : CartRepository {
7 | val updateCartItemInvocations: MutableList> = mutableListOf()
8 | val cartUpdates = mutableMapOf>()
9 | val carts = mutableMapOf()
10 |
11 | override fun updateCartItem(userId: String, cartItem: CartItem) {
12 | updateCartItemInvocations.add(userId to cartItem)
13 | }
14 |
15 | override fun observeCart(userId: String): Flow {
16 | return cartUpdates[userId] ?: throw IllegalStateException("no stubbing for userId")
17 | }
18 |
19 | override fun getCart(userId: String): Cart {
20 | return carts[userId] ?: throw IllegalStateException("no stubbing for userId")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/cart-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/cart/domain/usecase/AddCartItemUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.*
4 | import com.denisbrandi.androidrealca.cart.domain.repository.TestCartRepository
5 | import com.denisbrandi.androidrealca.user.domain.model.User
6 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
7 | import kotlin.test.*
8 |
9 | class AddCartItemUseCaseTest {
10 |
11 | private val getUser = GetUser { USER }
12 | private val cartRepository = TestCartRepository()
13 | private val updateCartItem = TestUpdateCartItem()
14 | private val sut = AddCartItemUseCase(getUser, cartRepository, updateCartItem)
15 |
16 | @Test
17 | fun `EXPECT item added to cart WHEN not present`() {
18 | cartRepository.carts[USER_ID] = Cart(listOf(makeCartItem().copy(id = "1345")))
19 |
20 | sut(CART_ITEM)
21 |
22 | assertEquals(
23 | listOf(CART_ITEM),
24 | updateCartItem.invocations
25 | )
26 | }
27 |
28 | @Test
29 | fun `EXPECT quantity increased WHEN item already present in cart`() {
30 | cartRepository.carts[USER_ID] = Cart(CART_ITEMS)
31 |
32 | sut(CART_ITEM)
33 |
34 | assertEquals(
35 | listOf(CART_ITEM.copy(quantity = 8)),
36 | updateCartItem.invocations
37 | )
38 | }
39 |
40 | private class TestUpdateCartItem : UpdateCartItem {
41 | val invocations = mutableListOf()
42 | override fun invoke(cartItem: CartItem) {
43 | invocations.add(cartItem)
44 | }
45 | }
46 |
47 | private companion object {
48 | const val USER_ID = "1234"
49 | val USER = User(USER_ID, "")
50 | val CART_ITEM = makeCartItem(quantity = 3)
51 | val CART_ITEMS = listOf(CART_ITEM.copy(quantity = 5))
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/cart-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/cart/domain/usecase/ObserveUserCartUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.*
4 | import com.denisbrandi.androidrealca.cart.domain.repository.TestCartRepository
5 | import com.denisbrandi.androidrealca.flow.testobserver.test
6 | import com.denisbrandi.androidrealca.user.domain.model.User
7 | import kotlin.test.*
8 | import kotlinx.coroutines.flow.flowOf
9 |
10 | class ObserveUserCartUseCaseTest {
11 | private val testCartRepository = TestCartRepository()
12 | private val sut = ObserveUserCartUseCase({ USER }, testCartRepository)
13 |
14 | @Test
15 | fun `EXPECT cart updates`() {
16 | testCartRepository.cartUpdates[USER_ID] = flowOf(Cart(emptyList()), Cart(CART_ITEMS))
17 |
18 | val testObserver = sut().test()
19 |
20 | assertEquals(
21 | listOf(Cart(emptyList()), Cart(CART_ITEMS)),
22 | testObserver.getValues()
23 | )
24 | }
25 |
26 | private companion object {
27 | const val USER_ID = "1234"
28 | val USER = User(USER_ID, "")
29 | val CART_ITEMS = listOf(makeCartItem())
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/cart-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/cart/domain/usecase/UpdateCartItemUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.makeCartItem
4 | import com.denisbrandi.androidrealca.cart.domain.repository.TestCartRepository
5 | import com.denisbrandi.androidrealca.user.domain.model.User
6 | import kotlin.test.*
7 |
8 | class UpdateCartItemUseCaseTest {
9 |
10 | private val testCartRepository = TestCartRepository()
11 | private val sut = UpdateCartItemUseCase({ USER }, testCartRepository)
12 |
13 | @Test
14 | fun `EXPECT delegation to repository`() {
15 | sut(CART_ITEM)
16 |
17 | assertEquals(
18 | listOf(USER_ID to CART_ITEM),
19 | testCartRepository.updateCartItemInvocations
20 | )
21 | }
22 |
23 | private companion object {
24 | const val USER_ID = "1234"
25 | val USER = User(USER_ID, "")
26 | val CART_ITEM = makeCartItem()
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/cart-ui/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/cart-ui/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | alias(libs.plugins.screenshot)
6 | }
7 |
8 | apply(from = "../coverage/androidCoverageReport.gradle")
9 |
10 | android {
11 | namespace = "com.denisbrandi.androidrealca.cart.ui"
12 | compileSdk = 35
13 |
14 | defaultConfig {
15 | minSdk = 24
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility = JavaVersion.VERSION_17
32 | targetCompatibility = JavaVersion.VERSION_17
33 | }
34 | kotlinOptions {
35 | jvmTarget = "17"
36 | }
37 | buildFeatures {
38 | compose = true
39 | }
40 | composeOptions {
41 | kotlinCompilerExtensionVersion = libs.versions.kotlin.compiler.extension.get()
42 | }
43 | experimentalProperties["android.experimental.enableScreenshotTest"] = true
44 | }
45 |
46 | dependencies {
47 | implementation(project(":foundations"))
48 | implementation(project(":money-component"))
49 | implementation(project(":cart-component"))
50 | implementation(project(":money-ui"))
51 | implementation(project(":viewmodel"))
52 | implementation(project(":designsystem"))
53 | implementation(libs.coroutines.core)
54 | implementation(libs.lifecycle.viewmodel)
55 | implementation(libs.androidx.runtime.android)
56 |
57 | implementation(platform(libs.androidx.compose.bom))
58 | implementation(libs.androidx.ui.tooling.preview)
59 | implementation(libs.androidx.material3)
60 | debugImplementation(libs.androidx.ui.tooling)
61 | debugImplementation(libs.androidx.ui.test.manifest)
62 | implementation(libs.androidx.core.ktx)
63 |
64 | implementation(libs.coil.compose)
65 | implementation(libs.coil.okhttp)
66 |
67 | testImplementation(libs.junit)
68 | testImplementation(libs.coroutines.test)
69 | testImplementation(project(":flow-test-observer"))
70 | testImplementation(project(":coroutines-test-dispatcher"))
71 |
72 | screenshotTestImplementation(libs.androidx.ui.tooling)
73 | }
74 |
--------------------------------------------------------------------------------
/cart-ui/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/cart-ui/consumer-rules.pro
--------------------------------------------------------------------------------
/cart-ui/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/cart-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/cart/presentation/view/CartScreenPreviewsKt/PreviewPLPEmptyState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/cart-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/cart/presentation/view/CartScreenPreviewsKt/PreviewPLPEmptyState_0.png
--------------------------------------------------------------------------------
/cart-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/cart/presentation/view/CartScreenPreviewsKt/PreviewPLPProductsState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/cart-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/cart/presentation/view/CartScreenPreviewsKt/PreviewPLPProductsState_0.png
--------------------------------------------------------------------------------
/cart-ui/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/cart-ui/src/main/java/com/denisbrandi/androidrealca/cart/di/CartUIDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.di
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.lifecycle.viewmodel.compose.viewModel
5 | import com.denisbrandi.androidrealca.cart.presentation.view.CartScreen
6 | import com.denisbrandi.androidrealca.cart.presentation.viewmodel.*
7 | import com.denisbrandi.androidrealca.viewmodel.StateDelegate
8 |
9 | class CartUIDI(
10 | private val cartComponentDI: CartComponentDI
11 | ) {
12 | @Composable
13 | private fun makeCartViewModel(): CartViewModel {
14 | return viewModel {
15 | RealCartViewModel(
16 | cartComponentDI.observeUserCart,
17 | cartComponentDI.updateCartItem,
18 | StateDelegate()
19 | )
20 | }
21 | }
22 |
23 | @Composable
24 | fun CartScreenDI() {
25 | CartScreen(makeCartViewModel())
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/cart-ui/src/main/java/com/denisbrandi/androidrealca/cart/presentation/viewmodel/CartViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.presentation.viewmodel
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.*
4 | import com.denisbrandi.androidrealca.viewmodel.StateViewModel
5 |
6 | internal interface CartViewModel : StateViewModel {
7 | fun updateCartItemQuantity(cartItem: CartItem)
8 | }
9 |
10 | internal data class CartScreenState(val cart: Cart)
11 |
--------------------------------------------------------------------------------
/cart-ui/src/main/java/com/denisbrandi/androidrealca/cart/presentation/viewmodel/RealCartViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.presentation.viewmodel
2 |
3 | import androidx.lifecycle.*
4 | import com.denisbrandi.androidrealca.cart.domain.model.*
5 | import com.denisbrandi.androidrealca.cart.domain.usecase.*
6 | import com.denisbrandi.androidrealca.viewmodel.*
7 | import kotlinx.coroutines.flow.*
8 |
9 | internal class RealCartViewModel(
10 | observeUserCart: ObserveUserCart,
11 | private val updateCartItem: UpdateCartItem,
12 | private val stateDelegate: StateDelegate
13 | ) : CartViewModel, StateViewModel by stateDelegate, ViewModel() {
14 |
15 | init {
16 | stateDelegate.setDefaultState(CartScreenState(Cart(emptyList())))
17 | observeUserCart().onEach { cart ->
18 | stateDelegate.updateState { CartScreenState(cart) }
19 | }.launchIn(viewModelScope)
20 | }
21 |
22 | override fun updateCartItemQuantity(cartItem: CartItem) {
23 | updateCartItem(cartItem)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/cart-ui/src/main/res/drawable/baseline_add_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/cart-ui/src/main/res/drawable/baseline_remove_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/cart-ui/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Your Cart
4 | You haven\'t added any product to your cart yet
5 | Subtotal: %s
6 |
7 |
--------------------------------------------------------------------------------
/cart-ui/src/screenshotTest/kotlin/com/denisbrandi/androidrealca/cart/presentation/view/CartScreenPreviews.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.presentation.view
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.tooling.preview.Preview
5 | import com.denisbrandi.androidrealca.cart.domain.model.*
6 | import com.denisbrandi.androidrealca.cart.presentation.viewmodel.*
7 | import com.denisbrandi.androidrealca.viewmodel.StateViewModel
8 | import kotlinx.coroutines.flow.*
9 |
10 | @Preview
11 | @Composable
12 | fun PreviewPLPEmptyState() {
13 | CartScreen(createViewModelWithState(CartScreenState(Cart(emptyList()))))
14 | }
15 |
16 | @Preview
17 | @Composable
18 | fun PreviewPLPProductsState() {
19 | CartScreen(createViewModelWithState(CartScreenState(Cart((cartItems)))))
20 | }
21 |
22 | private fun createViewModelWithState(state: CartScreenState): CartViewModel {
23 | return TestCartViewModel(MutableStateFlow(state))
24 | }
25 |
26 | private class TestCartViewModel(
27 | stateFlow: StateFlow
28 | ) : CartViewModel,
29 | StateViewModel {
30 | override val state = stateFlow
31 | override fun updateCartItemQuantity(cartItem: CartItem) {}
32 | }
33 |
--------------------------------------------------------------------------------
/cart-ui/src/screenshotTest/kotlin/com/denisbrandi/androidrealca/cart/presentation/view/PreviewFixtures.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.presentation.view
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.CartItem
4 | import com.denisbrandi.androidrealca.money.domain.model.Money
5 |
6 | val cartItems = listOf(
7 | CartItem(
8 | "1",
9 | "Wireless Headphones",
10 | Money(99.99, "$"),
11 | "https://m.media-amazon.com/images/I/61fU3njgzZL._AC_SL1500_.jpg",
12 | quantity = 1
13 | ),
14 | CartItem(
15 | "2",
16 | "Smartphone Stand",
17 | Money(15.49, "$"),
18 | "https://m.media-amazon.com/images/I/61shuq7IDcL._AC_SL1500_.jpg",
19 | quantity = 5
20 | ),
21 | CartItem(
22 | "3",
23 | "Bluetooth Speaker",
24 | Money(79.99, "$"),
25 | "https://m.media-amazon.com/images/I/81mr1duG3ZL._AC_SL1500_.jpg",
26 | quantity = 8
27 | ),
28 | CartItem(
29 | "4",
30 | "Portable Charger",
31 | Money(24.99, "$"),
32 | "https://m.media-amazon.com/images/I/61cAlFkKsyL._AC_SL1500_.jpg",
33 | quantity = 16
34 | ),
35 | CartItem(
36 | "5",
37 | "Wireless Mouse",
38 | Money(29.99, "$"),
39 | "https://m.media-amazon.com/images/I/61N+CzcA8vL._AC_SL1500_.jpg",
40 | quantity = 1
41 | ),
42 | CartItem(
43 | "6",
44 | "USB-C Cable",
45 | Money(12.99, "$"),
46 | "https://m.media-amazon.com/images/I/61wYXznLNtL._SL1500_.jpg",
47 | quantity = 1
48 | ),
49 | CartItem(
50 | "7",
51 | "Laptop Sleeve",
52 | Money(34.99, "$"),
53 | "https://m.media-amazon.com/images/I/819Ook6vDGL._AC_SL1300_.jpg",
54 | quantity = 1
55 | ),
56 | CartItem(
57 | "8",
58 | "Smartwatch",
59 | Money(199.99, "$"),
60 | "https://m.media-amazon.com/images/I/71Ggfcmy2cL._AC_SL1500_.jpg",
61 | quantity = 1
62 | ),
63 | CartItem(
64 | "9",
65 | "Noise Cancelling Earbuds",
66 | Money(149.99, "$"),
67 | "https://m.media-amazon.com/images/I/719T7V6DWWL._AC_SL1500_.jpg",
68 | quantity = 1
69 | ),
70 | CartItem(
71 | "10",
72 | "Gaming Keyboard",
73 | Money(89.99, "$"),
74 | "https://m.media-amazon.com/images/I/71wBsNJdfdL._AC_SL1500_.jpg",
75 | quantity = 1
76 | )
77 | )
78 |
--------------------------------------------------------------------------------
/cart-ui/src/test/java/com/denisbrandi/androidrealca/cart/presentation/viewmodel/RealCartViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.cart.presentation.viewmodel
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.*
4 | import com.denisbrandi.androidrealca.cart.domain.usecase.*
5 | import com.denisbrandi.androidrealca.coroutines.testdispatcher.MainCoroutineRule
6 | import com.denisbrandi.androidrealca.flow.testobserver.*
7 | import com.denisbrandi.androidrealca.money.domain.model.Money
8 | import com.denisbrandi.androidrealca.viewmodel.StateDelegate
9 | import kotlinx.coroutines.flow.*
10 | import kotlinx.coroutines.test.runTest
11 | import org.junit.*
12 | import org.junit.Assert.assertEquals
13 |
14 | class RealCartViewModelTest {
15 |
16 | @get:Rule
17 | val rule = MainCoroutineRule()
18 |
19 | private val observeUserCart = TestObserveUserCart()
20 | private val updateCartItem = TestUpdateCartItem()
21 | private lateinit var sut: RealCartViewModel
22 | private lateinit var stateObserver: FlowTestObserver
23 |
24 | @Before
25 | fun setUp() {
26 | sut = RealCartViewModel(observeUserCart, updateCartItem, StateDelegate())
27 | stateObserver = sut.state.test()
28 | }
29 |
30 | @Test
31 | fun `EXPECT cart updates`() = runTest {
32 | observeUserCart.cartUpdates.emit(CART)
33 |
34 | assertEquals(
35 | listOf(
36 | CartScreenState(Cart(emptyList())),
37 | CartScreenState(CART)
38 | ),
39 | stateObserver.getValues()
40 | )
41 | }
42 |
43 | @Test
44 | fun `EXPECT cart updated`() {
45 | sut.updateCartItemQuantity(CART_ITEM)
46 |
47 | assertEquals(listOf(CART_ITEM), updateCartItem.invocations)
48 | }
49 |
50 | private class TestObserveUserCart : ObserveUserCart {
51 | val cartUpdates = MutableStateFlow(Cart(emptyList()))
52 | override fun invoke(): Flow = cartUpdates
53 | }
54 |
55 | private class TestUpdateCartItem : UpdateCartItem {
56 | val invocations = mutableListOf()
57 | override fun invoke(cartItem: CartItem) {
58 | invocations.add(cartItem)
59 | }
60 | }
61 |
62 | private companion object {
63 | val CART_ITEM = CartItem(
64 | "1",
65 | "Wireless Headphones",
66 | Money(99.99, "$"),
67 | "https://m.media-amazon.com/images/I/61fU3njgzZL._AC_SL1500_.jpg",
68 | quantity = 1
69 | )
70 | val CART = Cart(listOf(CART_ITEM))
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/coroutines-test-dispatcher/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/coroutines-test-dispatcher/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("java-library")
3 | alias(libs.plugins.jetbrains.kotlin.jvm)
4 | }
5 |
6 | java {
7 | sourceCompatibility = JavaVersion.VERSION_17
8 | targetCompatibility = JavaVersion.VERSION_17
9 | }
10 |
11 | dependencies {
12 | implementation(libs.junit)
13 | implementation(libs.coroutines.test)
14 | }
15 |
--------------------------------------------------------------------------------
/coroutines-test-dispatcher/src/main/java/com/denisbrandi/androidrealca/coroutines/testdispatcher/MainCoroutineRule.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalCoroutinesApi::class)
2 |
3 | package com.denisbrandi.androidrealca.coroutines.testdispatcher
4 |
5 | import kotlinx.coroutines.*
6 | import kotlinx.coroutines.test.*
7 | import org.junit.rules.TestWatcher
8 | import org.junit.runner.Description
9 |
10 | class MainCoroutineRule : TestWatcher() {
11 |
12 | private val testDispatcher = UnconfinedTestDispatcher()
13 |
14 | override fun starting(description: Description?) {
15 | Dispatchers.setMain(testDispatcher)
16 | }
17 |
18 | override fun finished(description: Description?) {
19 | Dispatchers.resetMain()
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/coverage/androidCoverageReport.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'org.jetbrains.kotlinx.kover'
2 |
3 | kover {
4 | currentProject {
5 | createVariant("customDebug") {
6 | add(["debug"], false)
7 | }
8 | }
9 | currentProject {
10 | createVariant("customRelease") {
11 | add(["release"], false)
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/coverage/kmpCoverageReport.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'org.jetbrains.kotlinx.kover'
2 |
3 | kover {
4 | currentProject {
5 | createVariant("customDebug") {
6 | add(["jvm"], false)
7 | }
8 | }
9 | currentProject {
10 | createVariant("customRelease") {
11 | add(["jvm"], false)
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/coverage/overallCoverageReport.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'org.jetbrains.kotlinx.kover'
2 |
3 | kover {
4 | currentProject {
5 | createVariant("customDebug") {}
6 | createVariant("customRelease") {}
7 | }
8 | }
9 |
10 | dependencies {
11 | kover(project(":cache"))
12 | kover(project(":cart-component"))
13 | kover(project(":cart-ui"))
14 | kover(project(":foundations"))
15 | kover(project(":httpclient"))
16 | kover(project(":main-ui"))
17 | kover(project(":money-component"))
18 | kover(project(":money-ui"))
19 | kover(project(":onboarding-ui"))
20 | kover(project(":plp-ui"))
21 | kover(project(":product-component"))
22 | kover(project(":user-component"))
23 | kover(project(":wishlist-component"))
24 | kover(project(":wishlist-ui"))
25 | }
26 |
--------------------------------------------------------------------------------
/designsystem/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/designsystem/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | }
6 |
7 | android {
8 | namespace = "com.denisbrandi.androidrealca.designsystem"
9 | compileSdk = 35
10 |
11 | defaultConfig {
12 | minSdk = 24
13 |
14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
15 | consumerProguardFiles("consumer-rules.pro")
16 | }
17 |
18 | buildTypes {
19 | release {
20 | isMinifyEnabled = false
21 | proguardFiles(
22 | getDefaultProguardFile("proguard-android-optimize.txt"),
23 | "proguard-rules.pro"
24 | )
25 | }
26 | }
27 | compileOptions {
28 | sourceCompatibility = JavaVersion.VERSION_17
29 | targetCompatibility = JavaVersion.VERSION_17
30 | }
31 | kotlinOptions {
32 | jvmTarget = "17"
33 | }
34 | buildFeatures {
35 | compose = true
36 | }
37 | experimentalProperties["android.experimental.enableScreenshotTest"] = true
38 | }
39 |
40 | dependencies {
41 |
42 | implementation(libs.androidx.core.ktx)
43 | implementation(libs.androidx.activity.compose)
44 | implementation(platform(libs.androidx.compose.bom))
45 | implementation(libs.androidx.compose.foundation)
46 | implementation(libs.androidx.material3)
47 | debugImplementation(libs.androidx.ui.tooling)
48 | debugImplementation(libs.androidx.ui.test.manifest)
49 | }
50 |
--------------------------------------------------------------------------------
/designsystem/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/designsystem/consumer-rules.pro
--------------------------------------------------------------------------------
/designsystem/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/designsystem/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/Buttons.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.material3.*
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 |
7 | @Composable
8 | fun LabelButton(text: String, onClick: () -> Unit) {
9 | Button(
10 | onClick = { onClick() },
11 | content = { Text(text = text) }
12 | )
13 | }
14 |
15 | @Composable
16 | fun LoadingButton(text: String, isLoading: Boolean, onClick: () -> Unit) {
17 | Button(
18 | enabled = !isLoading,
19 | content = {
20 | if (isLoading) {
21 | CircularProgressIndicator(color = Progress)
22 | } else {
23 | MediumLabel(Modifier, text = text)
24 | }
25 | },
26 | onClick = onClick
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/Colors.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
12 |
13 | val TopBarText = Color(0xFF000000)
14 |
15 | val TextField = Color(0xFF001234)
16 |
17 | val Progress = Color(0xFF000000)
18 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/Dimens.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.ui.unit.Dp
4 |
5 | val defaultMargin = Dp(16f)
6 | val halfMargin = Dp(8f)
7 | val quarterMargin = Dp(4f)
8 | val doubleMargin = Dp(32f)
9 | val noMargin = Dp(0f)
10 |
11 | val cardElevation = Dp(2f)
12 | val cardHeight = Dp(96f)
13 | val cardImage = Dp(48f)
14 |
15 | val topBarElevation = Dp(4f)
16 | val bottomNavElevation = Dp(4f)
17 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/EmptyContents.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.*
6 |
7 | @Composable
8 | fun FullScreenMessage(message: String) {
9 | Column(
10 | modifier = Modifier.fillMaxSize(),
11 | horizontalAlignment = Alignment.CenterHorizontally,
12 | verticalArrangement = Arrangement.Center,
13 | content = {
14 | MediumLabel(
15 | modifier = Modifier.padding(defaultMargin),
16 | text = message
17 | )
18 | }
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/ErrorDialogs.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.material3.*
4 | import androidx.compose.runtime.Composable
5 |
6 | @Composable
7 | fun CustomAlertDialog(
8 | onConfirm: () -> Unit,
9 | onDismiss: () -> Unit,
10 | dialogText: String,
11 | confirmText: String,
12 | dismissText: String? = null,
13 | ) {
14 | AlertDialog(
15 | text = {
16 | Text(text = dialogText)
17 | },
18 | onDismissRequest = {
19 | onDismiss()
20 | },
21 | confirmButton = {
22 | TextButton(
23 | onClick = {
24 | onConfirm()
25 | }
26 | ) {
27 | Text(confirmText)
28 | }
29 | },
30 | dismissButton = dismissText?.let { text ->
31 | {
32 | TextButton(
33 | onClick = {
34 | onDismiss()
35 | }
36 | ) {
37 | Text(text)
38 | }
39 | }
40 | }
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/ErrorViews.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.*
6 | import androidx.compose.ui.res.stringResource
7 |
8 | @Composable
9 | inline fun RetryErrorView(
10 | errorMessage: String = stringResource(id = R.string.generic_error_message),
11 | crossinline onRetry: () -> Unit
12 | ) {
13 | Column(
14 | modifier = Modifier.fillMaxSize(),
15 | horizontalAlignment = Alignment.CenterHorizontally,
16 | verticalArrangement = Arrangement.Center,
17 | content = {
18 | MediumLabel(modifier = Modifier.padding(defaultMargin), text = errorMessage)
19 | LabelButton(text = stringResource(R.string.retry)) { onRetry() }
20 | }
21 | )
22 | }
23 |
24 | @Composable
25 | fun ErrorView(
26 | errorMessage: String = stringResource(id = R.string.generic_error_message)
27 | ) {
28 | Column(
29 | modifier = Modifier.fillMaxSize(),
30 | horizontalAlignment = Alignment.CenterHorizontally,
31 | verticalArrangement = Arrangement.Center,
32 | content = {
33 | MediumLabel(modifier = Modifier.padding(defaultMargin), text = errorMessage)
34 | }
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/Labels.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.material3.*
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 |
7 | @Composable
8 | fun MediumLabel(modifier: Modifier = Modifier, text: String) {
9 | Text(
10 | modifier = modifier,
11 | style = MaterialTheme.typography.bodyMedium,
12 | text = text
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/Loadings.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material3.CircularProgressIndicator
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.*
7 |
8 | @Composable
9 | fun FullScreenLoading() {
10 | Box(
11 | modifier = Modifier.fillMaxSize(),
12 | contentAlignment = Alignment.Center
13 | ) {
14 | CircularProgressIndicator(color = Progress)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/ModalEvent.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | data class ModalEvent(val viewEvent: T?)
4 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.material3.*
4 | import androidx.compose.runtime.Composable
5 |
6 | private val LightColorScheme = lightColorScheme(
7 | primary = Purple40,
8 | secondary = PurpleGrey40,
9 | tertiary = Pink40,
10 | )
11 |
12 | @Composable
13 | fun RealCleanArchitectureInAndroidTheme(
14 | content: @Composable () -> Unit,
15 | ) {
16 | MaterialTheme(
17 | colorScheme = LightColorScheme,
18 | typography = Typography,
19 | content = content,
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/TopBar.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalMaterial3Api::class)
2 |
3 | package com.denisbrandi.androidrealca.designsystem
4 |
5 | import androidx.compose.material3.*
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.draw.shadow
9 | import androidx.compose.ui.graphics.Color
10 |
11 | @Composable
12 | fun TopBar(text: String) {
13 | TopAppBar(
14 | modifier = Modifier.shadow(elevation = topBarElevation),
15 | colors = TopAppBarDefaults.topAppBarColors(
16 | containerColor = Color.White
17 | ),
18 | title = {
19 | Text(
20 | text = text,
21 | color = TopBarText,
22 | style = MaterialTheme.typography.titleLarge
23 | )
24 | }
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/designsystem/src/main/java/com/denisbrandi/androidrealca/designsystem/Typography.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.designsystem
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.*
6 | import androidx.compose.ui.unit.sp
7 |
8 | val Typography = Typography(
9 | bodyLarge = TextStyle(
10 | fontFamily = FontFamily.Default,
11 | fontWeight = FontWeight.Normal,
12 | fontSize = 16.sp,
13 | lineHeight = 24.sp,
14 | letterSpacing = 0.5.sp,
15 | ),
16 | titleLarge = TextStyle(
17 | fontFamily = FontFamily.Default,
18 | fontWeight = FontWeight.Normal,
19 | fontSize = 22.sp,
20 | lineHeight = 28.sp,
21 | letterSpacing = 0.sp
22 | ),
23 | labelSmall = TextStyle(
24 | fontFamily = FontFamily.Default,
25 | fontWeight = FontWeight.Medium,
26 | fontSize = 11.sp,
27 | lineHeight = 16.sp,
28 | letterSpacing = 0.5.sp
29 | )
30 | )
31 |
--------------------------------------------------------------------------------
/designsystem/src/main/res/drawable/baseline_add_shopping_cart_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/designsystem/src/main/res/drawable/baseline_image_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/designsystem/src/main/res/values/string.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Something went wrong
4 | Retry
5 | OK
6 | Something went wrong
7 | Cancel
8 |
9 |
--------------------------------------------------------------------------------
/flow-test-observer/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/flow-test-observer/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | }
4 |
5 | kotlin {
6 | jvmToolchain(17)
7 | jvm()
8 | iosX64()
9 | iosArm64()
10 | iosSimulatorArm64()
11 | sourceSets {
12 | commonMain {
13 | dependencies {
14 | implementation(libs.coroutines.core)
15 | implementation(libs.coroutines.test)
16 | }
17 | }
18 | commonTest {
19 | dependencies {
20 | }
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/flow-test-observer/src/commonMain/kotlin/com/denisbrandi/androidrealca/flow/testobserver/FlowTestObserver.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalCoroutinesApi::class)
2 |
3 | package com.denisbrandi.androidrealca.flow.testobserver
4 |
5 | import kotlinx.coroutines.*
6 | import kotlinx.coroutines.flow.*
7 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
8 |
9 | class FlowTestObserver(
10 | private val flow: Flow,
11 | coroutineScope: CoroutineScope
12 | ) {
13 | private val emittedValues = mutableListOf()
14 | private val job: Job = flow.onEach {
15 | emittedValues.add(it)
16 | }.launchIn(coroutineScope)
17 |
18 | fun getValues() = emittedValues
19 |
20 | fun stopObserving() {
21 | job.cancel()
22 | }
23 |
24 | fun getFlow() = flow
25 | }
26 |
27 | fun Flow.test(coroutineScope: CoroutineScope = CoroutineScope(UnconfinedTestDispatcher())) =
28 | FlowTestObserver(this, coroutineScope)
29 |
--------------------------------------------------------------------------------
/foundations/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/foundations/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | }
4 |
5 | apply(from = "../coverage/kmpCoverageReport.gradle")
6 |
7 | kotlin {
8 | jvmToolchain(17)
9 | jvm()
10 | iosX64()
11 | iosArm64()
12 | iosSimulatorArm64()
13 | sourceSets {
14 | commonMain {
15 | dependencies {}
16 | }
17 | commonTest {
18 | dependencies {
19 | implementation(libs.kotlin.test)
20 | }
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/foundations/src/commonMain/kotlin/com/denisbrandi/androidrealca/foundations/Answer.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.foundations
2 |
3 | sealed class Answer {
4 | data class Success(
5 | val data: T,
6 | ) : Answer()
7 |
8 | data class Error(
9 | val reason: E,
10 | ) : Answer()
11 |
12 | inline fun fold(
13 | success: (T) -> C,
14 | error: (E) -> C,
15 | ): C =
16 | when (this) {
17 | is Success -> success(data)
18 | is Error -> error(reason)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/foundations/src/commonTest/kotlin/com/denisbrandi/androidrealca/foundations/AnswerTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.foundations
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class AnswerTest {
7 | @Test
8 | fun `EXPECT success callback on fold WHEN Answer is Success`() {
9 | val sut = Answer.Success("success")
10 |
11 | val actual = sut.fold(success = { it }, error = {})
12 |
13 | assertEquals("success", actual)
14 | }
15 |
16 | @Test
17 | fun `EXPECT error callback on fold WHEN Answer is Error`() {
18 | val sut = Answer.Error("failure")
19 |
20 | val actual = sut.fold(success = { }, error = { it })
21 |
22 | assertEquals("failure", actual)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | android.experimental.enableScreenshotTest=true
25 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Tue Oct 01 19:51:28 BST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/httpclient/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/httpclient/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | }
4 |
5 | apply(from = "../coverage/kmpCoverageReport.gradle")
6 |
7 | kotlin {
8 | jvmToolchain(17)
9 | jvm()
10 | iosX64()
11 | iosArm64()
12 | iosSimulatorArm64()
13 | sourceSets {
14 | commonMain {
15 | dependencies {
16 | api(libs.ktor)
17 | api(libs.ktor.cio)
18 | api(libs.ktor.serialization)
19 | api(libs.ktor.content.negotiation)
20 | }
21 | }
22 | commonTest {
23 | dependencies {
24 | implementation(libs.kotlin.test)
25 | }
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/httpclient/src/commonMain/kotlin/com/denisbrandi/androidrealca/httpclient/AccessTokenProvider.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.httpclient
2 |
3 | object AccessTokenProvider {
4 | fun getAccessTokenHeader(): Pair {
5 | return "Authorization" to "Bearer t98jaw2i9048ctpaxb19g901p3yaasy5xyvdwue6"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/httpclient/src/commonMain/kotlin/com/denisbrandi/androidrealca/httpclient/HttpClientProvider.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.httpclient
2 |
3 | import io.ktor.client.HttpClient
4 |
5 | interface HttpClientProvider {
6 | fun getClient(): HttpClient
7 | }
8 |
--------------------------------------------------------------------------------
/httpclient/src/commonMain/kotlin/com/denisbrandi/androidrealca/httpclient/RealHttpClientProvider.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.httpclient
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.HttpClientEngine
5 | import io.ktor.client.engine.cio.CIO
6 | import io.ktor.client.plugins.HttpTimeout
7 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8 | import io.ktor.serialization.kotlinx.json.json
9 | import kotlinx.serialization.json.Json
10 |
11 | object RealHttpClientProvider : HttpClientProvider {
12 |
13 | private val httpClient by lazy {
14 | createClient(CIO.create())
15 | }
16 |
17 | fun createClient(engine: HttpClientEngine): HttpClient {
18 | return HttpClient(engine) {
19 | install(ContentNegotiation) {
20 | json(
21 | Json {
22 | isLenient = true
23 | ignoreUnknownKeys = true
24 | }
25 | )
26 | }
27 | install(HttpTimeout) {
28 | requestTimeoutMillis = 3000L
29 | }
30 | }
31 | }
32 |
33 | override fun getClient(): HttpClient {
34 | return httpClient
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/httpclient/src/commonTest/kotlin/com/denisbrandi/androidrealca/httpclient/AccessTokenProviderTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.httpclient
2 |
3 | import kotlin.test.*
4 |
5 | class AccessTokenProviderTest {
6 | @Test
7 | fun `EXPECT accessToken header`() {
8 | val result = AccessTokenProvider.getAccessTokenHeader()
9 |
10 | assertEquals(
11 | "Authorization" to "Bearer t98jaw2i9048ctpaxb19g901p3yaasy5xyvdwue6",
12 | result
13 | )
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/main-ui/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/main-ui/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | alias(libs.plugins.kotlin.serialization)
6 | alias(libs.plugins.screenshot)
7 | }
8 |
9 | apply(from = "../coverage/androidCoverageReport.gradle")
10 |
11 | android {
12 | namespace = "com.denisbrandi.androidrealca.main.ui"
13 | compileSdk = 35
14 |
15 | defaultConfig {
16 | minSdk = 24
17 |
18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19 | consumerProguardFiles("consumer-rules.pro")
20 | }
21 |
22 | buildTypes {
23 | release {
24 | isMinifyEnabled = false
25 | proguardFiles(
26 | getDefaultProguardFile("proguard-android-optimize.txt"),
27 | "proguard-rules.pro"
28 | )
29 | }
30 | }
31 | compileOptions {
32 | sourceCompatibility = JavaVersion.VERSION_17
33 | targetCompatibility = JavaVersion.VERSION_17
34 | }
35 | kotlinOptions {
36 | jvmTarget = "17"
37 | }
38 | buildFeatures {
39 | compose = true
40 | }
41 | composeOptions {
42 | kotlinCompilerExtensionVersion = libs.versions.kotlin.compiler.extension.get()
43 | }
44 | experimentalProperties["android.experimental.enableScreenshotTest"] = true
45 | }
46 |
47 | dependencies {
48 | implementation(project(":foundations"))
49 | implementation(project(":money-component"))
50 | implementation(project(":wishlist-component"))
51 | implementation(project(":cart-component"))
52 | implementation(project(":viewmodel"))
53 | implementation(project(":designsystem"))
54 | implementation(libs.coroutines.core)
55 | implementation(libs.lifecycle.viewmodel)
56 | implementation(libs.androidx.runtime.android)
57 | implementation(libs.kotlin.serialization)
58 |
59 | implementation(platform(libs.androidx.compose.bom))
60 | implementation(libs.compose.navigation)
61 | implementation(libs.androidx.ui.tooling.preview)
62 | implementation(libs.androidx.material3)
63 | implementation(libs.androidx.core.ktx)
64 | debugImplementation(libs.androidx.ui.tooling)
65 | debugImplementation(libs.androidx.ui.test.manifest)
66 |
67 | implementation(libs.coil.compose)
68 | implementation(libs.coil.okhttp)
69 |
70 | testImplementation(libs.junit)
71 | testImplementation(libs.coroutines.test)
72 | testImplementation(project(":flow-test-observer"))
73 | testImplementation(project(":coroutines-test-dispatcher"))
74 |
75 | screenshotTestImplementation(libs.androidx.ui.tooling)
76 | }
77 |
--------------------------------------------------------------------------------
/main-ui/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/main-ui/consumer-rules.pro
--------------------------------------------------------------------------------
/main-ui/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/main-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/main/presentation/view/MainScreenPreviewsKt/PreviewCartBadgeState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/main-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/main/presentation/view/MainScreenPreviewsKt/PreviewCartBadgeState_0.png
--------------------------------------------------------------------------------
/main-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/main/presentation/view/MainScreenPreviewsKt/PreviewNoBadgesState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/main-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/main/presentation/view/MainScreenPreviewsKt/PreviewNoBadgesState_0.png
--------------------------------------------------------------------------------
/main-ui/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/main-ui/src/main/java/com/denisbrandi/androidrealca/main/di/MainUIDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.main.di
2 |
3 | import androidx.compose.runtime.Composable
4 | import com.denisbrandi.androidrealca.cart.domain.usecase.ObserveUserCart
5 | import com.denisbrandi.androidrealca.main.presentation.view.MainScreen
6 | import com.denisbrandi.androidrealca.main.presentation.viewmodel.*
7 | import com.denisbrandi.androidrealca.viewmodel.StateDelegate
8 | import com.denisbrandi.androidrealca.wishlist.domain.usecase.ObserveUserWishlistIds
9 |
10 | class MainUIDI(
11 | private val observeUserWishlistIds: ObserveUserWishlistIds,
12 | private val observeUserCart: ObserveUserCart
13 | ) {
14 |
15 | private fun makeMainViewModel(): MainViewModel {
16 | return RealMainViewModel(observeUserWishlistIds, observeUserCart, StateDelegate())
17 | }
18 |
19 | @Composable
20 | fun MainScreenDI(
21 | makePLPScreen: @Composable () -> Unit,
22 | makeWishlistScreen: @Composable () -> Unit,
23 | makeCartScreen: @Composable () -> Unit
24 | ) {
25 | MainScreen(
26 | mainViewModel = makeMainViewModel(),
27 | makePLPScreen = makePLPScreen,
28 | makeWishlistScreen = makeWishlistScreen,
29 | makeCartScreen = makeCartScreen
30 | )
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/main-ui/src/main/java/com/denisbrandi/androidrealca/main/presentation/viewmodel/MainViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.main.presentation.viewmodel
2 |
3 | import com.denisbrandi.androidrealca.viewmodel.StateViewModel
4 |
5 | internal interface MainViewModel : StateViewModel
6 |
7 | internal data class MainScreenState(val wishlistBadge: Int = 0, val cartBadge: Int = 0)
8 |
--------------------------------------------------------------------------------
/main-ui/src/main/java/com/denisbrandi/androidrealca/main/presentation/viewmodel/RealMainViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.main.presentation.viewmodel
2 |
3 | import androidx.lifecycle.*
4 | import com.denisbrandi.androidrealca.cart.domain.usecase.ObserveUserCart
5 | import com.denisbrandi.androidrealca.viewmodel.*
6 | import com.denisbrandi.androidrealca.wishlist.domain.usecase.ObserveUserWishlistIds
7 | import kotlinx.coroutines.flow.*
8 |
9 | internal class RealMainViewModel(
10 | observeUserWishlistIds: ObserveUserWishlistIds,
11 | observeUserCart: ObserveUserCart,
12 | stateDelegate: StateDelegate
13 | ) : MainViewModel, StateViewModel by stateDelegate, ViewModel() {
14 |
15 | init {
16 | stateDelegate.setDefaultState(MainScreenState())
17 | observeUserWishlistIds().onEach { list ->
18 | stateDelegate.updateState { state -> state.copy(wishlistBadge = list.size) }
19 | }.launchIn(viewModelScope)
20 | observeUserCart().onEach { cart ->
21 | stateDelegate.updateState { state -> state.copy(cartBadge = cart.getNumberOfItems()) }
22 | }.launchIn(viewModelScope)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/main-ui/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Products
4 | Wishlist
5 | Cart
6 |
7 |
--------------------------------------------------------------------------------
/main-ui/src/screenshotTest/kotlin/com/denisbrandi/androidrealca/main/presentation/view/MainScreenPreviews.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.main.presentation.view
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.tooling.preview.Preview
5 | import com.denisbrandi.androidrealca.main.presentation.viewmodel.*
6 | import com.denisbrandi.androidrealca.viewmodel.StateViewModel
7 | import kotlinx.coroutines.flow.*
8 |
9 | @Preview
10 | @Composable
11 | fun PreviewNoBadgesState() {
12 | MainScreen(createViewModelWithState(MainScreenState()), {}, {}, {})
13 | }
14 |
15 | @Preview
16 | @Composable
17 | fun PreviewCartBadgeState() {
18 | MainScreen(createViewModelWithState(MainScreenState(wishlistBadge = 5, cartBadge = 9)), {}, {}, {})
19 | }
20 |
21 | private fun createViewModelWithState(state: MainScreenState): MainViewModel {
22 | return TestMainViewModel(MutableStateFlow(state))
23 | }
24 |
25 | private class TestMainViewModel(
26 | stateFlow: StateFlow
27 | ) : MainViewModel,
28 | StateViewModel {
29 | override val state = stateFlow
30 | }
31 |
--------------------------------------------------------------------------------
/money-component/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/money-component/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | alias(libs.plugins.kotlin.serialization)
4 | }
5 |
6 | apply(from = "../coverage/kmpCoverageReport.gradle")
7 |
8 | kotlin {
9 | jvmToolchain(17)
10 | jvm()
11 | iosX64()
12 | iosArm64()
13 | iosSimulatorArm64()
14 | sourceSets {
15 | commonMain {
16 | dependencies {
17 | implementation(project(":foundations"))
18 | implementation(libs.kotlin.serialization)
19 | }
20 | }
21 | commonTest {
22 | dependencies {
23 | implementation(libs.kotlin.test)
24 | }
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/money-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/money/domain/model/Money.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.money.domain.model
2 |
3 | data class Money(val amount: Double, val currencySymbol: String)
4 |
--------------------------------------------------------------------------------
/money-ui/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/money-ui/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | }
6 |
7 | apply(from = "../coverage/androidCoverageReport.gradle")
8 |
9 | android {
10 | namespace = "com.denisbrandi.androidrealca.money.ui"
11 | compileSdk = 35
12 |
13 | defaultConfig {
14 | minSdk = 24
15 |
16 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
17 | consumerProguardFiles("consumer-rules.pro")
18 | }
19 |
20 | buildTypes {
21 | release {
22 | isMinifyEnabled = false
23 | proguardFiles(
24 | getDefaultProguardFile("proguard-android-optimize.txt"),
25 | "proguard-rules.pro"
26 | )
27 | }
28 | }
29 | compileOptions {
30 | sourceCompatibility = JavaVersion.VERSION_17
31 | targetCompatibility = JavaVersion.VERSION_17
32 | }
33 | kotlinOptions {
34 | jvmTarget = "17"
35 | }
36 | buildFeatures {
37 | compose = true
38 | }
39 | composeOptions {
40 | kotlinCompilerExtensionVersion = libs.versions.kotlin.compiler.extension.get()
41 | }
42 | }
43 |
44 | dependencies {
45 | implementation(project(":money-component"))
46 | implementation(project(":designsystem"))
47 | implementation(libs.androidx.runtime.android)
48 |
49 | implementation(platform(libs.androidx.compose.bom))
50 | implementation(libs.compose.navigation)
51 | implementation(libs.androidx.ui.tooling.preview)
52 | implementation(libs.androidx.material3)
53 | debugImplementation(libs.androidx.ui.tooling)
54 | debugImplementation(libs.androidx.ui.test.manifest)
55 |
56 | implementation(libs.coil.compose)
57 | implementation(libs.coil.okhttp)
58 |
59 | testImplementation(libs.junit)
60 | testImplementation(libs.coroutines.test)
61 | testImplementation(project(":flow-test-observer"))
62 | testImplementation(project(":coroutines-test-dispatcher"))
63 |
64 | implementation(libs.androidx.core.ktx)
65 | }
66 |
--------------------------------------------------------------------------------
/money-ui/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/money-ui/consumer-rules.pro
--------------------------------------------------------------------------------
/money-ui/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/money-ui/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/money-ui/src/main/java/com/denisbrandi/androidrealca/money/presentation/presenter/MoneyPresenter.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.money.presentation.presenter
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 | import kotlin.math.floor
5 |
6 | object MoneyPresenter {
7 | fun format(money: Money): String {
8 | return "${money.currencySymbol}${floor(money.amount * 100) / 100}"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/money-ui/src/main/java/com/denisbrandi/androidrealca/money/presentation/view/PriceText.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.money.presentation.view
2 |
3 | import androidx.compose.material3.*
4 | import androidx.compose.runtime.Composable
5 | import com.denisbrandi.androidrealca.money.domain.model.Money
6 | import com.denisbrandi.androidrealca.money.presentation.presenter.MoneyPresenter
7 |
8 | @Composable
9 | fun PriceText(
10 | money: Money
11 | ) {
12 | Text(
13 | text = MoneyPresenter.format(money),
14 | style = MaterialTheme.typography.bodyMedium
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/money-ui/src/test/java/com/denisbrandi/androidrealca/money/presentation/presenter/MoneyPresenterTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.money.presentation.presenter
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 | import org.junit.Assert.assertEquals
5 | import org.junit.Test
6 |
7 | class MoneyPresenterTest {
8 |
9 | private val sut = MoneyPresenter
10 |
11 | @Test
12 | fun `EXPECT price formatted`() {
13 | val result = sut.format(Money(99.999999, "$"))
14 |
15 | assertEquals("$99.99", result)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/onboarding-ui/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/onboarding-ui/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | alias(libs.plugins.screenshot)
6 | }
7 |
8 | apply(from = "../coverage/androidCoverageReport.gradle")
9 |
10 | android {
11 | namespace = "com.denisbrandi.androidrealca.onboarding.ui"
12 | compileSdk = 35
13 |
14 | defaultConfig {
15 | minSdk = 24
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility = JavaVersion.VERSION_17
32 | targetCompatibility = JavaVersion.VERSION_17
33 | }
34 | kotlinOptions {
35 | jvmTarget = "17"
36 | }
37 | buildFeatures {
38 | compose = true
39 | }
40 | composeOptions {
41 | kotlinCompilerExtensionVersion = libs.versions.kotlin.compiler.extension.get()
42 | }
43 | experimentalProperties["android.experimental.enableScreenshotTest"] = true
44 | }
45 |
46 | dependencies {
47 | implementation(project(":foundations"))
48 | implementation(project(":user-component"))
49 | implementation(project(":viewmodel"))
50 | implementation(project(":designsystem"))
51 | implementation(libs.coroutines.core)
52 | implementation(libs.lifecycle.viewmodel)
53 | implementation(libs.androidx.runtime.android)
54 | implementation(libs.androidx.core.ktx)
55 |
56 | implementation(platform(libs.androidx.compose.bom))
57 | implementation(libs.androidx.ui.tooling.preview)
58 | implementation(libs.androidx.material3)
59 | debugImplementation(libs.androidx.ui.tooling)
60 | debugImplementation(libs.androidx.ui.test.manifest)
61 |
62 | testImplementation(libs.junit)
63 | testImplementation(libs.coroutines.test)
64 | testImplementation(project(":flow-test-observer"))
65 | testImplementation(project(":coroutines-test-dispatcher"))
66 |
67 | screenshotTestImplementation(libs.androidx.ui.tooling)
68 | }
69 |
--------------------------------------------------------------------------------
/onboarding-ui/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/onboarding-ui/consumer-rules.pro
--------------------------------------------------------------------------------
/onboarding-ui/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/onboarding-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/onboarding/presentation/view/LoginScreenPreviewsKt/PreviewLoginScreenFormState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/onboarding-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/onboarding/presentation/view/LoginScreenPreviewsKt/PreviewLoginScreenFormState_0.png
--------------------------------------------------------------------------------
/onboarding-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/onboarding/presentation/view/LoginScreenPreviewsKt/PreviewLoginScreenLoggingInState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/onboarding-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/onboarding/presentation/view/LoginScreenPreviewsKt/PreviewLoginScreenLoggingInState_0.png
--------------------------------------------------------------------------------
/onboarding-ui/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/onboarding-ui/src/main/java/com/denisbrandi/androidrealca/onboarding/di/OnboardingUIDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.onboarding.di
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.lifecycle.viewmodel.compose.viewModel
5 | import com.denisbrandi.androidrealca.onboarding.presentation.view.LoginScreen
6 | import com.denisbrandi.androidrealca.onboarding.presentation.viewmodel.*
7 | import com.denisbrandi.androidrealca.user.domain.usecase.Login
8 | import com.denisbrandi.androidrealca.viewmodel.*
9 |
10 | class OnboardingUIDI(
11 | private val login: Login
12 | ) {
13 | @Composable
14 | private fun makeLoginViewModel(): LoginViewModel {
15 | return viewModel {
16 | RealLoginViewModel(
17 | login,
18 | StateDelegate(),
19 | EventDelegate()
20 | )
21 | }
22 | }
23 |
24 | @Composable
25 | fun LoginScreenDI(onLoggedIn: () -> Unit) {
26 | LoginScreen(makeLoginViewModel(), onLoggedIn)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/onboarding-ui/src/main/java/com/denisbrandi/androidrealca/onboarding/presentation/viewmodel/LoginViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.onboarding.presentation.viewmodel
2 |
3 | import com.denisbrandi.androidrealca.user.domain.model.LoginError
4 | import com.denisbrandi.androidrealca.viewmodel.*
5 |
6 | internal interface LoginViewModel : StateViewModel, EventViewModel {
7 | fun login(email: String, password: String)
8 | }
9 |
10 | internal data class LoginScreenState(val displayState: DisplayState)
11 |
12 | internal sealed interface DisplayState {
13 | data object Form : DisplayState
14 | data object LoggingIn : DisplayState
15 | }
16 |
17 | internal sealed interface LoginScreenEvent {
18 | data class ShowError(val loginError: LoginError) : LoginScreenEvent
19 | data object SuccessfulLogin : LoginScreenEvent
20 | }
21 |
--------------------------------------------------------------------------------
/onboarding-ui/src/main/java/com/denisbrandi/androidrealca/onboarding/presentation/viewmodel/RealLoginViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.onboarding.presentation.viewmodel
2 |
3 | import androidx.lifecycle.*
4 | import com.denisbrandi.androidrealca.user.domain.model.LoginRequest
5 | import com.denisbrandi.androidrealca.user.domain.usecase.Login
6 | import com.denisbrandi.androidrealca.viewmodel.*
7 | import kotlinx.coroutines.launch
8 |
9 | internal class RealLoginViewModel(
10 | private val login: Login,
11 | private val stateDelegate: StateDelegate,
12 | private val eventDelegate: EventDelegate
13 | ) : LoginViewModel,
14 | StateViewModel by stateDelegate,
15 | EventViewModel by eventDelegate,
16 | ViewModel() {
17 |
18 | init {
19 | stateDelegate.setDefaultState(LoginScreenState(DisplayState.Form))
20 | }
21 |
22 | override fun login(email: String, password: String) {
23 | stateDelegate.updateState { it.copy(displayState = DisplayState.LoggingIn) }
24 |
25 | viewModelScope.launch {
26 | login(LoginRequest(email, password)).fold(
27 | success = {
28 | eventDelegate.sendEvent(viewModelScope, LoginScreenEvent.SuccessfulLogin)
29 | },
30 | error = { loginError ->
31 | stateDelegate.updateState { it.copy(displayState = DisplayState.Form) }
32 | eventDelegate.sendEvent(viewModelScope, LoginScreenEvent.ShowError(loginError))
33 | }
34 | )
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/onboarding-ui/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Login
4 | Enter email
5 | Enter password
6 | Login
7 | Please insert a valid email
8 | Please insert a valid password (min 8 chars)
9 | No user with the provided credentials can be found
10 |
11 |
--------------------------------------------------------------------------------
/onboarding-ui/src/screenshotTest/kotlin/com/denisbrandi/androidrealca/onboarding/presentation/view/LoginScreenPreviews.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.onboarding.presentation.view
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.tooling.preview.Preview
5 | import com.denisbrandi.androidrealca.onboarding.presentation.viewmodel.*
6 | import com.denisbrandi.androidrealca.viewmodel.*
7 | import kotlinx.coroutines.flow.*
8 |
9 | @Preview
10 | @Composable
11 | fun PreviewLoginScreenFormState() {
12 | LoginScreen(createViewModelWithState(LoginScreenState(DisplayState.Form))) {}
13 | }
14 |
15 | @Preview
16 | @Composable
17 | fun PreviewLoginScreenLoggingInState() {
18 | LoginScreen(createViewModelWithState(LoginScreenState(DisplayState.LoggingIn))) {}
19 | }
20 |
21 | private fun createViewModelWithState(loginScreenState: LoginScreenState): LoginViewModel {
22 | return TestLoginViewModel(MutableStateFlow(loginScreenState), emptyFlow())
23 | }
24 |
25 | private class TestLoginViewModel(
26 | flowState: StateFlow,
27 | flowViewEvent: Flow
28 | ) : LoginViewModel,
29 | StateViewModel,
30 | EventViewModel {
31 | override val state = flowState
32 | override val viewEvent = flowViewEvent
33 | override fun login(email: String, password: String) {}
34 | }
35 |
--------------------------------------------------------------------------------
/onboarding-ui/src/test/java/com/denisbrandi/androidrealca/onboarding/presentation/viewmodel/RealLoginViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.onboarding.presentation.viewmodel
2 |
3 | import com.denisbrandi.androidrealca.coroutines.testdispatcher.MainCoroutineRule
4 | import com.denisbrandi.androidrealca.flow.testobserver.*
5 | import com.denisbrandi.androidrealca.foundations.Answer
6 | import com.denisbrandi.androidrealca.user.domain.model.*
7 | import com.denisbrandi.androidrealca.user.domain.usecase.Login
8 | import com.denisbrandi.androidrealca.viewmodel.*
9 | import kotlinx.coroutines.awaitCancellation
10 | import kotlinx.coroutines.test.runTest
11 | import org.junit.*
12 | import org.junit.Assert.assertEquals
13 |
14 | class RealLoginViewModelTest {
15 |
16 | @get:Rule
17 | val rule = MainCoroutineRule()
18 |
19 | private val login = TestLogin()
20 | private lateinit var stateObserver: FlowTestObserver
21 | private lateinit var eventObserver: FlowTestObserver
22 | private val sut = RealLoginViewModel(login, StateDelegate(), EventDelegate())
23 |
24 | @Before
25 | fun setUp() {
26 | stateObserver = sut.state.test()
27 | eventObserver = sut.viewEvent.test()
28 | }
29 |
30 | @Test
31 | fun `EXPECT default state WHEN initialized`() {
32 | assertEquals(listOf(LoginScreenState(DisplayState.Form)), stateObserver.getValues())
33 | }
34 |
35 | @Test
36 | fun `EXPECT loading state WHEN awaiting for use case`() = runTest {
37 | login.loginResult = { awaitCancellation() }
38 |
39 | sut.login(EMAIL, PASSWORD)
40 |
41 | assertEquals(
42 | listOf(
43 | LoginScreenState(DisplayState.Form),
44 | LoginScreenState(DisplayState.LoggingIn)
45 | ),
46 | stateObserver.getValues()
47 | )
48 | }
49 |
50 | @Test
51 | fun `EXPECT error event WHEN use case returns error`() = runTest {
52 | val loginError = LoginError.GenericError
53 | login.loginResult = { Answer.Error(loginError) }
54 |
55 | sut.login(EMAIL, PASSWORD)
56 |
57 | assertEquals(
58 | listOf(
59 | LoginScreenState(DisplayState.Form),
60 | LoginScreenState(DisplayState.LoggingIn),
61 | LoginScreenState(DisplayState.Form)
62 | ),
63 | stateObserver.getValues()
64 | )
65 | assertEquals(listOf(LoginScreenEvent.ShowError(loginError)), eventObserver.getValues())
66 | }
67 |
68 | @Test
69 | fun `EXPECT success event WHEN use case returns success`() = runTest {
70 | login.loginResult = { Answer.Success(Unit) }
71 |
72 | sut.login(EMAIL, PASSWORD)
73 |
74 | assertEquals(
75 | listOf(
76 | LoginScreenState(DisplayState.Form),
77 | LoginScreenState(DisplayState.LoggingIn)
78 | ),
79 | stateObserver.getValues()
80 | )
81 | assertEquals(listOf(LoginScreenEvent.SuccessfulLogin), eventObserver.getValues())
82 | }
83 |
84 | private class TestLogin : Login {
85 | lateinit var loginResult: suspend () -> Answer
86 | override suspend fun invoke(loginRequest: LoginRequest): Answer {
87 | return if (LoginRequest(EMAIL, PASSWORD) == loginRequest) {
88 | loginResult()
89 | } else {
90 | throw IllegalStateException("login not stubbed")
91 | }
92 | }
93 | }
94 |
95 | private companion object {
96 | const val EMAIL = "valid@email.com"
97 | const val PASSWORD = "12345678"
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/plp-ui/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/plp-ui/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | alias(libs.plugins.screenshot)
6 | }
7 |
8 | apply(from = "../coverage/androidCoverageReport.gradle")
9 |
10 | android {
11 | namespace = "com.denisbrandi.androidrealca.plp.ui"
12 | compileSdk = 35
13 |
14 | defaultConfig {
15 | minSdk = 24
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility = JavaVersion.VERSION_17
32 | targetCompatibility = JavaVersion.VERSION_17
33 | }
34 | kotlinOptions {
35 | jvmTarget = "17"
36 | }
37 | buildFeatures {
38 | compose = true
39 | }
40 | composeOptions {
41 | kotlinCompilerExtensionVersion = libs.versions.kotlin.compiler.extension.get()
42 | }
43 | experimentalProperties["android.experimental.enableScreenshotTest"] = true
44 | }
45 |
46 | dependencies {
47 | implementation(project(":foundations"))
48 | implementation(project(":user-component"))
49 | implementation(project(":money-component"))
50 | implementation(project(":product-component"))
51 | implementation(project(":wishlist-component"))
52 | implementation(project(":cart-component"))
53 | implementation(project(":money-ui"))
54 | implementation(project(":viewmodel"))
55 | implementation(project(":designsystem"))
56 | implementation(libs.coroutines.core)
57 | implementation(libs.lifecycle.viewmodel)
58 | implementation(libs.androidx.runtime.android)
59 |
60 | implementation(platform(libs.androidx.compose.bom))
61 | implementation(libs.androidx.ui.tooling.preview)
62 | implementation(libs.androidx.material3)
63 | debugImplementation(libs.androidx.ui.tooling)
64 | debugImplementation(libs.androidx.ui.test.manifest)
65 | implementation(libs.androidx.core.ktx)
66 |
67 | implementation(libs.coil.compose)
68 | implementation(libs.coil.okhttp)
69 |
70 | testImplementation(libs.junit)
71 | testImplementation(libs.coroutines.test)
72 | testImplementation(project(":flow-test-observer"))
73 | testImplementation(project(":coroutines-test-dispatcher"))
74 |
75 | screenshotTestImplementation(libs.androidx.ui.tooling)
76 | }
77 |
--------------------------------------------------------------------------------
/plp-ui/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/plp-ui/consumer-rules.pro
--------------------------------------------------------------------------------
/plp-ui/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPDefaultState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPDefaultState_0.png
--------------------------------------------------------------------------------
/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPEmptyState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPEmptyState_0.png
--------------------------------------------------------------------------------
/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPErrorState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPErrorState_0.png
--------------------------------------------------------------------------------
/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPLoadingState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPLoadingState_0.png
--------------------------------------------------------------------------------
/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPProductsState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/plp-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviewsKt/PreviewPLPProductsState_0.png
--------------------------------------------------------------------------------
/plp-ui/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/plp-ui/src/main/java/com/denisbrandi/androidrealca/plp/di/PLPUIDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.plp.di
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.lifecycle.viewmodel.compose.viewModel
5 | import com.denisbrandi.androidrealca.cart.domain.usecase.AddCartItem
6 | import com.denisbrandi.androidrealca.plp.presentation.view.PLPScreen
7 | import com.denisbrandi.androidrealca.plp.presentation.viewmodel.*
8 | import com.denisbrandi.androidrealca.product.domain.usecase.GetProducts
9 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
10 | import com.denisbrandi.androidrealca.viewmodel.StateDelegate
11 | import com.denisbrandi.androidrealca.wishlist.di.WishlistComponentDI
12 |
13 | class PLPUIDI(
14 | private val getUser: GetUser,
15 | private val getProducts: GetProducts,
16 | private val wishlistComponentDI: WishlistComponentDI,
17 | private val addCartItem: AddCartItem
18 | ) {
19 | @Composable
20 | private fun makePLPViewModel(): PLPViewModel {
21 | return viewModel {
22 | RealPLPViewModel(
23 | getUser,
24 | getProducts,
25 | wishlistComponentDI.observeUserWishlistIds,
26 | wishlistComponentDI.addToWishlist,
27 | wishlistComponentDI.removeFromWishlist,
28 | addCartItem,
29 | StateDelegate()
30 | )
31 | }
32 | }
33 |
34 | @Composable
35 | fun PLPScreenDI() {
36 | PLPScreen(makePLPViewModel())
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/plp-ui/src/main/java/com/denisbrandi/androidrealca/plp/presentation/viewmodel/PLPViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.plp.presentation.viewmodel
2 |
3 | import com.denisbrandi.androidrealca.product.domain.model.Product
4 | import com.denisbrandi.androidrealca.viewmodel.StateViewModel
5 |
6 | internal interface PLPViewModel : StateViewModel {
7 | fun loadProducts()
8 | fun isFavourite(productId: String): Boolean
9 | fun addProductToWishlist(product: Product)
10 | fun removeProductFromWishlist(productId: String)
11 | fun addProductToCart(product: Product)
12 | }
13 |
14 | internal data class PLPScreenState(
15 | val fullName: String,
16 | val wishlistIds: List = emptyList(),
17 | val displayState: DisplayState? = null
18 | )
19 |
20 | internal sealed interface DisplayState {
21 | data object Loading : DisplayState
22 | data object Error : DisplayState
23 | data class Content(val products: List) : DisplayState
24 | }
25 |
--------------------------------------------------------------------------------
/plp-ui/src/main/java/com/denisbrandi/androidrealca/plp/presentation/viewmodel/RealPLPViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.plp.presentation.viewmodel
2 |
3 | import androidx.lifecycle.*
4 | import com.denisbrandi.androidrealca.cart.domain.model.CartItem
5 | import com.denisbrandi.androidrealca.cart.domain.usecase.AddCartItem
6 | import com.denisbrandi.androidrealca.product.domain.model.Product
7 | import com.denisbrandi.androidrealca.product.domain.usecase.GetProducts
8 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
9 | import com.denisbrandi.androidrealca.viewmodel.*
10 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
11 | import com.denisbrandi.androidrealca.wishlist.domain.usecase.*
12 | import kotlinx.coroutines.flow.*
13 | import kotlinx.coroutines.launch
14 |
15 | internal class RealPLPViewModel(
16 | getUser: GetUser,
17 | private val getProducts: GetProducts,
18 | observeUserWishlistIds: ObserveUserWishlistIds,
19 | private val addToWishlist: AddToWishlist,
20 | private val removeFromWishlist: RemoveFromWishlist,
21 | private val addCartItem: AddCartItem,
22 | private val stateDelegate: StateDelegate
23 | ) : PLPViewModel, StateViewModel by stateDelegate, ViewModel() {
24 |
25 | init {
26 | stateDelegate.setDefaultState(PLPScreenState(getUser().fullName))
27 | observeUserWishlistIds().onEach { ids ->
28 | stateDelegate.updateState { it.copy(wishlistIds = ids) }
29 | }.launchIn(viewModelScope)
30 | }
31 |
32 | override fun loadProducts() {
33 | if (state.value.displayState == null) {
34 | stateDelegate.updateState {
35 | it.copy(displayState = DisplayState.Loading)
36 | }
37 | viewModelScope.launch {
38 | getProducts().fold(
39 | success = { products ->
40 | stateDelegate.updateState {
41 | it.copy(
42 | displayState = DisplayState.Content(
43 | products
44 | )
45 | )
46 | }
47 | },
48 | error = {
49 | stateDelegate.updateState { it.copy(displayState = DisplayState.Error) }
50 | }
51 | )
52 | }
53 | }
54 | }
55 |
56 | override fun isFavourite(productId: String): Boolean {
57 | return state.value.wishlistIds.contains(productId)
58 | }
59 |
60 | override fun addProductToWishlist(product: Product) {
61 | addToWishlist(WishlistItem(product.id, product.name, product.money, product.imageUrl))
62 | }
63 |
64 | override fun removeProductFromWishlist(productId: String) {
65 | removeFromWishlist(productId)
66 | }
67 |
68 | override fun addProductToCart(product: Product) {
69 | addCartItem(
70 | CartItem(
71 | id = product.id,
72 | name = product.name,
73 | money = product.money,
74 | imageUrl = product.imageUrl,
75 | quantity = 1
76 | )
77 | )
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/plp-ui/src/main/res/drawable/baseline_favorite_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/plp-ui/src/main/res/drawable/baseline_favorite_border_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/plp-ui/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Welcome back, %s
4 | No products were found
5 |
6 |
--------------------------------------------------------------------------------
/plp-ui/src/screenshotTest/kotlin/com/denisbrandi/androidrealca/plp/presentation/view/PLPScreenPreviews.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.plp.presentation.view
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.tooling.preview.Preview
5 | import com.denisbrandi.androidrealca.plp.presentation.viewmodel.*
6 | import com.denisbrandi.androidrealca.product.domain.model.Product
7 | import com.denisbrandi.androidrealca.viewmodel.StateViewModel
8 | import kotlinx.coroutines.flow.*
9 |
10 | @Preview
11 | @Composable
12 | fun PreviewPLPDefaultState() {
13 | PLPScreen(createViewModelWithState(PLPScreenState(fullName = "Full Name")))
14 | }
15 |
16 | @Preview
17 | @Composable
18 | fun PreviewPLPLoadingState() {
19 | PLPScreen(
20 | createViewModelWithState(
21 | PLPScreenState(
22 | fullName = "Full Name",
23 | displayState = DisplayState.Loading
24 | )
25 | )
26 | )
27 | }
28 |
29 | @Preview
30 | @Composable
31 | fun PreviewPLPErrorState() {
32 | PLPScreen(
33 | createViewModelWithState(
34 | PLPScreenState(
35 | fullName = "Full Name",
36 | displayState = DisplayState.Error
37 | )
38 | )
39 | )
40 | }
41 |
42 | @Preview
43 | @Composable
44 | fun PreviewPLPEmptyState() {
45 | PLPScreen(
46 | createViewModelWithState(
47 | PLPScreenState(
48 | fullName = "Full Name",
49 | displayState = DisplayState.Content(emptyList())
50 | )
51 | )
52 | )
53 | }
54 |
55 | @Preview
56 | @Composable
57 | fun PreviewPLPProductsState() {
58 | PLPScreen(
59 | createViewModelWithState(
60 | PLPScreenState(
61 | fullName = "Full Name",
62 | displayState = DisplayState.Content(productList)
63 | )
64 | )
65 | )
66 | }
67 |
68 | private fun createViewModelWithState(state: PLPScreenState): PLPViewModel {
69 | return TestPLPViewModel(MutableStateFlow(state))
70 | }
71 |
72 | private class TestPLPViewModel(
73 | stateFlow: StateFlow
74 | ) : PLPViewModel,
75 | StateViewModel {
76 | override val state = stateFlow
77 | override fun loadProducts() {}
78 | override fun isFavourite(productId: String): Boolean {
79 | return productId.toInt() % 2 == 0
80 | }
81 |
82 | override fun addProductToWishlist(product: Product) {}
83 |
84 | override fun removeProductFromWishlist(productId: String) {}
85 |
86 | override fun addProductToCart(product: Product) {}
87 | }
88 |
--------------------------------------------------------------------------------
/product-component/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/product-component/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | alias(libs.plugins.kotlin.serialization)
4 | }
5 |
6 | apply(from = "../coverage/kmpCoverageReport.gradle")
7 |
8 | kotlin {
9 | jvmToolchain(17)
10 | jvm()
11 | iosX64()
12 | iosArm64()
13 | iosSimulatorArm64()
14 | sourceSets {
15 | commonMain {
16 | dependencies {
17 | implementation(project(":foundations"))
18 | implementation(project(":money-component"))
19 | implementation(project(":httpclient"))
20 | implementation(libs.ktor)
21 | implementation(libs.ktor.serialization)
22 | implementation(libs.ktor.content.negotiation)
23 | }
24 | }
25 | commonTest {
26 | dependencies {
27 | implementation(libs.kotlin.test)
28 | implementation(libs.coroutines.test)
29 | implementation(libs.netmock)
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/product-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/product/data/model/JsonProductResponseDTO.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.product.data.model
2 |
3 | import kotlinx.serialization.*
4 |
5 | @Serializable
6 | data class JsonProductResponseDTO(
7 | @SerialName("id") val id: Int,
8 | @SerialName("name") val name: String,
9 | @SerialName("price") val price: Double,
10 | @SerialName("currency") val currency: String,
11 | @SerialName("imageUrl") val imageUrl: String
12 | )
13 |
--------------------------------------------------------------------------------
/product-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/product/data/repository/RealProductRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.product.data.repository
2 |
3 | import com.denisbrandi.androidrealca.foundations.Answer
4 | import com.denisbrandi.androidrealca.httpclient.AccessTokenProvider
5 | import com.denisbrandi.androidrealca.money.domain.model.Money
6 | import com.denisbrandi.androidrealca.product.data.model.JsonProductResponseDTO
7 | import com.denisbrandi.androidrealca.product.domain.model.Product
8 | import com.denisbrandi.androidrealca.product.domain.repository.ProductRepository
9 | import io.ktor.client.HttpClient
10 | import io.ktor.client.call.body
11 | import io.ktor.client.request.*
12 | import io.ktor.client.statement.HttpResponse
13 | import io.ktor.http.*
14 |
15 | internal class RealProductRepository(
16 | private val httpClient: HttpClient
17 | ) : ProductRepository {
18 | override suspend fun getProducts(): Answer, Unit> {
19 | return try {
20 | val response =
21 | httpClient.get("https://api.json-generator.com/templates/Vc6TVI8VwZNT/data") {
22 | headers {
23 | append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
24 | val accessTokenHeader = AccessTokenProvider.getAccessTokenHeader()
25 | append(accessTokenHeader.first, accessTokenHeader.second)
26 | }
27 | }
28 | if (response.status.isSuccess()) {
29 | handleSuccessfulProductsResponse(response)
30 | } else {
31 | Answer.Error(Unit)
32 | }
33 | } catch (t: Throwable) {
34 | Answer.Error(Unit)
35 | }
36 | }
37 |
38 | private suspend fun handleSuccessfulProductsResponse(httpResponse: HttpResponse): Answer, Unit> {
39 | val responseBody = httpResponse.body>()
40 | return Answer.Success(mapProducts(responseBody))
41 | }
42 |
43 | private fun mapProducts(jsonProducts: List): List {
44 | return jsonProducts.map { jsonProduct ->
45 | Product(
46 | jsonProduct.id.toString(),
47 | jsonProduct.name,
48 | Money(jsonProduct.price, jsonProduct.currency),
49 | jsonProduct.imageUrl
50 | )
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/product-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/product/di/ProductComponentDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.product.di
2 |
3 | import com.denisbrandi.androidrealca.product.data.repository.RealProductRepository
4 | import com.denisbrandi.androidrealca.product.domain.usecase.GetProducts
5 | import io.ktor.client.HttpClient
6 |
7 | class ProductComponentDI(
8 | private val httpClient: HttpClient
9 | ) {
10 | private val productRepository by lazy {
11 | RealProductRepository(httpClient)
12 | }
13 |
14 | val getProducts by lazy {
15 | GetProducts(productRepository::getProducts)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/product-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/product/domain/model/Product.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.product.domain.model
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 |
5 | data class Product(
6 | val id: String,
7 | val name: String,
8 | val money: Money,
9 | val imageUrl: String
10 | )
11 |
--------------------------------------------------------------------------------
/product-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/product/domain/repository/ProductRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.product.domain.repository
2 |
3 | import com.denisbrandi.androidrealca.foundations.Answer
4 | import com.denisbrandi.androidrealca.product.domain.model.Product
5 |
6 | internal interface ProductRepository {
7 | suspend fun getProducts(): Answer, Unit>
8 | }
9 |
--------------------------------------------------------------------------------
/product-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/product/domain/usecase/GetProducts.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.product.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.foundations.Answer
4 | import com.denisbrandi.androidrealca.product.domain.model.Product
5 |
6 | fun interface GetProducts {
7 | suspend operator fun invoke(): Answer, Unit>
8 | }
9 |
--------------------------------------------------------------------------------
/scripts/installKtlint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 | curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.3.1/ktlint && chmod a+x ktlint && sudo mv ktlint /usr/local/bin/
--------------------------------------------------------------------------------
/scripts/ktlintCheck.sh:
--------------------------------------------------------------------------------
1 | ktlint "**/*.kt" "!**/generated/**" "!**/build/**" --color --color-name=RED
--------------------------------------------------------------------------------
/scripts/ktlintFormat.sh:
--------------------------------------------------------------------------------
1 | ktlint "**/*.kt" "!**/generated/**" "!**/build/**" -F --color --color-name=RED
--------------------------------------------------------------------------------
/scripts/runAllTests.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 | ./gradlew koverHtmlReportCustomDebug
4 | ./gradlew validateDebugScreenshotTest
5 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "Real Clean Architecture in Android"
23 | include(":app")
24 | include(":foundations")
25 | include(":user-component")
26 | include(":cache")
27 | include(":httpclient")
28 | include(":cache-test")
29 | include(":onboarding-ui")
30 | include(":flow-test-observer")
31 | include(":viewmodel")
32 | include(":coroutines-test-dispatcher")
33 | include(":product-component")
34 | include(":money-component")
35 | include(":wishlist-component")
36 | include(":cart-component")
37 | include(":designsystem")
38 | include(":plp-ui")
39 | include(":wishlist-ui")
40 | include(":cart-ui")
41 | include(":main-ui")
42 | include(":money-ui")
43 |
--------------------------------------------------------------------------------
/user-component/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/user-component/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | alias(libs.plugins.kotlin.serialization)
4 | }
5 |
6 | apply(from = "../coverage/kmpCoverageReport.gradle")
7 |
8 | kotlin {
9 | jvmToolchain(17)
10 | jvm()
11 | iosX64()
12 | iosArm64()
13 | iosSimulatorArm64()
14 | sourceSets {
15 | commonMain {
16 | dependencies {
17 | implementation(project(":foundations"))
18 | implementation(project(":cache"))
19 | implementation(project(":httpclient"))
20 | implementation(libs.ktor)
21 | implementation(libs.ktor.serialization)
22 | implementation(libs.ktor.content.negotiation)
23 | }
24 | }
25 | commonTest {
26 | dependencies {
27 | implementation(libs.kotlin.test)
28 | implementation(libs.coroutines.test)
29 | implementation(libs.netmock)
30 | implementation(project(":cache-test"))
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/data/model/JsonLoginRequestDTO.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.data.model
2 |
3 | import kotlinx.serialization.*
4 |
5 | @Serializable
6 | internal class JsonLoginRequestDTO(
7 | @SerialName("email") val email: String,
8 | @SerialName("password") val password: String
9 | )
10 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/data/model/JsonLoginResponseDTO.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.data.model
2 |
3 | import kotlinx.serialization.*
4 |
5 | @Serializable
6 | internal class JsonLoginResponseDTO(
7 | @SerialName("id") val id: String,
8 | @SerialName("fullName") val fullName: String
9 | )
10 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/data/model/JsonUserCacheDTO.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.data.model
2 |
3 | import kotlinx.serialization.*
4 |
5 | @Serializable
6 | internal data class JsonUserCacheDTO(
7 | @SerialName("id") val id: String,
8 | @SerialName("fullName") val fullName: String
9 | )
10 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/data/repository/RealUserRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.data.repository
2 |
3 | import com.denisbrandi.androidrealca.cache.*
4 | import com.denisbrandi.androidrealca.foundations.Answer
5 | import com.denisbrandi.androidrealca.httpclient.AccessTokenProvider
6 | import com.denisbrandi.androidrealca.user.data.model.*
7 | import com.denisbrandi.androidrealca.user.domain.model.*
8 | import com.denisbrandi.androidrealca.user.domain.repository.UserRepository
9 | import io.ktor.client.HttpClient
10 | import io.ktor.client.call.body
11 | import io.ktor.client.request.*
12 | import io.ktor.client.statement.HttpResponse
13 | import io.ktor.http.*
14 |
15 | internal class RealUserRepository(
16 | private val client: HttpClient,
17 | cacheProvider: CacheProvider
18 | ) : UserRepository {
19 |
20 | private val cachedObject: CachedObject by lazy {
21 | cacheProvider.getCachedObject(
22 | fileName = "user-cache",
23 | serializer = JsonUserCacheDTO.serializer(),
24 | defaultValue = DEFAULT_USER
25 | )
26 | }
27 |
28 | override suspend fun login(loginRequest: LoginRequest): Answer {
29 | return try {
30 | val response =
31 | client.post("https://api.json-generator.com/templates/Q7s_NUVpyBND/data") {
32 | headers {
33 | append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
34 | val accessTokenHeader = AccessTokenProvider.getAccessTokenHeader()
35 | append(accessTokenHeader.first, accessTokenHeader.second)
36 | }
37 | setBody(JsonLoginRequestDTO(loginRequest.email, loginRequest.password))
38 | }
39 | if (response.status.isSuccess()) {
40 | handleSuccessfulLoginResponse(response)
41 | } else {
42 | handleFailingLoginResponse(response)
43 | }
44 | } catch (t: Throwable) {
45 | t.printStackTrace()
46 | Answer.Error(LoginError.GenericError)
47 | }
48 | }
49 |
50 | private suspend fun handleSuccessfulLoginResponse(httpResponse: HttpResponse): Answer {
51 | val responseBody = httpResponse.body()
52 | cachedObject.put(JsonUserCacheDTO(responseBody.id, responseBody.fullName))
53 | return Answer.Success(Unit)
54 | }
55 |
56 | private fun handleFailingLoginResponse(httpResponse: HttpResponse): Answer {
57 | val error = if (httpResponse.status.value == 401) {
58 | LoginError.IncorrectCredentials
59 | } else {
60 | LoginError.GenericError
61 | }
62 | return Answer.Error(error)
63 | }
64 |
65 | override fun getUser(): User {
66 | val cachedUser = cachedObject.get()
67 | return User(cachedUser.id, cachedUser.fullName)
68 | }
69 |
70 | override fun isLoggedIn(): Boolean {
71 | return cachedObject.get() != DEFAULT_USER
72 | }
73 |
74 | companion object {
75 | val DEFAULT_USER = JsonUserCacheDTO("", "")
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/di/UserComponentDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.di
2 |
3 | import com.denisbrandi.androidrealca.cache.CacheProvider
4 | import com.denisbrandi.androidrealca.user.data.repository.RealUserRepository
5 | import com.denisbrandi.androidrealca.user.domain.usecase.*
6 | import io.ktor.client.HttpClient
7 |
8 | class UserComponentDI(
9 | private val httpClient: HttpClient,
10 | private val cacheProvider: CacheProvider
11 | ) {
12 |
13 | private val userRepository by lazy {
14 | RealUserRepository(httpClient, cacheProvider)
15 | }
16 |
17 | val login: Login by lazy {
18 | LoginUseCase(userRepository)
19 | }
20 |
21 | val getUser by lazy {
22 | GetUser(userRepository::getUser)
23 | }
24 |
25 | val isUserLoggedIn by lazy {
26 | IsUserLoggedIn(userRepository::isLoggedIn)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/domain/model/Email.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.model
2 |
3 | class Email(private val value: String) {
4 | fun isValid(): Boolean {
5 | return value.isNotBlank() && value.matches(Regex(EMAIL_ADDRESS_PATTERN))
6 | }
7 |
8 | private companion object {
9 | const val EMAIL_ADDRESS_PATTERN =
10 | "(?:[a-zA-Z0-9!#\$%&'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#\$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/domain/model/LoginError.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.model
2 |
3 | sealed interface LoginError {
4 | data object InvalidEmail : LoginError
5 | data object InvalidPassword : LoginError
6 | data object GenericError : LoginError
7 | data object IncorrectCredentials : LoginError
8 | }
9 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/domain/model/LoginRequest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.model
2 |
3 | data class LoginRequest(val email: String, val password: String)
4 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/domain/model/Password.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.model
2 |
3 | class Password(private val value: String) {
4 |
5 | fun isValid(): Boolean {
6 | return value.length >= 8
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/domain/model/User.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.model
2 |
3 | data class User(val id: String, val fullName: String)
4 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/domain/repository/UserRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.repository
2 |
3 | import com.denisbrandi.androidrealca.foundations.Answer
4 | import com.denisbrandi.androidrealca.user.domain.model.*
5 |
6 | internal interface UserRepository {
7 | suspend fun login(loginRequest: LoginRequest): Answer
8 | fun getUser(): User
9 | fun isLoggedIn(): Boolean
10 | }
11 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/domain/usecase/LoginUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.foundations.Answer
4 | import com.denisbrandi.androidrealca.user.domain.model.*
5 | import com.denisbrandi.androidrealca.user.domain.repository.UserRepository
6 |
7 | internal class LoginUseCase(
8 | private val userRepository: UserRepository
9 | ) : Login {
10 | override suspend fun invoke(loginRequest: LoginRequest): Answer {
11 | return when {
12 | !Email(loginRequest.email).isValid() -> {
13 | Answer.Error(LoginError.InvalidEmail)
14 | }
15 |
16 | !Password(loginRequest.password).isValid() -> {
17 | Answer.Error(LoginError.InvalidPassword)
18 | }
19 |
20 | else -> {
21 | return userRepository.login(loginRequest)
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/user-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/user/domain/usecase/UserUseCases.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.foundations.Answer
4 | import com.denisbrandi.androidrealca.user.domain.model.*
5 |
6 | fun interface Login {
7 | suspend operator fun invoke(loginRequest: LoginRequest): Answer
8 | }
9 |
10 | fun interface GetUser {
11 | operator fun invoke(): User
12 | }
13 |
14 | fun interface IsUserLoggedIn {
15 | operator fun invoke(): Boolean
16 | }
17 |
--------------------------------------------------------------------------------
/user-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/user/domain/model/EmailTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.model
2 |
3 | import kotlin.test.*
4 |
5 | class EmailTest {
6 |
7 | @Test
8 | fun `EXPECT invalid email WHEN no text entered`() {
9 | val result = Email("").isValid()
10 |
11 | assertFalse(result)
12 | }
13 |
14 | @Test
15 | fun `EXPECT invalid email WHEN blank text entered`() {
16 | val result = Email(" ").isValid()
17 |
18 | assertFalse(result)
19 | }
20 |
21 | @Test
22 | fun `EXPECT invalid email WHEN text entered`() {
23 | val result = Email("a").isValid()
24 |
25 | assertFalse(result)
26 | }
27 |
28 | @Test
29 | fun `EXPECT invalid email WHEN domain entered`() {
30 | val result = Email("test.com").isValid()
31 |
32 | assertFalse(result)
33 | }
34 |
35 | @Test
36 | fun `EXPECT invalid email WHEN at symbol entered`() {
37 | val result = Email("test@").isValid()
38 |
39 | assertFalse(result)
40 | }
41 |
42 | @Test
43 | fun `EXPECT valid email WHEN valid email entered`() {
44 | val result = Email("test@address.com").isValid()
45 |
46 | assertTrue(result)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/user-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/user/domain/model/PasswordTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.model
2 |
3 | import kotlin.test.*
4 |
5 | class PasswordTest {
6 |
7 | @Test
8 | fun `EXPECT valid password WHEN password has at least 8 characters`() {
9 | val result = Password("abcdefgh").isValid()
10 |
11 | assertTrue(result)
12 | }
13 |
14 | @Test
15 | fun `EXPECT invalid password WHEN password has less than 8 characters`() {
16 | val result = Password("abcdefg").isValid()
17 |
18 | assertFalse(result)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/user-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/user/domain/repository/TestUserRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.repository
2 |
3 | import com.denisbrandi.androidrealca.foundations.Answer
4 | import com.denisbrandi.androidrealca.user.domain.model.*
5 |
6 | class TestUserRepository : UserRepository {
7 | lateinit var expectedLoginRequest: LoginRequest
8 | lateinit var loginResult: Answer
9 |
10 | override suspend fun login(loginRequest: LoginRequest): Answer {
11 | return if (expectedLoginRequest == loginRequest) {
12 | loginResult
13 | } else {
14 | throw IllegalStateException("method called with not stubbed parameters")
15 | }
16 | }
17 |
18 | override fun getUser(): User {
19 | TODO("Not yet implemented")
20 | }
21 |
22 | override fun isLoggedIn(): Boolean {
23 | TODO("Not yet implemented")
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/user-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/user/domain/usecase/LoginUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.user.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.foundations.Answer
4 | import com.denisbrandi.androidrealca.user.domain.model.*
5 | import com.denisbrandi.androidrealca.user.domain.repository.TestUserRepository
6 | import kotlin.test.*
7 | import kotlinx.coroutines.test.runTest
8 |
9 | class LoginUseCaseTest {
10 |
11 | private val userRepository = TestUserRepository().also {
12 | it.expectedLoginRequest = VALID_LOGIN_REQUEST
13 | }
14 | private val sut = LoginUseCase(userRepository)
15 |
16 | @Test
17 | fun `EXPECT invalid email error WHEN login request has invalid email`() = runTest {
18 | val loginRequestWithInvalidEmail = LoginRequest("", "validPassword")
19 |
20 | val result = sut(loginRequestWithInvalidEmail)
21 |
22 | assertEquals(Answer.Error(LoginError.InvalidEmail), result)
23 | }
24 |
25 | @Test
26 | fun `EXPECT invalid password error WHEN login request has invalid password`() = runTest {
27 | val loginRequestWithInvalidPassword = LoginRequest("valid@email.com", "")
28 |
29 | val result = sut(loginRequestWithInvalidPassword)
30 |
31 | assertEquals(Answer.Error(LoginError.InvalidPassword), result)
32 | }
33 |
34 | @Test
35 | fun `EXPECT success WHEN login request is valid and repository returns success`() = runTest {
36 | val repositoryResult = Answer.Success(Unit)
37 | userRepository.loginResult = repositoryResult
38 |
39 | val result = sut(VALID_LOGIN_REQUEST)
40 |
41 | assertEquals(repositoryResult, result)
42 | }
43 |
44 | @Test
45 | fun `EXPECT error WHEN login request is valid and repository returns error`() = runTest {
46 | val repositoryResult = Answer.Error(LoginError.GenericError)
47 | userRepository.loginResult = repositoryResult
48 |
49 | val result = sut(VALID_LOGIN_REQUEST)
50 |
51 | assertEquals(repositoryResult, result)
52 | }
53 |
54 | private companion object {
55 | val VALID_LOGIN_REQUEST = LoginRequest("valid@email.com", "validPassword")
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/viewmodel/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/viewmodel/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | }
4 |
5 | kotlin {
6 | jvmToolchain(17)
7 | jvm()
8 | iosX64()
9 | iosArm64()
10 | iosSimulatorArm64()
11 | sourceSets {
12 | commonMain {
13 | dependencies {
14 | implementation(libs.coroutines.core)
15 | }
16 | }
17 | commonTest {
18 | dependencies {
19 | }
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/viewmodel/src/commonMain/kotlin/com/denisbrandi/androidrealca/viewmodel/ViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.viewmodel
2 |
3 | import kotlinx.coroutines.*
4 | import kotlinx.coroutines.flow.*
5 |
6 | interface StateViewModel {
7 | val state: StateFlow
8 | }
9 |
10 | interface EventViewModel {
11 | val viewEvent: Flow
12 | }
13 |
14 | class StateDelegate : StateViewModel {
15 | private lateinit var _state: MutableStateFlow
16 | override val state: StateFlow
17 | get() = _state.asStateFlow()
18 |
19 | fun setDefaultState(state: State) {
20 | _state = MutableStateFlow(state)
21 | }
22 |
23 | fun updateState(block: (State) -> State) {
24 | _state.update {
25 | block(it)
26 | }
27 | }
28 | }
29 |
30 | class EventDelegate : EventViewModel {
31 | private val _viewEvent = MutableSharedFlow()
32 | override val viewEvent: Flow = _viewEvent.asSharedFlow()
33 |
34 | fun sendEvent(scope: CoroutineScope, newEvent: ViewEvent) {
35 | scope.launch {
36 | _viewEvent.emit(newEvent)
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/wishlist-component/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/wishlist-component/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlin.multiplatform)
3 | alias(libs.plugins.kotlin.serialization)
4 | }
5 |
6 | apply(from = "../coverage/kmpCoverageReport.gradle")
7 |
8 | kotlin {
9 | jvmToolchain(17)
10 | jvm()
11 | iosX64()
12 | iosArm64()
13 | iosSimulatorArm64()
14 | sourceSets {
15 | commonMain {
16 | dependencies {
17 | implementation(libs.coroutines.core)
18 | implementation(libs.kotlin.serialization)
19 | implementation(project(":cache"))
20 | implementation(project(":money-component"))
21 | implementation(project(":user-component"))
22 | }
23 | }
24 | commonTest {
25 | dependencies {
26 | implementation(libs.kotlin.test)
27 | implementation(libs.coroutines.test)
28 | implementation(project(":cache-test"))
29 | implementation(project(":flow-test-observer"))
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/data/model/JsonWishlistCacheDto.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.data.model
2 |
3 | import kotlinx.serialization.*
4 |
5 | @Serializable
6 | data class JsonWishlistCacheDto(
7 | @SerialName("usersWishlist") val usersWishlist: Map>
8 | )
9 |
10 | @Serializable
11 | data class JsonWishlistItemCacheDTO(
12 | @SerialName("id") val id: String,
13 | @SerialName("name") val name: String,
14 | @SerialName("price") val price: Double,
15 | @SerialName("currency") val currency: String,
16 | @SerialName("imageUrl") val imageUrl: String
17 | )
18 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/data/repository/RealWishlistRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.data.repository
2 |
3 | import com.denisbrandi.androidrealca.cache.*
4 | import com.denisbrandi.androidrealca.money.domain.model.Money
5 | import com.denisbrandi.androidrealca.wishlist.data.model.*
6 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
7 | import com.denisbrandi.androidrealca.wishlist.domain.repository.WishlistRepository
8 | import kotlinx.coroutines.flow.*
9 |
10 | internal class RealWishlistRepository(
11 | private val cacheProvider: CacheProvider
12 | ) : WishlistRepository {
13 |
14 | private val flowCachedObject: FlowCachedObject by lazy {
15 | cacheProvider.getFlowCachedObject(
16 | fileName = "wishlist-cache",
17 | serializer = JsonWishlistCacheDto.serializer(),
18 | defaultValue = JsonWishlistCacheDto(emptyMap())
19 | )
20 | }
21 |
22 | override fun addToWishlist(userId: String, wishlistItem: WishlistItem) {
23 | val updatedCache = getUpdatedCacheForUser(userId) { userWishlist ->
24 | if (userWishlist.find { it.id == wishlistItem.id } == null) {
25 | userWishlist.add(mapToDto(wishlistItem))
26 | }
27 | }
28 | flowCachedObject.put(updatedCache)
29 | }
30 |
31 | override fun removeFromWishlist(userId: String, wishlistItemId: String) {
32 | val updatedCache = getUpdatedCacheForUser(userId) { userWishlist ->
33 | userWishlist.find { it.id == wishlistItemId }?.let { itemToRemove ->
34 | userWishlist.remove(itemToRemove)
35 | }
36 | }
37 | flowCachedObject.put(updatedCache)
38 | }
39 |
40 | private fun getUpdatedCacheForUser(
41 | userId: String,
42 | onUserWishlist: (MutableList) -> Unit
43 | ): JsonWishlistCacheDto {
44 | val usersWishlist = flowCachedObject.get().usersWishlist
45 | val userWishlist = usersWishlist[userId].orEmpty().toMutableList()
46 | return JsonWishlistCacheDto(
47 | usersWishlist = usersWishlist.toMutableMap().apply {
48 | put(
49 | userId,
50 | userWishlist.apply {
51 | onUserWishlist(this)
52 | }.toList()
53 | )
54 | }
55 | )
56 | }
57 |
58 | override fun observeWishlist(userId: String): Flow> {
59 | return flowCachedObject.observe().map { cachedDto ->
60 | mapToWishlistItems(cachedDto.usersWishlist[userId] ?: emptyList())
61 | }
62 | }
63 |
64 | private fun mapToWishlistItems(dtos: List): List {
65 | return dtos.map { dto ->
66 | WishlistItem(
67 | id = dto.id,
68 | name = dto.name,
69 | money = Money(dto.price, dto.currency),
70 | imageUrl = dto.imageUrl
71 | )
72 | }
73 | }
74 |
75 | private fun mapToDto(wishlistItem: WishlistItem): JsonWishlistItemCacheDTO {
76 | return JsonWishlistItemCacheDTO(
77 | id = wishlistItem.id,
78 | name = wishlistItem.name,
79 | price = wishlistItem.money.amount,
80 | currency = wishlistItem.money.currencySymbol,
81 | imageUrl = wishlistItem.imageUrl
82 | )
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/di/WishlistComponentDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.di
2 |
3 | import com.denisbrandi.androidrealca.cache.CacheProvider
4 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
5 | import com.denisbrandi.androidrealca.wishlist.data.repository.RealWishlistRepository
6 | import com.denisbrandi.androidrealca.wishlist.domain.usecase.*
7 |
8 | class WishlistComponentDI(
9 | private val cacheProvider: CacheProvider,
10 | private val getUser: GetUser
11 | ) {
12 |
13 | private val wishlistRepository by lazy {
14 | RealWishlistRepository(cacheProvider)
15 | }
16 |
17 | val addToWishlist: AddToWishlist by lazy {
18 | AddToWishlistUseCase(getUser, wishlistRepository)
19 | }
20 |
21 | val removeFromWishlist: RemoveFromWishlist by lazy {
22 | RemoveFromWishlistUseCase(getUser, wishlistRepository)
23 | }
24 |
25 | val observeUserWishlist: ObserveUserWishlist by lazy {
26 | ObserveUserWishlistUseCase(getUser, wishlistRepository)
27 | }
28 |
29 | val observeUserWishlistIds: ObserveUserWishlistIds by lazy {
30 | ObserveUserWishlistIdsUseCase(observeUserWishlist)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/domain/model/WishlistItem.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.model
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 |
5 | data class WishlistItem(
6 | val id: String,
7 | val name: String,
8 | val money: Money,
9 | val imageUrl: String
10 | )
11 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/domain/repository/WishlistRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.repository
2 |
3 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | internal interface WishlistRepository {
7 | fun addToWishlist(userId: String, wishlistItem: WishlistItem)
8 | fun removeFromWishlist(userId: String, wishlistItemId: String)
9 | fun observeWishlist(userId: String): Flow>
10 | }
11 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/AddToWishlistUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
4 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
5 | import com.denisbrandi.androidrealca.wishlist.domain.repository.WishlistRepository
6 |
7 | internal class AddToWishlistUseCase(
8 | private val getUser: GetUser,
9 | private val wishlistRepository: WishlistRepository
10 | ) : AddToWishlist {
11 | override fun invoke(wishlistItem: WishlistItem) {
12 | wishlistRepository.addToWishlist(getUser().id, wishlistItem)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/ObserveUserWishlistIdsUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import kotlinx.coroutines.flow.*
4 |
5 | internal class ObserveUserWishlistIdsUseCase(
6 | private val observeUserWishlist: ObserveUserWishlist
7 | ) : ObserveUserWishlistIds {
8 | override fun invoke(): Flow> {
9 | return observeUserWishlist().map { list -> list.map { item -> item.id } }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/ObserveUserWishlistUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
4 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
5 | import com.denisbrandi.androidrealca.wishlist.domain.repository.WishlistRepository
6 | import kotlinx.coroutines.flow.Flow
7 |
8 | internal class ObserveUserWishlistUseCase(
9 | private val getUser: GetUser,
10 | private val wishlistRepository: WishlistRepository
11 | ) : ObserveUserWishlist {
12 | override fun invoke(): Flow> {
13 | return wishlistRepository.observeWishlist(getUser().id)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/RemoveFromWishlistUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.user.domain.usecase.GetUser
4 | import com.denisbrandi.androidrealca.wishlist.domain.repository.WishlistRepository
5 |
6 | internal class RemoveFromWishlistUseCase(
7 | private val getUser: GetUser,
8 | private val wishlistRepository: WishlistRepository
9 | ) : RemoveFromWishlist {
10 | override fun invoke(wishlistItemId: String) {
11 | wishlistRepository.removeFromWishlist(getUser().id, wishlistItemId)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonMain/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/WishlistUseCases.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | fun interface AddToWishlist {
7 | operator fun invoke(wishlistItem: WishlistItem)
8 | }
9 |
10 | fun interface RemoveFromWishlist {
11 | operator fun invoke(wishlistItemId: String)
12 | }
13 |
14 | fun interface ObserveUserWishlist {
15 | operator fun invoke(): Flow>
16 | }
17 |
18 | fun interface ObserveUserWishlistIds {
19 | operator fun invoke(): Flow>
20 | }
21 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/wishlist/data/repository/RealWishlistRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.data.repository
2 |
3 | import com.denisbrandi.androidrealca.cache.test.TestCacheProvider
4 | import com.denisbrandi.androidrealca.flow.testobserver.test
5 | import com.denisbrandi.androidrealca.wishlist.data.model.JsonWishlistCacheDto
6 | import com.denisbrandi.androidrealca.wishlist.domain.model.makeWishlistItem
7 | import kotlin.test.*
8 |
9 | class RealWishlistRepositoryTest {
10 |
11 | private val cacheProvider = TestCacheProvider(
12 | "wishlist-cache",
13 | JsonWishlistCacheDto(emptyMap())
14 | )
15 | private val sut = RealWishlistRepository(cacheProvider)
16 |
17 | @Test
18 | fun `EXPECT data saved and wishlist updates emitted`() {
19 | val wishlistObserver = sut.observeWishlist(USER_ID).test()
20 |
21 | sut.addToWishlist(USER_ID, WISHLIST_ITEM)
22 |
23 | assertEquals(
24 | listOf(emptyList(), listOf(WISHLIST_ITEM)),
25 | wishlistObserver.getValues()
26 | )
27 | }
28 |
29 | @Test
30 | fun `EXPECT data added and wishlist updates emitted`() {
31 | val wishlistObserver = sut.observeWishlist(USER_ID).test()
32 | sut.addToWishlist(USER_ID, WISHLIST_ITEM)
33 |
34 | sut.addToWishlist(USER_ID, WISHLIST_ITEM.copy(id = "2"))
35 |
36 | assertEquals(
37 | listOf(
38 | emptyList(),
39 | listOf(WISHLIST_ITEM),
40 | listOf(WISHLIST_ITEM, WISHLIST_ITEM.copy(id = "2"))
41 | ),
42 | wishlistObserver.getValues()
43 | )
44 | }
45 |
46 | @Test
47 | fun `EXPECT no new emissions WHEN item to add is already in wishlist`() {
48 | val wishlistObserver = sut.observeWishlist(USER_ID).test()
49 | sut.addToWishlist(USER_ID, WISHLIST_ITEM)
50 |
51 | sut.addToWishlist(USER_ID, WISHLIST_ITEM)
52 |
53 | assertEquals(
54 | listOf(
55 | emptyList(),
56 | listOf(WISHLIST_ITEM),
57 | ),
58 | wishlistObserver.getValues()
59 | )
60 | }
61 |
62 | @Test
63 | fun `EXPECT no new emissions WHEN item to remove is not in wishlist`() {
64 | val wishlistObserver = sut.observeWishlist(USER_ID).test()
65 | sut.addToWishlist(USER_ID, WISHLIST_ITEM)
66 |
67 | sut.removeFromWishlist(USER_ID, "id not present")
68 |
69 | assertEquals(
70 | listOf(
71 | emptyList(),
72 | listOf(WISHLIST_ITEM),
73 | ),
74 | wishlistObserver.getValues()
75 | )
76 | }
77 |
78 | @Test
79 | fun `EXPECT data removed WHEN item to remove is in wishlist`() {
80 | val wishlistObserver = sut.observeWishlist(USER_ID).test()
81 | sut.addToWishlist(USER_ID, WISHLIST_ITEM)
82 |
83 | sut.removeFromWishlist(USER_ID, WISHLIST_ITEM.id)
84 |
85 | assertEquals(
86 | listOf(
87 | emptyList(),
88 | listOf(WISHLIST_ITEM),
89 | emptyList()
90 | ),
91 | wishlistObserver.getValues()
92 | )
93 | }
94 |
95 | private companion object {
96 | const val USER_ID = "1234"
97 | val WISHLIST_ITEM = makeWishlistItem()
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/wishlist/domain/model/WishlistItemFixtures.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.model
2 |
3 | import com.denisbrandi.androidrealca.money.domain.model.Money
4 |
5 | fun makeWishlistItem(): WishlistItem {
6 | return WishlistItem(
7 | id = "1",
8 | name = "Wireless Headphones",
9 | money = Money(99.99, "$"),
10 | imageUrl = "https://example.com/images/wireless-headphones.jpg"
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/wishlist/domain/repository/TestWishlistRepository.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.repository
2 |
3 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | class TestWishlistRepository : WishlistRepository {
7 | val addToWishlistInvocations: MutableList> = mutableListOf()
8 | val removeFromWishlistInvocations: MutableList> = mutableListOf()
9 | val wishlistUpdates = mutableMapOf>>()
10 |
11 | override fun addToWishlist(userId: String, wishlistItem: WishlistItem) {
12 | addToWishlistInvocations.add(userId to wishlistItem)
13 | }
14 |
15 | override fun removeFromWishlist(userId: String, wishlistItemId: String) {
16 | removeFromWishlistInvocations.add(userId to wishlistItemId)
17 | }
18 |
19 | override fun observeWishlist(userId: String): Flow> {
20 | return wishlistUpdates[userId] ?: throw IllegalStateException("no stubbing for userId")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/AddToWishlistUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.user.domain.model.User
4 | import com.denisbrandi.androidrealca.wishlist.domain.model.makeWishlistItem
5 | import com.denisbrandi.androidrealca.wishlist.domain.repository.TestWishlistRepository
6 | import kotlin.test.*
7 |
8 | class AddToWishlistUseCaseTest {
9 |
10 | private val testWishlistRepository = TestWishlistRepository()
11 | private val sut = AddToWishlistUseCase({ USER }, testWishlistRepository)
12 |
13 | @Test
14 | fun `EXPECT delegation to repository`() {
15 | sut(WISHLIST_ITEM)
16 |
17 | assertEquals(
18 | listOf(USER_ID to WISHLIST_ITEM),
19 | testWishlistRepository.addToWishlistInvocations
20 | )
21 | }
22 |
23 | private companion object {
24 | const val USER_ID = "1234"
25 | val USER = User(USER_ID, "")
26 | val WISHLIST_ITEM = makeWishlistItem()
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/ObserveUserWishlistIdsUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.flow.testobserver.test
4 | import com.denisbrandi.androidrealca.wishlist.domain.model.*
5 | import kotlin.test.*
6 | import kotlinx.coroutines.flow.*
7 | import kotlinx.coroutines.test.runTest
8 |
9 | class ObserveUserWishlistIdsUseCaseTest {
10 |
11 | private val observeUserWishlist = TestObserveUserWishlist()
12 | private val sut = ObserveUserWishlistIdsUseCase(observeUserWishlist)
13 |
14 | @Test
15 | fun `EXPECT list of ids`() = runTest {
16 | observeUserWishlist.wishlistUpdates = flowOf(emptyList(), WISHLIST_ITEMS)
17 |
18 | val testObserver = sut().test()
19 |
20 | assertEquals(
21 | listOf(emptyList(), WISHLIST_ITEMS_IDS),
22 | testObserver.getValues()
23 | )
24 | }
25 |
26 | private class TestObserveUserWishlist : ObserveUserWishlist {
27 | lateinit var wishlistUpdates: Flow>
28 | override fun invoke(): Flow> = wishlistUpdates
29 | }
30 |
31 | private companion object {
32 | val WISHLIST_ITEMS = listOf(makeWishlistItem())
33 | val WISHLIST_ITEMS_IDS = listOf("1")
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/ObserveUserWishlistTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.flow.testobserver.test
4 | import com.denisbrandi.androidrealca.user.domain.model.User
5 | import com.denisbrandi.androidrealca.wishlist.domain.model.makeWishlistItem
6 | import com.denisbrandi.androidrealca.wishlist.domain.repository.TestWishlistRepository
7 | import kotlin.test.*
8 | import kotlinx.coroutines.flow.flowOf
9 |
10 | class ObserveUserWishlistTest {
11 | private val testWishlistRepository = TestWishlistRepository()
12 | private val sut = ObserveUserWishlistUseCase({ USER }, testWishlistRepository)
13 |
14 | @Test
15 | fun `EXPECT wishlist updates`() {
16 | testWishlistRepository.wishlistUpdates[USER_ID] = flowOf(emptyList(), WISHLIST_ITEMS)
17 |
18 | val testObserver = sut().test()
19 |
20 | assertEquals(
21 | listOf(emptyList(), WISHLIST_ITEMS),
22 | testObserver.getValues()
23 | )
24 | }
25 |
26 | private companion object {
27 | const val USER_ID = "1234"
28 | val USER = User(USER_ID, "")
29 | val WISHLIST_ITEMS = listOf(makeWishlistItem())
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/wishlist-component/src/commonTest/kotlin/com/denisbrandi/androidrealca/wishlist/domain/usecase/RemoveFromWishlistUseCaseTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.domain.usecase
2 |
3 | import com.denisbrandi.androidrealca.user.domain.model.User
4 | import com.denisbrandi.androidrealca.wishlist.domain.repository.TestWishlistRepository
5 | import kotlin.test.*
6 |
7 | class RemoveFromWishlistUseCaseTest {
8 | private val testWishlistRepository = TestWishlistRepository()
9 | private val sut = RemoveFromWishlistUseCase({ USER }, testWishlistRepository)
10 |
11 | @Test
12 | fun `EXPECT delegation to repository`() {
13 | sut(WISHLIST_ITEM_ID)
14 |
15 | assertEquals(
16 | listOf(USER_ID to WISHLIST_ITEM_ID),
17 | testWishlistRepository.removeFromWishlistInvocations
18 | )
19 | }
20 |
21 | private companion object {
22 | const val USER_ID = "1234"
23 | val USER = User(USER_ID, "")
24 | const val WISHLIST_ITEM_ID = "1"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/wishlist-ui/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/wishlist-ui/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | alias(libs.plugins.screenshot)
6 | }
7 |
8 | apply(from = "../coverage/androidCoverageReport.gradle")
9 |
10 | android {
11 | namespace = "com.denisbrandi.androidrealca.wishlist.ui"
12 | compileSdk = 35
13 |
14 | defaultConfig {
15 | minSdk = 24
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(
25 | getDefaultProguardFile("proguard-android-optimize.txt"),
26 | "proguard-rules.pro"
27 | )
28 | }
29 | }
30 | compileOptions {
31 | sourceCompatibility = JavaVersion.VERSION_17
32 | targetCompatibility = JavaVersion.VERSION_17
33 | }
34 | kotlinOptions {
35 | jvmTarget = "17"
36 | }
37 | buildFeatures {
38 | compose = true
39 | }
40 | composeOptions {
41 | kotlinCompilerExtensionVersion = libs.versions.kotlin.compiler.extension.get()
42 | }
43 | experimentalProperties["android.experimental.enableScreenshotTest"] = true
44 | }
45 |
46 | dependencies {
47 | implementation(project(":foundations"))
48 | implementation(project(":money-component"))
49 | implementation(project(":wishlist-component"))
50 | implementation(project(":cart-component"))
51 | implementation(project(":money-ui"))
52 | implementation(project(":viewmodel"))
53 | implementation(project(":designsystem"))
54 | implementation(libs.coroutines.core)
55 | implementation(libs.lifecycle.viewmodel)
56 | implementation(libs.androidx.runtime.android)
57 |
58 | implementation(platform(libs.androidx.compose.bom))
59 | implementation(libs.androidx.ui.tooling.preview)
60 | implementation(libs.androidx.material3)
61 | debugImplementation(libs.androidx.ui.tooling)
62 | debugImplementation(libs.androidx.ui.test.manifest)
63 | implementation(libs.androidx.core.ktx)
64 |
65 | implementation(libs.coil.compose)
66 | implementation(libs.coil.okhttp)
67 |
68 | testImplementation(libs.junit)
69 | testImplementation(libs.coroutines.test)
70 | testImplementation(project(":flow-test-observer"))
71 | testImplementation(project(":coroutines-test-dispatcher"))
72 |
73 | screenshotTestImplementation(libs.androidx.ui.tooling)
74 | }
75 |
--------------------------------------------------------------------------------
/wishlist-ui/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/wishlist-ui/consumer-rules.pro
--------------------------------------------------------------------------------
/wishlist-ui/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/wishlist-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/wishlist/presentation/view/WishlistScreenPreviewsKt/PreviewPLPEmptyState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/wishlist-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/wishlist/presentation/view/WishlistScreenPreviewsKt/PreviewPLPEmptyState_0.png
--------------------------------------------------------------------------------
/wishlist-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/wishlist/presentation/view/WishlistScreenPreviewsKt/PreviewPLPProductsState_0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DenisBronx/Real-Clean-Architecture-In-Android---Sample/5b69453ff688f34caf0b04c3ccde77c133cfdfae/wishlist-ui/src/debug/screenshotTest/reference/com/denisbrandi/androidrealca/wishlist/presentation/view/WishlistScreenPreviewsKt/PreviewPLPProductsState_0.png
--------------------------------------------------------------------------------
/wishlist-ui/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/wishlist-ui/src/main/java/com/denisbrandi/androidrealca/wishlist/di/WishlistUIDI.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.di
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.lifecycle.viewmodel.compose.viewModel
5 | import com.denisbrandi.androidrealca.cart.domain.usecase.AddCartItem
6 | import com.denisbrandi.androidrealca.viewmodel.StateDelegate
7 | import com.denisbrandi.androidrealca.wishlist.presentation.view.WishlistScreen
8 | import com.denisbrandi.androidrealca.wishlist.presentation.viewmodel.*
9 |
10 | class WishlistUIDI(
11 | private val wishlistComponentDI: WishlistComponentDI,
12 | private val addCartItem: AddCartItem
13 | ) {
14 | @Composable
15 | private fun makeWishlistViewModel(): WishlistViewModel {
16 | return viewModel {
17 | RealWishlistViewModel(
18 | wishlistComponentDI.observeUserWishlist,
19 | wishlistComponentDI.removeFromWishlist,
20 | addCartItem,
21 | StateDelegate()
22 | )
23 | }
24 | }
25 |
26 | @Composable
27 | fun WishlistScreenDI() {
28 | WishlistScreen(makeWishlistViewModel())
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/wishlist-ui/src/main/java/com/denisbrandi/androidrealca/wishlist/presentation/viewmodel/RealWishlistViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.presentation.viewmodel
2 |
3 | import androidx.lifecycle.*
4 | import com.denisbrandi.androidrealca.cart.domain.model.CartItem
5 | import com.denisbrandi.androidrealca.cart.domain.usecase.*
6 | import com.denisbrandi.androidrealca.viewmodel.*
7 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
8 | import com.denisbrandi.androidrealca.wishlist.domain.usecase.*
9 | import kotlinx.coroutines.flow.*
10 |
11 | internal class RealWishlistViewModel(
12 | observeUserWishlist: ObserveUserWishlist,
13 | private val removeFromWishlist: RemoveFromWishlist,
14 | private val addCartItem: AddCartItem,
15 | private val stateDelegate: StateDelegate
16 | ) : WishlistViewModel, StateViewModel by stateDelegate, ViewModel() {
17 | init {
18 | stateDelegate.setDefaultState(WishlistScreenState())
19 | observeUserWishlist().onEach { wishlistItems ->
20 | stateDelegate.updateState { WishlistScreenState(wishlistItems) }
21 | }.launchIn(viewModelScope)
22 | }
23 |
24 | override fun removeItemFromWishlist(wishlistItemId: String) {
25 | removeFromWishlist(wishlistItemId)
26 | }
27 |
28 | override fun addProductToCart(wishlistItem: WishlistItem) {
29 | addCartItem(
30 | CartItem(
31 | id = wishlistItem.id,
32 | name = wishlistItem.name,
33 | money = wishlistItem.money,
34 | imageUrl = wishlistItem.imageUrl,
35 | quantity = 1
36 | )
37 | )
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/wishlist-ui/src/main/java/com/denisbrandi/androidrealca/wishlist/presentation/viewmodel/WishlistViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.presentation.viewmodel
2 |
3 | import com.denisbrandi.androidrealca.viewmodel.StateViewModel
4 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
5 |
6 | internal interface WishlistViewModel : StateViewModel {
7 | fun removeItemFromWishlist(wishlistItemId: String)
8 |
9 | fun addProductToCart(wishlistItem: WishlistItem)
10 | }
11 |
12 | data class WishlistScreenState(val wishlistItems: List = emptyList())
13 |
--------------------------------------------------------------------------------
/wishlist-ui/src/main/res/drawable/baseline_delete_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/wishlist-ui/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Your Wishlist
4 | You haven\'t added any item to your wishlist yet
5 |
6 |
--------------------------------------------------------------------------------
/wishlist-ui/src/screenshotTest/kotlin/com/denisbrandi/androidrealca/wishlist/presentation/view/WishlistScreenPreviews.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.presentation.view
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.tooling.preview.Preview
5 | import com.denisbrandi.androidrealca.viewmodel.StateViewModel
6 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
7 | import com.denisbrandi.androidrealca.wishlist.presentation.viewmodel.*
8 | import kotlinx.coroutines.flow.*
9 |
10 | @Preview
11 | @Composable
12 | fun PreviewPLPEmptyState() {
13 | WishlistScreen(createViewModelWithState(WishlistScreenState(emptyList())))
14 | }
15 |
16 | @Preview
17 | @Composable
18 | fun PreviewPLPProductsState() {
19 | WishlistScreen(createViewModelWithState(WishlistScreenState(wishlist)))
20 | }
21 |
22 | private fun createViewModelWithState(state: WishlistScreenState): WishlistViewModel {
23 | return TestWishlistViewModel(MutableStateFlow(state))
24 | }
25 |
26 | private class TestWishlistViewModel(
27 | stateFlow: StateFlow
28 | ) : WishlistViewModel,
29 | StateViewModel {
30 | override val state = stateFlow
31 |
32 | override fun removeItemFromWishlist(wishlistItemId: String) {}
33 |
34 | override fun addProductToCart(wishlistItem: WishlistItem) {}
35 | }
36 |
--------------------------------------------------------------------------------
/wishlist-ui/src/test/java/com/denisbrandi/androidrealca/wishlist/presentation/viewmodel/RealWishlistViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.denisbrandi.androidrealca.wishlist.presentation.viewmodel
2 |
3 | import com.denisbrandi.androidrealca.cart.domain.model.CartItem
4 | import com.denisbrandi.androidrealca.cart.domain.usecase.AddCartItem
5 | import com.denisbrandi.androidrealca.coroutines.testdispatcher.MainCoroutineRule
6 | import com.denisbrandi.androidrealca.flow.testobserver.*
7 | import com.denisbrandi.androidrealca.money.domain.model.Money
8 | import com.denisbrandi.androidrealca.viewmodel.StateDelegate
9 | import com.denisbrandi.androidrealca.wishlist.domain.model.WishlistItem
10 | import com.denisbrandi.androidrealca.wishlist.domain.usecase.*
11 | import kotlinx.coroutines.flow.*
12 | import kotlinx.coroutines.test.runTest
13 | import org.junit.*
14 | import org.junit.Assert.assertEquals
15 |
16 | class RealWishlistViewModelTest {
17 |
18 | @get:Rule
19 | val rule = MainCoroutineRule()
20 |
21 | private val observeUserWishlist = TestObserveUserWishlist()
22 | private val removeFromWishlist = TestRemoveFromWishlist()
23 | private val addCartItem = TestAddCartItem()
24 | private lateinit var sut: RealWishlistViewModel
25 | private lateinit var stateObserver: FlowTestObserver
26 |
27 | @Before
28 | fun setUp() {
29 | sut = RealWishlistViewModel(
30 | observeUserWishlist,
31 | removeFromWishlist,
32 | addCartItem,
33 | StateDelegate()
34 | )
35 | stateObserver = sut.state.test()
36 | }
37 |
38 | @Test
39 | fun `EXPECT wishlist updates`() = runTest {
40 | observeUserWishlist.wishlistUpdates.emit(WISHLIST_ITEMS)
41 |
42 | assertEquals(
43 | listOf(
44 | WishlistScreenState(emptyList()),
45 | WishlistScreenState(WISHLIST_ITEMS)
46 | ),
47 | stateObserver.getValues()
48 | )
49 | }
50 |
51 | @Test
52 | fun `EXPECT item removed from wishlist`() = runTest {
53 | sut.removeItemFromWishlist("1")
54 |
55 | assertEquals(listOf("1"), removeFromWishlist.invocations)
56 | }
57 |
58 | @Test
59 | fun `EXPECT item added to cart`() {
60 | sut.addProductToCart(WISHLIST_ITEM)
61 |
62 | assertEquals(listOf(CART_ITEM), addCartItem.invocations)
63 | }
64 |
65 | private class TestObserveUserWishlist : ObserveUserWishlist {
66 | val wishlistUpdates = MutableStateFlow(emptyList())
67 | override fun invoke(): Flow> = wishlistUpdates
68 | }
69 |
70 | private class TestRemoveFromWishlist : RemoveFromWishlist {
71 | val invocations = mutableListOf()
72 | override fun invoke(wishlistItemId: String) {
73 | invocations.add(wishlistItemId)
74 | }
75 | }
76 |
77 | private class TestAddCartItem : AddCartItem {
78 | val invocations = mutableListOf()
79 | override fun invoke(cartItem: CartItem) {
80 | invocations.add(cartItem)
81 | }
82 | }
83 |
84 | private companion object {
85 | val WISHLIST_ITEM = WishlistItem(
86 | id = "1",
87 | name = "Wireless Headphones",
88 | money = Money(99.99, "$"),
89 | imageUrl = "https://m.media-amazon.com/images/I/61fU3njgzZL._AC_SL1500_.jpg"
90 | )
91 | val WISHLIST_ITEMS = listOf(WISHLIST_ITEM)
92 | val CART_ITEM = CartItem(
93 | "1",
94 | "Wireless Headphones",
95 | Money(99.99, "$"),
96 | "https://m.media-amazon.com/images/I/61fU3njgzZL._AC_SL1500_.jpg",
97 | quantity = 1
98 | )
99 | }
100 | }
101 |
--------------------------------------------------------------------------------