├── .github
└── workflows
│ └── ci-workflow.yml
├── .gitignore
├── .idea
├── .gitignore
├── .name
├── compiler.xml
├── deploymentTargetDropDown.xml
├── gradle.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── kotlinc.xml
├── misc.xml
└── vcs.xml
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── maruchin
│ │ │ └── domaindrivenandroid
│ │ │ ├── MyApplication.kt
│ │ │ ├── core
│ │ │ ├── CoroutinesModule.kt
│ │ │ ├── DataStoreModule.kt
│ │ │ └── RetrofitModule.kt
│ │ │ ├── data
│ │ │ ├── DataModule.kt
│ │ │ ├── account
│ │ │ │ ├── Account.kt
│ │ │ │ ├── AccountRepository.kt
│ │ │ │ ├── api
│ │ │ │ │ ├── AccountApi.kt
│ │ │ │ │ ├── AccountJson.kt
│ │ │ │ │ └── FakeAccountApi.kt
│ │ │ │ └── storage
│ │ │ │ │ ├── AccountStorage.kt
│ │ │ │ │ ├── DefaultAccountStorage.kt
│ │ │ │ │ └── FakeAccountStorage.kt
│ │ │ ├── coupon
│ │ │ │ ├── ActivationCode.kt
│ │ │ │ ├── Coupon.kt
│ │ │ │ ├── CouponsRepository.kt
│ │ │ │ ├── api
│ │ │ │ │ ├── CouponJson.kt
│ │ │ │ │ ├── CouponsApi.kt
│ │ │ │ │ └── FakeCouponsApi.kt
│ │ │ │ └── factory
│ │ │ │ │ ├── ActivationCodeFactory.kt
│ │ │ │ │ ├── DefaultActivationCodeFactory.kt
│ │ │ │ │ └── FakeActivationCodeFactory.kt
│ │ │ ├── registrationrequest
│ │ │ │ ├── RegistrationRequest.kt
│ │ │ │ └── RegistrationRequestRepository.kt
│ │ │ └── values
│ │ │ │ ├── Email.kt
│ │ │ │ ├── ID.kt
│ │ │ │ ├── Name.kt
│ │ │ │ └── Points.kt
│ │ │ ├── domain
│ │ │ ├── coupon
│ │ │ │ ├── CollectCouponUseCase.kt
│ │ │ │ ├── CollectableCoupon.kt
│ │ │ │ ├── GetAllCollectableCouponsUseCase.kt
│ │ │ │ └── GetCollectableCouponUseCase.kt
│ │ │ └── registrationrequest
│ │ │ │ ├── AcceptRegistrationTermsAndConditionsUseCase.kt
│ │ │ │ ├── CompleteRegistrationUseCase.kt
│ │ │ │ └── StartNewRegistrationUseCase.kt
│ │ │ └── ui
│ │ │ ├── FieldErrorView.kt
│ │ │ ├── Formatter.kt
│ │ │ ├── Logger.kt
│ │ │ ├── Placeholder.kt
│ │ │ ├── Theme.kt
│ │ │ ├── completeregistration
│ │ │ ├── CompleteRegistrationDestination.kt
│ │ │ ├── CompleteRegistrationScreen.kt
│ │ │ ├── CompleteRegistrationUiState.kt
│ │ │ └── CompleteRegistrationViewModel.kt
│ │ │ ├── couponPreview
│ │ │ ├── CouponPreviewDestination.kt
│ │ │ ├── CouponPreviewScreen.kt
│ │ │ ├── CouponPreviewUiState.kt
│ │ │ └── CouponPreviewViewModel.kt
│ │ │ ├── home
│ │ │ ├── HomeDestination.kt
│ │ │ ├── HomeScreen.kt
│ │ │ ├── HomeUiState.kt
│ │ │ └── HomeViewModel.kt
│ │ │ ├── main
│ │ │ ├── MainActivity.kt
│ │ │ └── MainViewModel.kt
│ │ │ ├── navigation
│ │ │ ├── HomeGraph.kt
│ │ │ ├── MainNavHost.kt
│ │ │ └── RegistrationGraph.kt
│ │ │ ├── personaldataform
│ │ │ ├── EmailFieldState.kt
│ │ │ ├── PersonalDataFormDestination.kt
│ │ │ ├── PersonalDataFormScreen.kt
│ │ │ ├── PersonalDataFormUiState.kt
│ │ │ └── PersonalDataFormViewModel.kt
│ │ │ ├── termsandconditions
│ │ │ ├── TermsAndConditionsDestination.kt
│ │ │ ├── TermsAndConditionsScreen.kt
│ │ │ ├── TermsAndConditionsUiState.kt
│ │ │ └── TermsAndConditionsViewModel.kt
│ │ │ └── welcome
│ │ │ ├── WelcomeDestination.kt
│ │ │ └── WelcomeScreen.kt
│ └── res
│ │ ├── drawable
│ │ ├── all_good.jpeg
│ │ ├── ic_launcher_background.xml
│ │ ├── ic_launcher_foreground.xml
│ │ └── welcome.jpeg
│ │ ├── mipmap-anydpi
│ │ ├── 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
│ └── test
│ └── java
│ └── com
│ └── maruchin
│ └── domaindrivenandroid
│ ├── data
│ ├── account
│ │ ├── AccountRepositoryTest.kt
│ │ ├── AccountTest.kt
│ │ └── api
│ │ │ └── AccountJsonTest.kt
│ ├── coupon
│ │ ├── ActivationCodeTest.kt
│ │ ├── CouponTest.kt
│ │ ├── CouponsRepositoryTest.kt
│ │ ├── api
│ │ │ └── CouponJsonTest.kt
│ │ └── factory
│ │ │ └── DefaultActivationCodeFactoryTest.kt
│ ├── registrationrequest
│ │ ├── RegistrationRequestRepositoryTest.kt
│ │ └── RegistrationRequestTest.kt
│ └── values
│ │ └── PointsTest.kt
│ ├── domain
│ ├── coupon
│ │ ├── CollectCouponUseCaseTest.kt
│ │ ├── GetAllCollectableCouponsUseCaseTest.kt
│ │ └── GetCollectableCouponUseCaseTest.kt
│ └── registrationrequest
│ │ ├── AcceptRegistrationTermsAndConditionsUseCaseTest.kt
│ │ ├── CompleteRegistrationUseCaseTest.kt
│ │ └── StartNewRegistrationUseCaseTest.kt
│ └── ui
│ ├── completeregistration
│ └── CompleteRegistrationViewModelTest.kt
│ ├── couponPreview
│ └── CouponPreviewViewModelTest.kt
│ ├── home
│ └── HomeViewModelTest.kt
│ ├── main
│ └── MainViewModelTest.kt
│ ├── personaldataform
│ └── PersonalDataFormViewModelTest.kt
│ └── termsandconditions
│ └── TermsAndConditionsViewModelTest.kt
├── build.gradle.kts
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── images
├── cheesburger_with_fries_coupon.jpeg
├── chicken_nuggets_with_fries_coupon.jpeg
├── chickenburger_with_fries_coupon.jpeg
├── two_milkshakes_coupon.jpeg
└── two_soda_drinks_coupon.jpeg
├── json
└── coupons.json
└── settings.gradle.kts
/.github/workflows/ci-workflow.yml:
--------------------------------------------------------------------------------
1 | name: 'CI'
2 |
3 | on:
4 | push:
5 |
6 | jobs:
7 | build:
8 | name: 'Build'
9 | runs-on: macos-latest
10 | steps:
11 | - uses: actions/checkout@v3
12 |
13 | - uses: actions/setup-java@v3
14 | with:
15 | distribution: temurin
16 | java-version: 17
17 |
18 | - name: 'Setup Gradle'
19 | uses: gradle/gradle-build-action@v2
20 |
21 | - name: 'All Tests'
22 | run: ./gradlew testDebugUnitTest
23 |
24 | - name: 'Bundle Android'
25 | run: ./gradlew bundle
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/.name:
--------------------------------------------------------------------------------
1 | Domain Driven Android
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/deploymentTargetDropDown.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Domain Driven Android
2 |
3 | [](https://github.com/Maruchin1/domain-driven-android/actions/workflows/ci-workflow.yml)
4 |
5 | # Building a Model which makes sense
6 |
7 | During countless discussions regarding the best Android architecture we often forget about principles of an object-oriented programming and proper domain modeling.
8 |
9 | This repository contains an example of a Domain Driven Design in the Android application. You can find more details and an in-depth explanation in the [Medium article](https://medium.com/@maruchin/domain-driven-android-building-a-model-which-makes-sense-badb774c606d).
10 |
11 | # Structure of the app
12 |
13 | For a sake of simplicity the app is built in a single `app` module. It's struture can be scaled up and divided into multiple modules if needed.
14 |
15 | Application follows an official [guide to app architecture](https://developer.android.com/topic/architecture) and is split into 4 major architectural layers:
16 |
17 | - `ui` - Screens, ViewModels, Theme
18 | - `domain` - Use Cases and corresponding data structures
19 | - `data` - The main Model of the applicaton with corresponding Repositories
20 | - `core` - Setup for [Coroutines](https://kotlinlang.org/docs/coroutines-overview.html), [Data Store](https://developer.android.com/topic/libraries/architecture/datastore) and [Retrofit](https://square.github.io/retrofit/)
21 |
22 | # Tech stack
23 |
24 | - [Jetpack Compose + View Model](https://developer.android.com/jetpack/compose?gclid=CjwKCAjw6IiiBhAOEiwALNqncXeI1D4qospRfSBTQylLhzj6cN2u7US96zsQ9fULwqPqb3mDQHajzxoCGVgQAvD_BwE&gclsrc=aw.ds) for the UI
25 | - [Coroutines + Flow](https://kotlinlang.org/docs/coroutines-overview.html#tutorials) for an asynchronous processing
26 | - [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for a Dependency Injection
27 | - [Retrofit](https://square.github.io/retrofit/) for an HTTP communication
28 | - [Data Store](https://developer.android.com/topic/libraries/architecture/datastore) for a simple key-value persistance
29 |
30 | # What does this app do?
31 |
32 | It is a loyalty application for the fast-food type of restaurant, inspired by the McDonalds app, where user can collect points and exchange them for the coupons.
33 |
34 | 
35 |
36 | ## Browsing available coupons
37 |
38 | User can browse the list of available coupons which are ordered from the cheapest to the most valueable one.
39 |
40 | Each coupon can be clicked and then the full screen preview of it is displayed.
41 |
42 | ## Exchanging collected points for the coupons
43 |
44 | For buying the food user collects the points. Each coupon in the app has a value expreseed in points.
45 |
46 | User can use collected points and exchange them for the coupons.
47 |
48 | ## Activation of the coupon
49 |
50 | When user exchanges points for the coupon this coupon becomes active for 60 seconds.
51 |
52 | During this time an activation code is displayed on the screen and user can use it in the restaurant to receive a free meal.
53 |
54 | ## Creating an account
55 |
56 | To collect the points user needs an account. Account can be created directly in the application.
57 |
58 | User needs to enter an email address and accept Terms & Conditions notes.
59 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.com.android.application)
3 | alias(libs.plugins.org.jetbrains.kotlin.android)
4 | kotlin("kapt")
5 | alias(libs.plugins.hilt.android)
6 | }
7 |
8 | android {
9 | namespace = "com.maruchin.domaindrivenandroid"
10 | compileSdk = 33
11 |
12 | defaultConfig {
13 | applicationId = "com.maruchin.domaindrivenandroid"
14 | minSdk = 26
15 | targetSdk = 33
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_1_8
36 | targetCompatibility = JavaVersion.VERSION_1_8
37 | }
38 | kotlinOptions {
39 | jvmTarget = "1.8"
40 | }
41 | buildFeatures {
42 | compose = true
43 | }
44 | composeOptions {
45 | kotlinCompilerExtensionVersion = "1.3.2"
46 | }
47 | packaging {
48 | resources {
49 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
50 | }
51 | }
52 | }
53 |
54 | dependencies {
55 |
56 | implementation(libs.core.ktx)
57 | implementation(libs.lifecycle.runtime.ktx)
58 | implementation(libs.activity.compose)
59 | implementation(platform(libs.compose.bom))
60 | implementation(libs.ui)
61 | implementation(libs.ui.graphics)
62 | implementation(libs.ui.tooling.preview)
63 | implementation(libs.material3)
64 | implementation(libs.material.icons)
65 | implementation(libs.navigation.compose)
66 | implementation(libs.hilt.android)
67 | implementation(libs.hilt.navigation.compose)
68 | implementation(libs.coil)
69 | implementation(libs.accompanist.placeholder)
70 | implementation(libs.retrofit)
71 | implementation(libs.retrofit.gson)
72 | implementation(libs.datastore.preferences)
73 | kapt(libs.hilt.android.compiler)
74 | testImplementation(libs.junit)
75 | testImplementation(libs.coroutines.test)
76 | testImplementation(libs.turbine)
77 | androidTestImplementation(libs.androidx.test.ext.junit)
78 | androidTestImplementation(libs.espresso.core)
79 | androidTestImplementation(platform(libs.compose.bom))
80 | androidTestImplementation(libs.ui.test.junit4)
81 | debugImplementation(libs.ui.tooling)
82 | debugImplementation(libs.ui.test.manifest)
83 | }
84 |
85 | kapt {
86 | correctErrorTypes = true
87 | }
88 |
--------------------------------------------------------------------------------
/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 |
8 |
19 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/MyApplication.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class MyApplication : Application()
8 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/core/CoroutinesModule.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.core
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.SupervisorJob
10 | import javax.inject.Singleton
11 |
12 | @Module
13 | @InstallIn(SingletonComponent::class)
14 | class CoroutinesModule {
15 |
16 | @Provides
17 | @Singleton
18 | fun applicationScope(): CoroutineScope {
19 | return CoroutineScope(SupervisorJob() + Dispatchers.Default)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/core/DataStoreModule.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.core
2 |
3 | import android.content.Context
4 | import androidx.datastore.core.DataStore
5 | import androidx.datastore.preferences.core.Preferences
6 | import androidx.datastore.preferences.preferencesDataStore
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.qualifiers.ApplicationContext
11 | import dagger.hilt.components.SingletonComponent
12 | import javax.inject.Singleton
13 |
14 | @Module
15 | @InstallIn(SingletonComponent::class)
16 | class DataStoreModule {
17 | private val Context.dataStore by preferencesDataStore(name = "preferences")
18 |
19 | @Provides
20 | @Singleton
21 | fun dataStore(@ApplicationContext context: Context): DataStore {
22 | return context.dataStore
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/core/RetrofitModule.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.core
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import retrofit2.Retrofit
8 | import retrofit2.converter.gson.GsonConverterFactory
9 | import java.net.URL
10 | import javax.inject.Singleton
11 |
12 | @Module
13 | @InstallIn(SingletonComponent::class)
14 | class RetrofitModule {
15 |
16 | @Provides
17 | fun baseUrl(): URL {
18 | return URL("https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/json/")
19 | }
20 |
21 | @Provides
22 | @Singleton
23 | fun gson(): GsonConverterFactory {
24 | return GsonConverterFactory.create()
25 | }
26 |
27 | @Provides
28 | @Singleton
29 | fun retrofit(baseUrl: URL, gsonConverterFactory: GsonConverterFactory): Retrofit {
30 | return Retrofit.Builder()
31 | .baseUrl(baseUrl)
32 | .addConverterFactory(gsonConverterFactory)
33 | .build()
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/DataModule.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data
2 |
3 | import com.maruchin.domaindrivenandroid.data.account.api.AccountApi
4 | import com.maruchin.domaindrivenandroid.data.account.storage.AccountStorage
5 | import com.maruchin.domaindrivenandroid.data.account.storage.DefaultAccountStorage
6 | import com.maruchin.domaindrivenandroid.data.account.api.FakeAccountApi
7 | import com.maruchin.domaindrivenandroid.data.coupon.api.CouponsApi
8 | import com.maruchin.domaindrivenandroid.data.coupon.factory.ActivationCodeFactory
9 | import com.maruchin.domaindrivenandroid.data.coupon.factory.DefaultActivationCodeFactory
10 | import dagger.Binds
11 | import dagger.Module
12 | import dagger.Provides
13 | import dagger.hilt.InstallIn
14 | import dagger.hilt.components.SingletonComponent
15 | import retrofit2.Retrofit
16 | import retrofit2.create
17 | import javax.inject.Singleton
18 |
19 | @Module
20 | @InstallIn(SingletonComponent::class)
21 | abstract class DataModule {
22 |
23 | @Binds
24 | abstract fun accountApi(impl: FakeAccountApi): AccountApi
25 |
26 | @Binds
27 | abstract fun accountStorage(impl: DefaultAccountStorage): AccountStorage
28 |
29 | @Binds
30 | abstract fun activationCodeFactory(impl: DefaultActivationCodeFactory): ActivationCodeFactory
31 |
32 |
33 | companion object {
34 |
35 | @Provides
36 | @Singleton
37 | fun couponsApi(retrofit: Retrofit): CouponsApi {
38 | return retrofit.create()
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/account/Account.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.account
2 |
3 | import com.maruchin.domaindrivenandroid.data.coupon.Coupon
4 | import com.maruchin.domaindrivenandroid.data.values.Email
5 | import com.maruchin.domaindrivenandroid.data.values.Points
6 |
7 | data class Account(
8 | val email: Email,
9 | val collectedPoints: Points,
10 | ) {
11 |
12 | fun canExchangePointsFor(coupon: Coupon): Boolean {
13 | return collectedPoints >= coupon.points
14 | }
15 |
16 | fun exchangePointsFor(coupon: Coupon): Account {
17 | check(canExchangePointsFor(coupon))
18 | return copy(collectedPoints = collectedPoints - coupon.points)
19 | }
20 | }
21 |
22 | val sampleAccount = Account(
23 | email = Email("marcinpk.mp@gmail.com"),
24 | collectedPoints = Points(170),
25 | )
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/account/AccountRepository.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.account
2 |
3 | import com.maruchin.domaindrivenandroid.data.account.api.AccountApi
4 | import com.maruchin.domaindrivenandroid.data.account.api.toModel
5 | import com.maruchin.domaindrivenandroid.data.account.storage.AccountStorage
6 | import com.maruchin.domaindrivenandroid.data.values.Email
7 | import kotlinx.coroutines.flow.Flow
8 | import javax.inject.Inject
9 | import javax.inject.Singleton
10 |
11 | @Singleton
12 | class AccountRepository @Inject constructor(
13 | private val accountApi: AccountApi,
14 | private val accountStorage: AccountStorage,
15 | ) {
16 |
17 | fun getLoggedInAccount(): Flow {
18 | return accountStorage.getLoggedInAccount()
19 | }
20 |
21 | suspend fun updateLoggedInAccount(account: Account) {
22 | accountStorage.saveLoggedInAccount(account)
23 | }
24 |
25 | suspend fun clearLoggedInAccount() {
26 | accountStorage.saveLoggedInAccount(null)
27 | }
28 |
29 | suspend fun createAccount(email: Email) {
30 | val accountFromApi = accountApi.createAccount(email.value)
31 | accountStorage.saveLoggedInAccount(accountFromApi.toModel())
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/account/api/AccountApi.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.account.api
2 |
3 | interface AccountApi {
4 |
5 | suspend fun createAccount(email: String): AccountJson
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/account/api/AccountJson.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.account.api
2 |
3 | import com.maruchin.domaindrivenandroid.data.account.Account
4 | import com.maruchin.domaindrivenandroid.data.values.Email
5 | import com.maruchin.domaindrivenandroid.data.values.Points
6 |
7 | data class AccountJson(val email: String, val collectedPoints: Int)
8 |
9 | fun AccountJson.toModel() = Account(
10 | email = Email(email),
11 | collectedPoints = Points(collectedPoints),
12 | )
13 |
14 | val sampleAccountJson = AccountJson(
15 | email = "marcinpk.mp@gmail.com",
16 | collectedPoints = 170,
17 | )
18 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/account/api/FakeAccountApi.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.account.api
2 |
3 | import javax.inject.Inject
4 |
5 | const val DEFAULT_COLLECTED_POINTS = 170
6 |
7 | class FakeAccountApi @Inject constructor() : AccountApi {
8 |
9 | override suspend fun createAccount(email: String): AccountJson {
10 | return AccountJson(email = email, collectedPoints = DEFAULT_COLLECTED_POINTS)
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/account/storage/AccountStorage.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.account.storage
2 |
3 | import com.maruchin.domaindrivenandroid.data.account.Account
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | interface AccountStorage {
7 |
8 | fun getLoggedInAccount(): Flow
9 |
10 | suspend fun saveLoggedInAccount(account: Account?)
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/account/storage/DefaultAccountStorage.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.account.storage
2 |
3 | import androidx.datastore.core.DataStore
4 | import androidx.datastore.preferences.core.Preferences
5 | import androidx.datastore.preferences.core.edit
6 | import androidx.datastore.preferences.core.intPreferencesKey
7 | import androidx.datastore.preferences.core.stringPreferencesKey
8 | import com.maruchin.domaindrivenandroid.data.account.Account
9 | import com.maruchin.domaindrivenandroid.data.values.Email
10 | import com.maruchin.domaindrivenandroid.data.values.Points
11 | import kotlinx.coroutines.flow.Flow
12 | import kotlinx.coroutines.flow.map
13 | import javax.inject.Inject
14 |
15 | val ACCOUNT_EMAIL = stringPreferencesKey("account_email")
16 | val ACCOUNT_COLLECTED_POINTS = intPreferencesKey("account_collected_points")
17 |
18 | class DefaultAccountStorage @Inject constructor(
19 | private val dataStore: DataStore
20 | ) : AccountStorage {
21 |
22 | override fun getLoggedInAccount(): Flow {
23 | return dataStore.data.map { preferences ->
24 | val email = preferences[ACCOUNT_EMAIL]
25 | val collectedPoints = preferences[ACCOUNT_COLLECTED_POINTS]
26 | if (email != null && collectedPoints != null) {
27 | Account(
28 | email = Email(email),
29 | collectedPoints = Points(collectedPoints)
30 | )
31 | } else null
32 | }
33 | }
34 |
35 | override suspend fun saveLoggedInAccount(account: Account?) {
36 | dataStore.edit { preferences ->
37 | if (account == null) {
38 | preferences.remove(ACCOUNT_EMAIL)
39 | preferences.remove(ACCOUNT_COLLECTED_POINTS)
40 | } else {
41 | preferences[ACCOUNT_EMAIL] = account.email.value
42 | preferences[ACCOUNT_COLLECTED_POINTS] = account.collectedPoints.value
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/account/storage/FakeAccountStorage.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.account.storage
2 |
3 | import com.maruchin.domaindrivenandroid.data.account.Account
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.MutableStateFlow
6 |
7 | class FakeAccountStorage : AccountStorage {
8 | private val account = MutableStateFlow(null)
9 |
10 | override fun getLoggedInAccount(): Flow {
11 | return account
12 | }
13 |
14 | override suspend fun saveLoggedInAccount(account: Account?) {
15 | this.account.emit(account)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/ActivationCode.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.coupon
2 |
3 | import kotlinx.coroutines.delay
4 | import kotlin.time.Duration
5 | import kotlin.time.Duration.Companion.seconds
6 |
7 |
8 | data class ActivationCode(val value: String, val remainingTime: Duration) {
9 | init {
10 | require(value.length == LENGTH)
11 | }
12 |
13 | val expired: Boolean
14 | get() = remainingTime.inWholeSeconds <= 0
15 |
16 | suspend fun waitForActivation(): ActivationCode {
17 | check(!expired)
18 | delay(1.seconds)
19 | return copy(remainingTime = remainingTime - 1.seconds)
20 | }
21 |
22 | companion object {
23 | const val LENGTH = 8
24 | }
25 | }
26 |
27 | val sampleActivationCode = ActivationCode(
28 | value = "QW123456",
29 | remainingTime = 60.seconds,
30 | )
31 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/Coupon.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.coupon
2 |
3 | import com.maruchin.domaindrivenandroid.data.values.ID
4 | import com.maruchin.domaindrivenandroid.data.values.Name
5 | import com.maruchin.domaindrivenandroid.data.values.Points
6 | import java.net.URL
7 |
8 | data class Coupon(
9 | val id: ID,
10 | val name: Name,
11 | val points: Points,
12 | val image: URL,
13 | val activationCode: ActivationCode?,
14 | ) {
15 |
16 | val canActivate: Boolean
17 | get() = activationCode != null && !activationCode.expired
18 |
19 | fun collect(activationCode: ActivationCode) = copy(
20 | activationCode = activationCode
21 | )
22 |
23 | suspend fun waitForActivation() = copy(
24 | activationCode = activationCode?.waitForActivation()
25 | )
26 |
27 | fun reset() = copy(
28 | activationCode = null,
29 | )
30 | }
31 |
32 | val sampleCoupons = listOf(
33 | Coupon(
34 | id = ID("1"),
35 | name = Name("Cheesburger with fries"),
36 | points = Points(200),
37 | image = URL("https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/cheesburger_with_fries_coupon.jpeg"),
38 | activationCode = null,
39 | ),
40 | Coupon(
41 | id = ID("2"),
42 | name = Name("2 x Milkshake"),
43 | points = Points(100),
44 | image = URL("https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/two_milkshakes_coupon.jpeg"),
45 | activationCode = null,
46 | ),
47 | Coupon(
48 | id = ID("3"),
49 | name = Name("2 x Soda drink"),
50 | points = Points(50),
51 | image = URL("https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/two_soda_drinks_coupon.jpeg"),
52 | activationCode = null,
53 | )
54 | )
55 |
--------------------------------------------------------------------------------
/app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/CouponsRepository.kt:
--------------------------------------------------------------------------------
1 | package com.maruchin.domaindrivenandroid.data.coupon
2 |
3 | import com.maruchin.domaindrivenandroid.data.coupon.api.CouponsApi
4 | import com.maruchin.domaindrivenandroid.data.coupon.api.toModel
5 | import com.maruchin.domaindrivenandroid.data.values.ID
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.async
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.MutableStateFlow
10 | import kotlinx.coroutines.flow.map
11 | import kotlinx.coroutines.flow.onStart
12 | import javax.inject.Inject
13 | import javax.inject.Singleton
14 |
15 | @Singleton
16 | class CouponsRepository @Inject constructor(
17 | private val couponsApi: CouponsApi,
18 | private val scope: CoroutineScope,
19 | ) {
20 | private val coupons = MutableStateFlow