├── .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 | 41 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Domain Driven Android 2 | 3 | [![CI](https://github.com/Maruchin1/domain-driven-android/actions/workflows/ci-workflow.yml/badge.svg)](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 | ![Projekt bez tytułu (3)](https://user-images.githubusercontent.com/46427781/233632707-fc1953f0-2dbb-4c99-a825-91f8ef43dad8.png) 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>(emptyMap()) 21 | 22 | fun getAllCoupons(): Flow> { 23 | return coupons.map { collection -> 24 | collection.values.toList() 25 | }.onStart { 26 | fetchCouponsIfNotAvailableLocallyAsync().await() 27 | } 28 | } 29 | 30 | fun getCoupon(id: ID): Flow { 31 | return coupons.map { collection -> 32 | collection[id] 33 | }.onStart { 34 | fetchCouponsIfNotAvailableLocallyAsync().await() 35 | } 36 | } 37 | 38 | suspend fun updateCoupon(coupon: Coupon) { 39 | val updatedCoupons = coupons.value + (coupon.id to coupon) 40 | coupons.emit(updatedCoupons) 41 | } 42 | 43 | private fun fetchCouponsIfNotAvailableLocallyAsync() = scope.async { 44 | if (coupons.value.isEmpty()) { 45 | val couponsFromApi = couponsApi.fetchAllCoupons().toModel().associateBy { it.id } 46 | coupons.emit(couponsFromApi) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/api/CouponJson.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.coupon.api 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import com.maruchin.domaindrivenandroid.data.coupon.Coupon 5 | import com.maruchin.domaindrivenandroid.data.values.ID 6 | import com.maruchin.domaindrivenandroid.data.values.Name 7 | import com.maruchin.domaindrivenandroid.data.values.Points 8 | import java.net.URL 9 | 10 | data class CouponJson( 11 | @SerializedName("id") 12 | val id: String, 13 | @SerializedName("name") 14 | val name: String, 15 | @SerializedName("points") 16 | val points: Int, 17 | @SerializedName("image") 18 | val image: String, 19 | ) 20 | 21 | fun CouponJson.toModel() = Coupon( 22 | id = ID(id), 23 | name = Name(name), 24 | points = Points(points), 25 | image = URL(image), 26 | activationCode = null, 27 | ) 28 | 29 | fun List.toModel() = map { it.toModel() } 30 | 31 | val sampleCouponsJson = listOf( 32 | CouponJson( 33 | id = "1", 34 | name = "Cheesburger with fries", 35 | points = 200, 36 | image = "https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/cheesburger_with_fries_coupon.jpeg", 37 | ), 38 | CouponJson( 39 | id = "2", 40 | name = "2 x Milkshake", 41 | points = 100, 42 | image = "https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/two_milkshakes_coupon.jpeg", 43 | ), 44 | CouponJson( 45 | id = "3", 46 | name = "2 x Soda drink", 47 | points = 50, 48 | image = "https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/two_soda_drinks_coupon.jpeg", 49 | ), 50 | ) 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/api/CouponsApi.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.coupon.api 2 | 3 | import retrofit2.http.GET 4 | 5 | interface CouponsApi { 6 | 7 | @GET("coupons.json") 8 | suspend fun fetchAllCoupons(): List 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/api/FakeCouponsApi.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.coupon.api 2 | 3 | class FakeCouponsApi : CouponsApi { 4 | 5 | override suspend fun fetchAllCoupons(): List { 6 | return sampleCouponsJson 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/factory/ActivationCodeFactory.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.coupon.factory 2 | 3 | import com.maruchin.domaindrivenandroid.data.coupon.ActivationCode 4 | 5 | interface ActivationCodeFactory { 6 | 7 | fun createRandomActivationCode(): ActivationCode 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/factory/DefaultActivationCodeFactory.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.coupon.factory 2 | 3 | import com.maruchin.domaindrivenandroid.data.coupon.ActivationCode 4 | import javax.inject.Inject 5 | import kotlin.random.Random 6 | import kotlin.time.Duration.Companion.seconds 7 | 8 | const val ALLOWED_CHARS = "1234567890QWERTYUIOPASDFGHJKLZXCVBNM" 9 | const val REMAINING_SECONDS = 60 10 | 11 | class DefaultActivationCodeFactory @Inject constructor() : ActivationCodeFactory { 12 | 13 | override fun createRandomActivationCode(): ActivationCode { 14 | val code = buildString(ActivationCode.LENGTH) { 15 | repeat(ActivationCode.LENGTH) { 16 | append(ALLOWED_CHARS[Random.nextInt(ALLOWED_CHARS.length)]) 17 | } 18 | } 19 | return ActivationCode(value = code, remainingTime = REMAINING_SECONDS.seconds) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/coupon/factory/FakeActivationCodeFactory.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.coupon.factory 2 | 3 | import com.maruchin.domaindrivenandroid.data.coupon.ActivationCode 4 | import com.maruchin.domaindrivenandroid.data.coupon.sampleActivationCode 5 | 6 | class FakeActivationCodeFactory : ActivationCodeFactory { 7 | override fun createRandomActivationCode(): ActivationCode { 8 | return sampleActivationCode 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/registrationrequest/RegistrationRequest.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.registrationrequest 2 | 3 | import com.maruchin.domaindrivenandroid.data.values.Email 4 | 5 | data class RegistrationRequest( 6 | val email: Email, 7 | val termsAndConditionsAccepted: Boolean = false, 8 | ) { 9 | 10 | fun acceptTermsAndConditions() = copy(termsAndConditionsAccepted = true) 11 | } 12 | 13 | val sampleRegistrationRequest = RegistrationRequest( 14 | email = Email("marcinpk.mp@gmail.com") 15 | ) 16 | 17 | val sampleAcceptedRegistrationRequest = RegistrationRequest( 18 | email = Email("marcinpk.mp@gmail.com"), 19 | termsAndConditionsAccepted = true, 20 | ) 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/registrationrequest/RegistrationRequestRepository.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.registrationrequest 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class RegistrationRequestRepository @Inject constructor() { 10 | private val registrationRequest = MutableStateFlow(null) 11 | 12 | fun getRegistrationRequest(): Flow { 13 | return registrationRequest 14 | } 15 | 16 | suspend fun saveRegistrationRequest(request: RegistrationRequest) { 17 | registrationRequest.emit(request) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/values/Email.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.values 2 | 3 | @JvmInline 4 | value class Email(val value: String) 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/values/ID.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.values 2 | 3 | @JvmInline 4 | value class ID(val value: String) 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/values/Name.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.values 2 | 3 | @JvmInline 4 | value class Name(val value: String) 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/data/values/Points.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.data.values 2 | 3 | @JvmInline 4 | value class Points(val value: Int) : Comparable { 5 | init { 6 | require(value >= 0) 7 | } 8 | 9 | override fun compareTo(other: Points): Int { 10 | return value.compareTo(other.value) 11 | } 12 | 13 | operator fun plus(other: Points): Points { 14 | return Points(value + other.value) 15 | } 16 | 17 | operator fun minus(other: Points): Points { 18 | return Points(value - other.value) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/domain/coupon/CollectCouponUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.domain.coupon 2 | 3 | import com.maruchin.domaindrivenandroid.data.account.AccountRepository 4 | import com.maruchin.domaindrivenandroid.data.coupon.CouponsRepository 5 | import com.maruchin.domaindrivenandroid.data.coupon.factory.ActivationCodeFactory 6 | import com.maruchin.domaindrivenandroid.data.values.ID 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.flow.first 9 | import kotlinx.coroutines.launch 10 | import javax.inject.Inject 11 | 12 | class CollectCouponUseCase @Inject constructor( 13 | private val accountRepository: AccountRepository, 14 | private val couponsRepository: CouponsRepository, 15 | private val activationCodeFactory: ActivationCodeFactory, 16 | private val scope: CoroutineScope, 17 | ) { 18 | 19 | suspend operator fun invoke(couponId: ID) { 20 | var account = checkNotNull(accountRepository.getLoggedInAccount().first()) 21 | var coupon = checkNotNull(couponsRepository.getCoupon(couponId).first()) 22 | account = account.exchangePointsFor(coupon) 23 | val activationCode = activationCodeFactory.createRandomActivationCode() 24 | coupon = coupon.collect(activationCode) 25 | accountRepository.updateLoggedInAccount(account) 26 | couponsRepository.updateCoupon(coupon) 27 | scope.launch { 28 | while (coupon.canActivate) { 29 | coupon = coupon.waitForActivation() 30 | couponsRepository.updateCoupon(coupon) 31 | } 32 | coupon = coupon.reset() 33 | couponsRepository.updateCoupon(coupon) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/domain/coupon/CollectableCoupon.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.domain.coupon 2 | 3 | import com.maruchin.domaindrivenandroid.data.account.Account 4 | import com.maruchin.domaindrivenandroid.data.coupon.Coupon 5 | import com.maruchin.domaindrivenandroid.data.coupon.sampleCoupons 6 | 7 | data class CollectableCoupon(val coupon: Coupon, val canCollect: Boolean) { 8 | 9 | constructor(coupon: Coupon, account: Account) : this( 10 | coupon = coupon, 11 | canCollect = account.canExchangePointsFor(coupon) 12 | ) 13 | } 14 | 15 | val sampleCollectableCoupons = listOf( 16 | CollectableCoupon(coupon = sampleCoupons[2], canCollect = true), 17 | CollectableCoupon(coupon = sampleCoupons[1], canCollect = true), 18 | CollectableCoupon(coupon = sampleCoupons[0], canCollect = false), 19 | ) 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/domain/coupon/GetAllCollectableCouponsUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.domain.coupon 2 | 3 | import com.maruchin.domaindrivenandroid.data.account.AccountRepository 4 | import com.maruchin.domaindrivenandroid.data.coupon.CouponsRepository 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.combine 7 | import kotlinx.coroutines.flow.filterNotNull 8 | import javax.inject.Inject 9 | 10 | class GetAllCollectableCouponsUseCase @Inject constructor( 11 | private val accountRepository: AccountRepository, 12 | private val couponsRepository: CouponsRepository, 13 | ) { 14 | 15 | operator fun invoke(): Flow> { 16 | return combine( 17 | accountRepository.getLoggedInAccount().filterNotNull(), 18 | couponsRepository.getAllCoupons() 19 | ) { account, allCoupons -> 20 | allCoupons.sortedBy { coupon -> 21 | coupon.points 22 | }.map { coupon -> 23 | CollectableCoupon(coupon, account) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/domain/coupon/GetCollectableCouponUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.domain.coupon 2 | 3 | import com.maruchin.domaindrivenandroid.data.account.AccountRepository 4 | import com.maruchin.domaindrivenandroid.data.coupon.CouponsRepository 5 | import com.maruchin.domaindrivenandroid.data.values.ID 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.combine 8 | import kotlinx.coroutines.flow.filterNotNull 9 | import javax.inject.Inject 10 | 11 | class GetCollectableCouponUseCase @Inject constructor( 12 | private val accountRepository: AccountRepository, 13 | private val couponsRepository: CouponsRepository, 14 | ) { 15 | 16 | operator fun invoke(couponId: ID): Flow { 17 | return combine( 18 | accountRepository.getLoggedInAccount().filterNotNull(), 19 | couponsRepository.getCoupon(couponId).filterNotNull(), 20 | ) { account, coupon -> 21 | CollectableCoupon(coupon, account) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/domain/registrationrequest/AcceptRegistrationTermsAndConditionsUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.domain.registrationrequest 2 | 3 | import com.maruchin.domaindrivenandroid.data.registrationrequest.RegistrationRequestRepository 4 | import kotlinx.coroutines.flow.first 5 | import javax.inject.Inject 6 | 7 | class AcceptRegistrationTermsAndConditionsUseCase @Inject constructor( 8 | private val registrationRequestRepository: RegistrationRequestRepository, 9 | ) { 10 | 11 | suspend operator fun invoke() { 12 | var request = checkNotNull(registrationRequestRepository.getRegistrationRequest().first()) 13 | request = request.acceptTermsAndConditions() 14 | registrationRequestRepository.saveRegistrationRequest(request) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/domain/registrationrequest/CompleteRegistrationUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.domain.registrationrequest 2 | 3 | import com.maruchin.domaindrivenandroid.data.account.AccountRepository 4 | import com.maruchin.domaindrivenandroid.data.registrationrequest.RegistrationRequestRepository 5 | import kotlinx.coroutines.flow.first 6 | import javax.inject.Inject 7 | 8 | class CompleteRegistrationUseCase @Inject constructor( 9 | private val registrationRequestRepository: RegistrationRequestRepository, 10 | private val accountRepository: AccountRepository, 11 | ) { 12 | 13 | suspend operator fun invoke() { 14 | val request = checkNotNull(registrationRequestRepository.getRegistrationRequest().first()) 15 | check(request.termsAndConditionsAccepted) 16 | accountRepository.createAccount(request.email) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/domain/registrationrequest/StartNewRegistrationUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.domain.registrationrequest 2 | 3 | import com.maruchin.domaindrivenandroid.data.registrationrequest.RegistrationRequest 4 | import com.maruchin.domaindrivenandroid.data.registrationrequest.RegistrationRequestRepository 5 | import com.maruchin.domaindrivenandroid.data.values.Email 6 | import javax.inject.Inject 7 | 8 | class StartNewRegistrationUseCase @Inject constructor( 9 | private val registrationRequestRepository: RegistrationRequestRepository, 10 | ) { 11 | 12 | suspend operator fun invoke(email: Email) { 13 | val newRequest = RegistrationRequest(email) 14 | registrationRequestRepository.saveRegistrationRequest(newRequest) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/FieldErrorView.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | 9 | @Composable 10 | fun FieldErrorView(error: String?) { 11 | if (error != null) { 12 | Text( 13 | text = error, 14 | style = MaterialTheme.typography.bodyMedium, 15 | color = MaterialTheme.colorScheme.error, 16 | modifier = Modifier.fillMaxWidth(), 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/Formatter.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui 2 | 3 | import com.maruchin.domaindrivenandroid.data.values.Points 4 | import kotlin.time.Duration 5 | import kotlin.time.DurationUnit 6 | 7 | fun Points.format(): String { 8 | return "$value pts" 9 | } 10 | 11 | fun Duration.formatSeconds(): String { 12 | return toString(DurationUnit.SECONDS) 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/Logger.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.ViewModel 5 | 6 | fun ViewModel.logError(t: Throwable) { 7 | Log.e(this::class.simpleName, t.stackTraceToString()) 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/Placeholder.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui 2 | 3 | import com.maruchin.domaindrivenandroid.data.coupon.Coupon 4 | import com.maruchin.domaindrivenandroid.data.values.ID 5 | import com.maruchin.domaindrivenandroid.data.values.Name 6 | import com.maruchin.domaindrivenandroid.data.values.Points 7 | import com.maruchin.domaindrivenandroid.domain.coupon.CollectableCoupon 8 | import java.net.URL 9 | 10 | fun placeholderCollectableCoupon(index: Int = 0) = CollectableCoupon( 11 | coupon = Coupon( 12 | id = ID(index.toString()), 13 | name = Name("00000 00000 00000 00000 00000"), 14 | points = Points(1000), 15 | image = URL("http://empty.placeholder"), 16 | activationCode = null, 17 | ), 18 | canCollect = true, 19 | ) 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui 2 | 3 | import android.app.Activity 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.SideEffect 8 | import androidx.compose.ui.graphics.toArgb 9 | import androidx.compose.ui.platform.LocalView 10 | import androidx.core.view.WindowCompat 11 | 12 | @Composable 13 | fun DomainDrivenAndroidTheme( 14 | darkTheme: Boolean = isSystemInDarkTheme(), 15 | content: @Composable () -> Unit 16 | ) { 17 | val colorScheme = MaterialTheme.colorScheme 18 | val view = LocalView.current 19 | if (!view.isInEditMode) { 20 | SideEffect { 21 | val window = (view.context as Activity).window 22 | window.statusBarColor = colorScheme.primary.toArgb() 23 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme 24 | } 25 | } 26 | MaterialTheme(content = content) 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/completeregistration/CompleteRegistrationDestination.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.completeregistration 2 | 3 | import androidx.compose.runtime.collectAsState 4 | import androidx.compose.runtime.getValue 5 | import androidx.hilt.navigation.compose.hiltViewModel 6 | import androidx.navigation.NavController 7 | import androidx.navigation.NavGraphBuilder 8 | import androidx.navigation.compose.composable 9 | 10 | const val COMPLETE_REGISTRATION_ROUTE = "complete-registration" 11 | 12 | fun NavGraphBuilder.completeRegistrationScreen( 13 | onBack: () -> Unit, 14 | onCompletedSuccessfully: () -> Unit 15 | ) { 16 | composable(COMPLETE_REGISTRATION_ROUTE) { 17 | val viewModel = hiltViewModel() 18 | val state by viewModel.uiState.collectAsState() 19 | CompleteRegistrationScreen( 20 | state = state, 21 | onBack = onBack, 22 | onComplete = viewModel::complete, 23 | onCompletedSuccessfully = onCompletedSuccessfully, 24 | ) 25 | } 26 | } 27 | 28 | fun NavController.navigateToCompleteRegistration() { 29 | navigate(COMPLETE_REGISTRATION_ROUTE) 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/completeregistration/CompleteRegistrationScreen.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.completeregistration 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.ArrowBack 10 | import androidx.compose.material3.Button 11 | import androidx.compose.material3.ExperimentalMaterial3Api 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.IconButton 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Scaffold 16 | import androidx.compose.material3.Text 17 | import androidx.compose.material3.TopAppBar 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.LaunchedEffect 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.layout.ContentScale 22 | import androidx.compose.ui.res.painterResource 23 | import androidx.compose.ui.text.style.TextAlign 24 | import androidx.compose.ui.tooling.preview.Preview 25 | import androidx.compose.ui.unit.dp 26 | import com.maruchin.domaindrivenandroid.R 27 | import com.maruchin.domaindrivenandroid.ui.DomainDrivenAndroidTheme 28 | 29 | @OptIn(ExperimentalMaterial3Api::class) 30 | @Composable 31 | fun CompleteRegistrationScreen( 32 | state: CompleteRegistrationUiState, 33 | onBack: () -> Unit, 34 | onComplete: () -> Unit, 35 | onCompletedSuccessfully: () -> Unit, 36 | ) { 37 | if (state.completed) { 38 | LaunchedEffect(Unit) { onCompletedSuccessfully() } 39 | } 40 | Scaffold( 41 | topBar = { 42 | TopAppBar( 43 | title = {}, 44 | navigationIcon = { 45 | IconButton(onClick = onBack) { 46 | Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null) 47 | } 48 | } 49 | ) 50 | } 51 | ) { padding -> 52 | Column(modifier = Modifier.padding(padding)) { 53 | Text( 54 | text = "All good!", 55 | style = MaterialTheme.typography.displayMedium, 56 | textAlign = TextAlign.Center, 57 | modifier = Modifier 58 | .fillMaxWidth() 59 | .padding(horizontal = 12.dp, vertical = 48.dp), 60 | ) 61 | Image( 62 | painter = painterResource(R.drawable.all_good), 63 | contentDescription = null, 64 | contentScale = ContentScale.FillWidth, 65 | modifier = Modifier 66 | .fillMaxWidth() 67 | .padding(horizontal = 20.dp), 68 | ) 69 | Spacer(modifier = Modifier.weight(1f)) 70 | Button( 71 | onClick = onComplete, 72 | modifier = Modifier 73 | .fillMaxWidth() 74 | .padding(horizontal = 12.dp, vertical = 16.dp) 75 | ) { 76 | Text(text = "Complete") 77 | } 78 | } 79 | } 80 | } 81 | 82 | @Preview 83 | @Composable 84 | private fun CompleteRegistration() { 85 | DomainDrivenAndroidTheme { 86 | CompleteRegistrationScreen( 87 | state = CompleteRegistrationUiState(), 88 | onBack = {}, 89 | onComplete = {}, 90 | onCompletedSuccessfully = {}, 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/completeregistration/CompleteRegistrationUiState.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.completeregistration 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | data class CompleteRegistrationUiState( 7 | val completed: Boolean = false, 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/completeregistration/CompleteRegistrationViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.completeregistration 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.maruchin.domaindrivenandroid.domain.registrationrequest.CompleteRegistrationUseCase 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | import kotlinx.coroutines.flow.update 10 | import kotlinx.coroutines.launch 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class CompleteRegistrationViewModel @Inject constructor( 15 | private val completeRegistrationUseCase: CompleteRegistrationUseCase, 16 | ) : ViewModel() { 17 | 18 | private val _uiState = MutableStateFlow(CompleteRegistrationUiState()) 19 | val uiState = _uiState.asStateFlow() 20 | 21 | fun complete() = viewModelScope.launch { 22 | completeRegistrationUseCase() 23 | _uiState.update { it.copy(completed = true) } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/couponPreview/CouponPreviewDestination.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.couponPreview 2 | 3 | import androidx.compose.runtime.LaunchedEffect 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | import androidx.hilt.navigation.compose.hiltViewModel 7 | import androidx.navigation.NavController 8 | import androidx.navigation.NavGraphBuilder 9 | import androidx.navigation.compose.composable 10 | import com.maruchin.domaindrivenandroid.data.values.ID 11 | 12 | const val COUPON_PREVIEW_ROUTE = "coupon-preview" 13 | const val COUPON_IN = "couponId" 14 | 15 | fun NavGraphBuilder.couponPreviewScreen(onBack: () -> Unit) { 16 | composable("$COUPON_PREVIEW_ROUTE/{$COUPON_IN}") { 17 | val couponId = ID(it.arguments?.getString(COUPON_IN) ?: "") 18 | val viewModel = hiltViewModel() 19 | val state by viewModel.uiState.collectAsState() 20 | LaunchedEffect(Unit) { 21 | viewModel.selectCoupon(couponId) 22 | } 23 | CouponPreviewScreen(state = state, onBack = onBack, onCollect = viewModel::collectCoupon) 24 | } 25 | } 26 | 27 | fun NavController.navigateToCouponPreview(couponId: ID) { 28 | navigate("$COUPON_PREVIEW_ROUTE/${couponId.value}") 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/couponPreview/CouponPreviewScreen.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.couponPreview 2 | 3 | import androidx.compose.animation.AnimatedContent 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.aspectRatio 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.rememberScrollState 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.outlined.ArrowBack 15 | import androidx.compose.material3.Button 16 | import androidx.compose.material3.Card 17 | import androidx.compose.material3.ExperimentalMaterial3Api 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.IconButton 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.OutlinedCard 22 | import androidx.compose.material3.Scaffold 23 | import androidx.compose.material3.Text 24 | import androidx.compose.material3.TopAppBar 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.layout.ContentScale 28 | import androidx.compose.ui.text.font.FontWeight 29 | import androidx.compose.ui.text.style.TextAlign 30 | import androidx.compose.ui.tooling.preview.Preview 31 | import androidx.compose.ui.tooling.preview.PreviewParameter 32 | import androidx.compose.ui.tooling.preview.PreviewParameterProvider 33 | import androidx.compose.ui.unit.dp 34 | import coil.compose.AsyncImage 35 | import com.google.accompanist.placeholder.PlaceholderHighlight 36 | import com.google.accompanist.placeholder.material.placeholder 37 | import com.google.accompanist.placeholder.material.shimmer 38 | import com.maruchin.domaindrivenandroid.data.coupon.ActivationCode 39 | import com.maruchin.domaindrivenandroid.data.values.Name 40 | import com.maruchin.domaindrivenandroid.data.coupon.sampleActivationCode 41 | import com.maruchin.domaindrivenandroid.domain.coupon.sampleCollectableCoupons 42 | import com.maruchin.domaindrivenandroid.ui.DomainDrivenAndroidTheme 43 | import com.maruchin.domaindrivenandroid.ui.format 44 | import com.maruchin.domaindrivenandroid.ui.formatSeconds 45 | 46 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) 47 | @Composable 48 | fun CouponPreviewScreen(state: CouponPreviewUiState, onBack: () -> Unit, onCollect: () -> Unit) { 49 | Scaffold( 50 | topBar = { TopBar(onBack) } 51 | ) { paddingValues -> 52 | Column( 53 | modifier = Modifier 54 | .fillMaxSize() 55 | .padding(paddingValues) 56 | .verticalScroll(rememberScrollState()), 57 | ) { 58 | CouponImage( 59 | imageUrl = state.coupon.coupon.image.toString(), 60 | isLoading = state.isLoading 61 | ) 62 | CouponNameView(couponName = state.coupon.coupon.name, isLoading = state.isLoading) 63 | PointsView(price = state.coupon.coupon.points.format(), isLoading = state.isLoading) 64 | Spacer(modifier = Modifier.weight(1f)) 65 | AnimatedContent(targetState = state.getCouponStatus()) { status -> 66 | when (status) { 67 | CouponStatus.NOT_COLLECTED -> CollectButton( 68 | isLoading = state.isLoading, 69 | canCollect = state.coupon.canCollect, 70 | onCollect = onCollect, 71 | ) 72 | 73 | CouponStatus.COLLECTING -> ActivationCodeView(code = null) 74 | CouponStatus.COLLECTED -> ActivationCodeView(code = state.coupon.coupon.activationCode) 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | @Composable 82 | @OptIn(ExperimentalMaterial3Api::class) 83 | private fun TopBar(onBack: () -> Unit) { 84 | TopAppBar( 85 | title = { Text(text = "My Coupons") }, 86 | navigationIcon = { 87 | IconButton(onClick = onBack) { 88 | Icon( 89 | imageVector = Icons.Outlined.ArrowBack, 90 | contentDescription = "Navigate up", 91 | ) 92 | } 93 | }, 94 | ) 95 | } 96 | 97 | @Composable 98 | private fun CouponImage(imageUrl: String, isLoading: Boolean) { 99 | OutlinedCard(modifier = Modifier.padding(12.dp)) { 100 | AsyncImage( 101 | model = imageUrl, 102 | contentDescription = null, 103 | contentScale = ContentScale.FillWidth, 104 | modifier = Modifier 105 | .fillMaxWidth() 106 | .aspectRatio(1f / 1f) 107 | .placeholder(isLoading), 108 | ) 109 | } 110 | } 111 | 112 | @Composable 113 | private fun CouponNameView(couponName: Name, isLoading: Boolean) { 114 | Text( 115 | text = couponName.value, 116 | style = MaterialTheme.typography.displaySmall, 117 | modifier = Modifier 118 | .padding(vertical = 12.dp, horizontal = 20.dp) 119 | .placeholder(isLoading) 120 | ) 121 | } 122 | 123 | @Composable 124 | private fun PointsView(price: String, isLoading: Boolean) { 125 | Text( 126 | text = price, 127 | style = MaterialTheme.typography.headlineMedium, 128 | fontWeight = FontWeight.SemiBold, 129 | color = MaterialTheme.colorScheme.primary, 130 | modifier = Modifier 131 | .padding(vertical = 12.dp, horizontal = 20.dp) 132 | .placeholder(isLoading), 133 | ) 134 | } 135 | 136 | @Composable 137 | private fun CollectButton(isLoading: Boolean, canCollect: Boolean, onCollect: () -> Unit) { 138 | Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp)) { 139 | if (!canCollect) { 140 | Text( 141 | text = "Not enough points", 142 | textAlign = TextAlign.Center, 143 | style = MaterialTheme.typography.bodyMedium, 144 | modifier = Modifier 145 | .fillMaxWidth() 146 | .padding(bottom = 4.dp) 147 | ) 148 | } 149 | Button( 150 | onClick = onCollect, 151 | enabled = !isLoading && canCollect, 152 | modifier = Modifier 153 | .fillMaxWidth() 154 | .placeholder(isLoading), 155 | ) { 156 | Text(text = "Collect".uppercase()) 157 | } 158 | } 159 | } 160 | 161 | @Composable 162 | private fun ActivationCodeView(code: ActivationCode?) { 163 | Card( 164 | modifier = Modifier 165 | .fillMaxWidth() 166 | .padding(12.dp) 167 | .placeholder(code == null, highlight = PlaceholderHighlight.shimmer()), 168 | ) { 169 | Column(modifier = Modifier.padding(20.dp)) { 170 | Text( 171 | text = code?.value ?: "", 172 | style = MaterialTheme.typography.headlineLarge, 173 | textAlign = TextAlign.Center, 174 | modifier = Modifier.fillMaxWidth() 175 | ) 176 | Text( 177 | text = code?.remainingTime?.formatSeconds() ?: "", 178 | style = MaterialTheme.typography.bodyMedium, 179 | textAlign = TextAlign.Center, 180 | modifier = Modifier 181 | .fillMaxWidth() 182 | .padding(top = 8.dp) 183 | ) 184 | } 185 | } 186 | } 187 | 188 | @Preview 189 | @Composable 190 | private fun DefaultPreview(@PreviewParameter(UiStateProvider::class) state: CouponPreviewUiState) { 191 | DomainDrivenAndroidTheme { 192 | CouponPreviewScreen(state = state, onBack = { }, onCollect = { }) 193 | } 194 | } 195 | 196 | private class UiStateProvider : PreviewParameterProvider { 197 | override val values = sequenceOf( 198 | CouponPreviewUiState(), 199 | CouponPreviewUiState( 200 | coupon = sampleCollectableCoupons[0], 201 | isLoading = false, 202 | ), 203 | CouponPreviewUiState( 204 | coupon = sampleCollectableCoupons[1], 205 | isLoading = false, 206 | ), 207 | CouponPreviewUiState( 208 | coupon = sampleCollectableCoupons[1], 209 | isLoading = false, 210 | isCollecting = true, 211 | ), 212 | CouponPreviewUiState( 213 | coupon = sampleCollectableCoupons[1].copy( 214 | coupon = sampleCollectableCoupons[1].coupon.collect(sampleActivationCode) 215 | ), 216 | isLoading = false, 217 | ) 218 | ) 219 | } 220 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/couponPreview/CouponPreviewUiState.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.couponPreview 2 | 3 | import com.maruchin.domaindrivenandroid.domain.coupon.CollectableCoupon 4 | import com.maruchin.domaindrivenandroid.ui.placeholderCollectableCoupon 5 | 6 | data class CouponPreviewUiState( 7 | val coupon: CollectableCoupon = placeholderCollectableCoupon(), 8 | val isLoading: Boolean = true, 9 | val isCollecting: Boolean = false, 10 | val failedToLoadCoupon: Boolean = false, 11 | ) 12 | 13 | fun CouponPreviewUiState.getCouponStatus(): CouponStatus { 14 | return when { 15 | isCollecting -> CouponStatus.COLLECTING 16 | coupon.coupon.activationCode != null -> CouponStatus.COLLECTED 17 | else -> CouponStatus.NOT_COLLECTED 18 | } 19 | } 20 | 21 | enum class CouponStatus { 22 | NOT_COLLECTED, COLLECTING, COLLECTED 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/couponPreview/CouponPreviewViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.couponPreview 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.maruchin.domaindrivenandroid.data.values.ID 6 | import com.maruchin.domaindrivenandroid.domain.coupon.CollectCouponUseCase 7 | import com.maruchin.domaindrivenandroid.domain.coupon.GetCollectableCouponUseCase 8 | import com.maruchin.domaindrivenandroid.ui.logError 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.SharingStarted 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.flow.catch 14 | import kotlinx.coroutines.flow.combine 15 | import kotlinx.coroutines.flow.filterNotNull 16 | import kotlinx.coroutines.flow.flatMapLatest 17 | import kotlinx.coroutines.flow.stateIn 18 | import kotlinx.coroutines.launch 19 | import javax.inject.Inject 20 | 21 | @HiltViewModel 22 | class CouponPreviewViewModel @Inject constructor( 23 | private val getCollectableCouponUseCase: GetCollectableCouponUseCase, 24 | private val collectCouponUseCase: CollectCouponUseCase, 25 | ) : ViewModel() { 26 | 27 | private val couponId = MutableStateFlow(null) 28 | private val isActivating = MutableStateFlow(false) 29 | 30 | private val coupon = couponId.filterNotNull().flatMapLatest { couponId -> 31 | getCollectableCouponUseCase(couponId) 32 | }.filterNotNull() 33 | 34 | val uiState: StateFlow = combine( 35 | coupon, 36 | isActivating 37 | ) { coupon, isActivating -> 38 | CouponPreviewUiState(coupon = coupon, isCollecting = isActivating, isLoading = false) 39 | }.catch { error -> 40 | logError(error) 41 | emit(CouponPreviewUiState(failedToLoadCoupon = true)) 42 | }.stateIn(viewModelScope, SharingStarted.Lazily, CouponPreviewUiState()) 43 | 44 | fun selectCoupon(couponId: ID) { 45 | this.couponId.value = couponId 46 | } 47 | 48 | fun collectCoupon() = viewModelScope.launch { 49 | isActivating.value = true 50 | couponId.value?.let { couponId -> 51 | collectCouponUseCase(couponId) 52 | } 53 | isActivating.value = false 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/home/HomeDestination.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.home 2 | 3 | import androidx.compose.runtime.collectAsState 4 | import androidx.compose.runtime.getValue 5 | import androidx.hilt.navigation.compose.hiltViewModel 6 | import androidx.navigation.NavGraphBuilder 7 | import androidx.navigation.compose.composable 8 | import com.maruchin.domaindrivenandroid.data.values.ID 9 | 10 | const val HOME_ROUTE = "home" 11 | 12 | fun NavGraphBuilder.homeScreen(onOpenCoupon: (ID) -> Unit, onLoggedOut: () -> Unit) { 13 | composable(HOME_ROUTE) { 14 | val viewModel = hiltViewModel() 15 | val state by viewModel.uiState.collectAsState() 16 | HomeScreen( 17 | state = state, 18 | onOpenCoupon = onOpenCoupon, 19 | onLogout = viewModel::logout, 20 | onLoggedOut = onLoggedOut, 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.home 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.aspectRatio 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.grid.GridCells 9 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 10 | import androidx.compose.foundation.lazy.grid.items 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.outlined.ExitToApp 13 | import androidx.compose.material3.ExperimentalMaterial3Api 14 | import androidx.compose.material3.Icon 15 | import androidx.compose.material3.IconButton 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.OutlinedCard 18 | import androidx.compose.material3.Scaffold 19 | import androidx.compose.material3.Text 20 | import androidx.compose.material3.TopAppBar 21 | import androidx.compose.material3.TopAppBarDefaults 22 | import androidx.compose.material3.TopAppBarScrollBehavior 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.LaunchedEffect 25 | import androidx.compose.runtime.getValue 26 | import androidx.compose.runtime.mutableStateOf 27 | import androidx.compose.runtime.remember 28 | import androidx.compose.runtime.setValue 29 | import androidx.compose.ui.Modifier 30 | import androidx.compose.ui.draw.alpha 31 | import androidx.compose.ui.input.nestedscroll.nestedScroll 32 | import androidx.compose.ui.platform.LocalDensity 33 | import androidx.compose.ui.text.SpanStyle 34 | import androidx.compose.ui.text.buildAnnotatedString 35 | import androidx.compose.ui.text.font.FontWeight 36 | import androidx.compose.ui.text.withStyle 37 | import androidx.compose.ui.tooling.preview.Preview 38 | import androidx.compose.ui.tooling.preview.PreviewParameter 39 | import androidx.compose.ui.tooling.preview.PreviewParameterProvider 40 | import androidx.compose.ui.unit.dp 41 | import coil.compose.AsyncImage 42 | import com.google.accompanist.placeholder.material.placeholder 43 | import com.maruchin.domaindrivenandroid.data.account.sampleAccount 44 | import com.maruchin.domaindrivenandroid.data.values.ID 45 | import com.maruchin.domaindrivenandroid.data.values.Points 46 | import com.maruchin.domaindrivenandroid.domain.coupon.CollectableCoupon 47 | import com.maruchin.domaindrivenandroid.domain.coupon.sampleCollectableCoupons 48 | import com.maruchin.domaindrivenandroid.ui.DomainDrivenAndroidTheme 49 | import com.maruchin.domaindrivenandroid.ui.format 50 | 51 | @OptIn(ExperimentalMaterial3Api::class) 52 | @Composable 53 | fun HomeScreen( 54 | state: HomeUiState, 55 | onOpenCoupon: (ID) -> Unit, 56 | onLogout: () -> Unit, 57 | onLoggedOut: () -> Unit, 58 | ) { 59 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() 60 | if (state.loggedOut) { 61 | LaunchedEffect(Unit) { onLoggedOut() } 62 | } 63 | Scaffold( 64 | topBar = { TopBar(scrollBehavior, state.myPoints, onLogout) } 65 | ) { paddingValues -> 66 | Box(modifier = Modifier.padding(paddingValues)) { 67 | CouponsListView( 68 | scrollBehavior = scrollBehavior, 69 | coupons = state.coupons, 70 | isLoading = state.isLoading, 71 | onOpenCoupon = onOpenCoupon, 72 | ) 73 | } 74 | } 75 | } 76 | 77 | @Composable 78 | @OptIn(ExperimentalMaterial3Api::class) 79 | private fun TopBar(scrollBehavior: TopAppBarScrollBehavior, points: Points?, onLogout: () -> Unit) { 80 | TopAppBar( 81 | title = { 82 | Text( 83 | text = buildAnnotatedString { 84 | append("My points: ") 85 | withStyle( 86 | SpanStyle( 87 | fontWeight = FontWeight.SemiBold, 88 | color = MaterialTheme.colorScheme.primary 89 | ) 90 | ) { 91 | append(points?.format() ?: "") 92 | } 93 | }, 94 | ) 95 | }, 96 | scrollBehavior = scrollBehavior, 97 | actions = { 98 | IconButton(onClick = onLogout) { 99 | Icon(imageVector = Icons.Outlined.ExitToApp, contentDescription = "Logout") 100 | } 101 | } 102 | ) 103 | } 104 | 105 | @OptIn(ExperimentalMaterial3Api::class) 106 | @Composable 107 | private fun CouponsListView( 108 | scrollBehavior: TopAppBarScrollBehavior, 109 | coupons: List, 110 | isLoading: Boolean, 111 | onOpenCoupon: (ID) -> Unit, 112 | ) { 113 | LazyVerticalGrid( 114 | columns = GridCells.Fixed(2), 115 | contentPadding = PaddingValues(6.dp), 116 | modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), 117 | ) { 118 | items(coupons) { coupon -> 119 | CouponView( 120 | coupon = coupon, 121 | isLoading = isLoading, 122 | onClick = { onOpenCoupon(coupon.coupon.id) }) 123 | } 124 | } 125 | } 126 | 127 | @OptIn(ExperimentalMaterial3Api::class) 128 | @Composable 129 | private fun CouponView(coupon: CollectableCoupon, isLoading: Boolean, onClick: () -> Unit) { 130 | val density = LocalDensity.current.density 131 | var couponNamePadding by remember { mutableStateOf(0.dp) } 132 | OutlinedCard( 133 | onClick = onClick, 134 | modifier = Modifier 135 | .padding(6.dp) 136 | .alpha(if (coupon.canCollect) 1f else 0.6f), 137 | ) { 138 | AsyncImage( 139 | model = coupon.coupon.image.toString().takeIf { it.isNotBlank() }, 140 | contentDescription = null, 141 | modifier = Modifier 142 | .fillMaxWidth() 143 | .aspectRatio(1f / 1f) 144 | .placeholder(isLoading), 145 | ) 146 | Text( 147 | text = coupon.coupon.name.value, 148 | style = MaterialTheme.typography.titleMedium, 149 | maxLines = 2, 150 | onTextLayout = { 151 | val lineCount = it.lineCount 152 | val height = (it.size.height / density).dp 153 | couponNamePadding = if (lineCount > 1) 0.dp else height 154 | }, 155 | modifier = Modifier 156 | .padding(horizontal = 12.dp) 157 | .padding(top = 12.dp, bottom = couponNamePadding) 158 | .placeholder(isLoading) 159 | ) 160 | Text( 161 | text = coupon.coupon.points.format(), 162 | style = MaterialTheme.typography.bodyLarge, 163 | color = MaterialTheme.colorScheme.primary, 164 | fontWeight = FontWeight.SemiBold, 165 | modifier = Modifier 166 | .padding(12.dp) 167 | .placeholder(isLoading), 168 | ) 169 | } 170 | } 171 | 172 | @Preview 173 | @Composable 174 | private fun DefaultPreview(@PreviewParameter(UiStateProvider::class) state: HomeUiState) { 175 | DomainDrivenAndroidTheme { 176 | HomeScreen(state = state, onOpenCoupon = {}, onLogout = {}, onLoggedOut = {}) 177 | } 178 | } 179 | 180 | private class UiStateProvider : PreviewParameterProvider { 181 | override val values = sequenceOf( 182 | HomeUiState(), 183 | HomeUiState( 184 | myPoints = sampleAccount.collectedPoints, 185 | coupons = sampleCollectableCoupons, 186 | isLoading = false, 187 | ) 188 | ) 189 | } 190 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/home/HomeUiState.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.home 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.maruchin.domaindrivenandroid.data.values.Points 5 | import com.maruchin.domaindrivenandroid.domain.coupon.CollectableCoupon 6 | import com.maruchin.domaindrivenandroid.ui.placeholderCollectableCoupon 7 | 8 | @Immutable 9 | data class HomeUiState( 10 | val myPoints: Points? = null, 11 | val coupons: List = getPlaceholderCoupons(), 12 | val isLoading: Boolean = true, 13 | val failedToLoadCoupons: Boolean = false, 14 | val loggedOut: Boolean = false, 15 | ) 16 | 17 | private fun getPlaceholderCoupons(): List { 18 | return (1..5).map(::placeholderCollectableCoupon) 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.home 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.maruchin.domaindrivenandroid.data.account.AccountRepository 6 | import com.maruchin.domaindrivenandroid.domain.coupon.GetAllCollectableCouponsUseCase 7 | import com.maruchin.domaindrivenandroid.ui.logError 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.SharingStarted 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.flow.catch 13 | import kotlinx.coroutines.flow.combine 14 | import kotlinx.coroutines.flow.filterNotNull 15 | import kotlinx.coroutines.flow.stateIn 16 | import kotlinx.coroutines.launch 17 | import javax.inject.Inject 18 | 19 | @HiltViewModel 20 | class HomeViewModel @Inject constructor( 21 | private val getAllCollectableCouponsUseCase: GetAllCollectableCouponsUseCase, 22 | private val accountRepository: AccountRepository, 23 | ) : ViewModel() { 24 | 25 | private val allCoupons = getAllCollectableCouponsUseCase() 26 | private val account = accountRepository.getLoggedInAccount().filterNotNull() 27 | private val loggedOut = MutableStateFlow(false) 28 | 29 | val uiState: StateFlow = combine( 30 | allCoupons, 31 | account, 32 | loggedOut 33 | ) { allCoupons, account, loggedOut -> 34 | HomeUiState( 35 | coupons = allCoupons, 36 | isLoading = false, 37 | myPoints = account.collectedPoints, 38 | loggedOut = loggedOut, 39 | ) 40 | }.catch { error -> 41 | logError(error) 42 | emit(HomeUiState(coupons = emptyList(), isLoading = false, failedToLoadCoupons = true)) 43 | }.stateIn(viewModelScope, SharingStarted.Lazily, HomeUiState()) 44 | 45 | fun logout() = viewModelScope.launch { 46 | accountRepository.clearLoggedInAccount() 47 | loggedOut.value = true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.main 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.getValue 8 | import androidx.hilt.navigation.compose.hiltViewModel 9 | import com.maruchin.domaindrivenandroid.ui.DomainDrivenAndroidTheme 10 | import com.maruchin.domaindrivenandroid.ui.navigation.MainNavHost 11 | import dagger.hilt.android.AndroidEntryPoint 12 | 13 | @AndroidEntryPoint 14 | class MainActivity : ComponentActivity() { 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | setContent { 18 | DomainDrivenAndroidTheme { 19 | val viewModel = hiltViewModel() 20 | val isLoggedIn by viewModel.isLoggedIn.collectAsState() 21 | isLoggedIn?.let { 22 | MainNavHost(isLoggedIn = it) 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.main 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.maruchin.domaindrivenandroid.data.account.AccountRepository 6 | import com.maruchin.domaindrivenandroid.ui.logError 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.flow.SharingStarted 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.flow.catch 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.flow.stateIn 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class MainViewModel @Inject constructor( 17 | private val accountRepository: AccountRepository, 18 | ) : ViewModel() { 19 | 20 | val isLoggedIn: StateFlow = accountRepository 21 | .getLoggedInAccount() 22 | .map { it != null } 23 | .catch { logError(it) } 24 | .stateIn(viewModelScope, SharingStarted.Lazily, null) 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/navigation/HomeGraph.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.navigation 2 | 3 | import androidx.navigation.NavController 4 | import androidx.navigation.NavGraph.Companion.findStartDestination 5 | import androidx.navigation.NavGraphBuilder 6 | import androidx.navigation.navigation 7 | import com.maruchin.domaindrivenandroid.ui.couponPreview.couponPreviewScreen 8 | import com.maruchin.domaindrivenandroid.ui.couponPreview.navigateToCouponPreview 9 | import com.maruchin.domaindrivenandroid.ui.home.HOME_ROUTE 10 | import com.maruchin.domaindrivenandroid.ui.home.homeScreen 11 | 12 | const val HOME_GRAPH_ROUTE = "home-graph" 13 | 14 | fun NavGraphBuilder.homeGraph(navController: NavController, onLoggedOut: () -> Unit) { 15 | navigation(startDestination = HOME_ROUTE, route = HOME_GRAPH_ROUTE) { 16 | homeScreen( 17 | onOpenCoupon = { navController.navigateToCouponPreview(it) }, 18 | onLoggedOut = onLoggedOut, 19 | ) 20 | couponPreviewScreen( 21 | onBack = { navController.navigateUp() }, 22 | ) 23 | } 24 | } 25 | 26 | fun NavController.navigateToHomeGraph() { 27 | navigate(HOME_GRAPH_ROUTE) { 28 | popUpTo(graph.findStartDestination().id) { 29 | inclusive = true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/navigation/MainNavHost.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.navigation 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.navigation.compose.NavHost 5 | import androidx.navigation.compose.rememberNavController 6 | 7 | @Composable 8 | fun MainNavHost(isLoggedIn: Boolean) { 9 | val navController = rememberNavController() 10 | NavHost( 11 | navController = navController, 12 | startDestination = if (isLoggedIn) HOME_GRAPH_ROUTE else REGISTRATION_GRAPH_ROUTE, 13 | ) { 14 | registrationGraph( 15 | navController = navController, 16 | onCompletedSuccessfully = { navController.navigateToHomeGraph() }, 17 | ) 18 | homeGraph( 19 | navController = navController, 20 | onLoggedOut = { navController.navigateToRegistrationGraph() }, 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/navigation/RegistrationGraph.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.navigation 2 | 3 | import androidx.navigation.NavController 4 | import androidx.navigation.NavGraph.Companion.findStartDestination 5 | import androidx.navigation.NavGraphBuilder 6 | import androidx.navigation.navigation 7 | import com.maruchin.domaindrivenandroid.ui.completeregistration.completeRegistrationScreen 8 | import com.maruchin.domaindrivenandroid.ui.completeregistration.navigateToCompleteRegistration 9 | import com.maruchin.domaindrivenandroid.ui.personaldataform.navigateToPersonalDataForm 10 | import com.maruchin.domaindrivenandroid.ui.personaldataform.personalDataFormScreen 11 | import com.maruchin.domaindrivenandroid.ui.termsandconditions.navigateToTermsAndConditions 12 | import com.maruchin.domaindrivenandroid.ui.termsandconditions.termsAndConditionsScreen 13 | import com.maruchin.domaindrivenandroid.ui.welcome.WELCOME_ROUTE 14 | import com.maruchin.domaindrivenandroid.ui.welcome.welcomeScreen 15 | 16 | const val REGISTRATION_GRAPH_ROUTE = "registration-graph" 17 | 18 | fun NavGraphBuilder.registrationGraph( 19 | navController: NavController, 20 | onCompletedSuccessfully: () -> Unit, 21 | ) { 22 | navigation(startDestination = WELCOME_ROUTE, route = REGISTRATION_GRAPH_ROUTE) { 23 | welcomeScreen( 24 | onSignUp = { navController.navigateToPersonalDataForm() }, 25 | onSignIn = {}, 26 | ) 27 | personalDataFormScreen( 28 | onBack = { navController.navigateUp() }, 29 | onProceed = { navController.navigateToTermsAndConditions() }, 30 | ) 31 | termsAndConditionsScreen( 32 | onBack = { navController.navigateUp() }, 33 | onContinue = { navController.navigateToCompleteRegistration() }, 34 | ) 35 | completeRegistrationScreen( 36 | onBack = { navController.navigateUp() }, 37 | onCompletedSuccessfully = onCompletedSuccessfully, 38 | ) 39 | } 40 | } 41 | 42 | fun NavController.navigateToRegistrationGraph() { 43 | navigate(REGISTRATION_GRAPH_ROUTE) { 44 | popUpTo(graph.findStartDestination().id) { 45 | inclusive = true 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/personaldataform/EmailFieldState.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.personaldataform 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.setValue 9 | import com.maruchin.domaindrivenandroid.data.values.Email 10 | 11 | @Stable 12 | class EmailFieldState(default: Email?) { 13 | 14 | var value by mutableStateOf(default?.value) 15 | 16 | val error: String? 17 | get() = value.let { 18 | when { 19 | it == null -> null 20 | it.isBlank() -> "Please enter your email address" 21 | else -> null 22 | } 23 | } 24 | 25 | val isValid: Boolean 26 | get() = value != null && error == null 27 | 28 | val completeEmail: Email? 29 | get() = value?.let(::Email) 30 | } 31 | 32 | @Composable 33 | fun rememberEmailFieldState(defaultValue: Email?) = remember(defaultValue) { 34 | EmailFieldState(defaultValue) 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/personaldataform/PersonalDataFormDestination.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.personaldataform 2 | 3 | import androidx.compose.runtime.collectAsState 4 | import androidx.compose.runtime.getValue 5 | import androidx.hilt.navigation.compose.hiltViewModel 6 | import androidx.navigation.NavController 7 | import androidx.navigation.NavGraphBuilder 8 | import androidx.navigation.compose.composable 9 | 10 | const val PERSONAL_DATA_FORM_ROUTE = "personal-data-form" 11 | 12 | fun NavGraphBuilder.personalDataFormScreen(onBack: () -> Unit, onProceed: () -> Unit) { 13 | composable(PERSONAL_DATA_FORM_ROUTE) { 14 | val viewModel = hiltViewModel() 15 | val state by viewModel.uiState.collectAsState() 16 | PersonalDataFormScreen( 17 | state = state, 18 | onBack = onBack, 19 | onProceed = { viewModel.proceed(it); onProceed() }, 20 | ) 21 | } 22 | } 23 | 24 | fun NavController.navigateToPersonalDataForm() { 25 | navigate(PERSONAL_DATA_FORM_ROUTE) 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/personaldataform/PersonalDataFormScreen.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.personaldataform 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.outlined.ArrowBack 12 | import androidx.compose.material.icons.outlined.Email 13 | import androidx.compose.material3.Button 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.IconButton 17 | import androidx.compose.material3.LargeTopAppBar 18 | import androidx.compose.material3.Scaffold 19 | import androidx.compose.material3.Text 20 | import androidx.compose.material3.TextField 21 | import androidx.compose.material3.TopAppBarDefaults 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.input.nestedscroll.nestedScroll 25 | import androidx.compose.ui.tooling.preview.Preview 26 | import androidx.compose.ui.unit.dp 27 | import com.maruchin.domaindrivenandroid.data.values.Email 28 | import com.maruchin.domaindrivenandroid.ui.DomainDrivenAndroidTheme 29 | import com.maruchin.domaindrivenandroid.ui.FieldErrorView 30 | 31 | @OptIn(ExperimentalMaterial3Api::class) 32 | @Composable 33 | fun PersonalDataFormScreen( 34 | state: PersonalDataFormUiState, 35 | onBack: () -> Unit, 36 | onProceed: (Email) -> Unit, 37 | ) { 38 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() 39 | val emailFieldState = rememberEmailFieldState(state.registrationRequest?.email) 40 | 41 | fun proceed() { 42 | emailFieldState.completeEmail?.let(onProceed) 43 | } 44 | 45 | Scaffold( 46 | topBar = { 47 | LargeTopAppBar( 48 | title = { Text(text = "Enter your personal data") }, 49 | navigationIcon = { 50 | IconButton( 51 | onClick = onBack 52 | ) { 53 | Icon( 54 | imageVector = Icons.Outlined.ArrowBack, 55 | contentDescription = "Navigate up" 56 | ) 57 | } 58 | }, 59 | scrollBehavior = scrollBehavior, 60 | ) 61 | } 62 | ) { padding -> 63 | Column( 64 | modifier = Modifier 65 | .fillMaxSize() 66 | .padding(padding) 67 | .verticalScroll(rememberScrollState()) 68 | .nestedScroll(scrollBehavior.nestedScrollConnection) 69 | ) { 70 | TextField( 71 | value = emailFieldState.value ?: "", 72 | onValueChange = { emailFieldState.value = it }, 73 | singleLine = true, 74 | label = { 75 | Text(text = "Email") 76 | }, 77 | leadingIcon = { 78 | Icon(imageVector = Icons.Outlined.Email, contentDescription = null) 79 | }, 80 | isError = emailFieldState.error != null, 81 | supportingText = { 82 | FieldErrorView(error = emailFieldState.error) 83 | }, 84 | modifier = Modifier 85 | .fillMaxWidth() 86 | .padding(horizontal = 12.dp, vertical = 24.dp) 87 | ) 88 | Spacer(modifier = Modifier.weight(1f)) 89 | Button( 90 | onClick = { proceed() }, 91 | enabled = emailFieldState.isValid, 92 | modifier = Modifier 93 | .fillMaxWidth() 94 | .padding(horizontal = 12.dp, vertical = 16.dp) 95 | ) { 96 | Text(text = "Proceed") 97 | } 98 | } 99 | } 100 | } 101 | 102 | @Preview 103 | @Composable 104 | private fun PersonalDataFormPreview() { 105 | DomainDrivenAndroidTheme { 106 | PersonalDataFormScreen(state = PersonalDataFormUiState(), onBack = {}, onProceed = {}) 107 | } 108 | } 109 | 110 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/personaldataform/PersonalDataFormUiState.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.personaldataform 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.maruchin.domaindrivenandroid.data.registrationrequest.RegistrationRequest 5 | 6 | @Immutable 7 | data class PersonalDataFormUiState( 8 | val registrationRequest: RegistrationRequest? = null, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/personaldataform/PersonalDataFormViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.personaldataform 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.maruchin.domaindrivenandroid.data.registrationrequest.RegistrationRequestRepository 6 | import com.maruchin.domaindrivenandroid.data.values.Email 7 | import com.maruchin.domaindrivenandroid.domain.registrationrequest.StartNewRegistrationUseCase 8 | import com.maruchin.domaindrivenandroid.ui.logError 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.SharingStarted 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.flow.catch 13 | import kotlinx.coroutines.flow.map 14 | import kotlinx.coroutines.flow.stateIn 15 | import kotlinx.coroutines.launch 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class PersonalDataFormViewModel @Inject constructor( 20 | private val registrationRequestRepository: RegistrationRequestRepository, 21 | private val startNewRegistrationUseCase: StartNewRegistrationUseCase, 22 | ) : ViewModel() { 23 | 24 | private val request = registrationRequestRepository.getRegistrationRequest() 25 | 26 | val uiState: StateFlow = request.map { registrationRequest -> 27 | PersonalDataFormUiState(registrationRequest = registrationRequest) 28 | }.catch { error -> 29 | logError(error) 30 | }.stateIn(viewModelScope, SharingStarted.Lazily, PersonalDataFormUiState()) 31 | 32 | fun proceed(email: Email) = viewModelScope.launch { 33 | startNewRegistrationUseCase(email) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/termsandconditions/TermsAndConditionsDestination.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.termsandconditions 2 | 3 | import androidx.compose.runtime.collectAsState 4 | import androidx.compose.runtime.getValue 5 | import androidx.hilt.navigation.compose.hiltViewModel 6 | import androidx.navigation.NavController 7 | import androidx.navigation.NavGraphBuilder 8 | import androidx.navigation.compose.composable 9 | 10 | const val TERMS_AND_CONDITIONS_ROUTE = "terms-and-conditions" 11 | 12 | fun NavGraphBuilder.termsAndConditionsScreen(onBack: () -> Unit, onContinue: () -> Unit) { 13 | composable(TERMS_AND_CONDITIONS_ROUTE) { 14 | val viewModel = hiltViewModel() 15 | val state by viewModel.uiState.collectAsState() 16 | TermsAndConditionsScreen( 17 | state = state, 18 | onBack = onBack, 19 | onContinue = { viewModel.proceed(); onContinue() }, 20 | ) 21 | } 22 | } 23 | 24 | fun NavController.navigateToTermsAndConditions() { 25 | navigate(TERMS_AND_CONDITIONS_ROUTE) 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/termsandconditions/TermsAndConditionsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.termsandconditions 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.verticalScroll 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.ArrowBack 13 | import androidx.compose.material3.Button 14 | import androidx.compose.material3.Checkbox 15 | import androidx.compose.material3.ExperimentalMaterial3Api 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.IconButton 18 | import androidx.compose.material3.LargeTopAppBar 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Scaffold 21 | import androidx.compose.material3.Text 22 | import androidx.compose.material3.TopAppBarDefaults 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.getValue 25 | import androidx.compose.runtime.mutableStateOf 26 | import androidx.compose.runtime.remember 27 | import androidx.compose.runtime.setValue 28 | import androidx.compose.ui.Alignment 29 | import androidx.compose.ui.Modifier 30 | import androidx.compose.ui.input.nestedscroll.nestedScroll 31 | import androidx.compose.ui.tooling.preview.Preview 32 | import androidx.compose.ui.unit.dp 33 | import com.maruchin.domaindrivenandroid.ui.DomainDrivenAndroidTheme 34 | 35 | @OptIn(ExperimentalMaterial3Api::class) 36 | @Composable 37 | fun TermsAndConditionsScreen( 38 | state: TermsAndConditionsUiState, 39 | onBack: () -> Unit, 40 | onContinue: () -> Unit, 41 | ) { 42 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() 43 | var accepted by remember(state.registrationRequest?.termsAndConditionsAccepted) { 44 | mutableStateOf(state.registrationRequest?.termsAndConditionsAccepted ?: false) 45 | } 46 | Scaffold( 47 | topBar = { 48 | LargeTopAppBar( 49 | title = { 50 | Text(text = "Terms & Conditions") 51 | }, 52 | navigationIcon = { 53 | IconButton(onClick = onBack) { 54 | Icon( 55 | imageVector = Icons.Default.ArrowBack, 56 | contentDescription = "Navigate up", 57 | ) 58 | } 59 | }, 60 | scrollBehavior = scrollBehavior, 61 | ) 62 | } 63 | ) { padding -> 64 | Column( 65 | modifier = Modifier 66 | .fillMaxSize() 67 | .padding(padding) 68 | .verticalScroll(rememberScrollState()) 69 | .nestedScroll(scrollBehavior.nestedScrollConnection) 70 | ) { 71 | Text( 72 | text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc non efficitur libero. Nam mattis vel justo non feugiat. Aliquam iaculis tempor mauris, nec sodales elit. Etiam vitae quam dignissim, pulvinar ipsum vitae, tempus nulla. Etiam in eros ac mauris tempus mollis vitae et tortor. Fusce ultricies, sapien at malesuada hendrerit, nulla lorem maximus eros, nec finibus nisi lorem eu erat. Duis posuere velit fermentum mi elementum, a dictum ex consequat. Donec euismod rutrum molestie. Ut mattis lectus et vulputate aliquam. Aliquam accumsan lectus sapien, sit amet sodales enim malesuada eu. Morbi ut vulputate eros, sit amet cursus mauris. Fusce at neque tempor, varius dolor et, imperdiet magna. Curabitur efficitur, orci in auctor venenatis, augue dolor maximus risus, tempus pellentesque massa mi sit amet felis. Pellentesque vulputate viverra fermentum. Suspendisse a ullamcorper purus.\n" + 73 | "\n" + 74 | "Fusce eget blandit tellus. Ut blandit justo vitae leo ornare, a tincidunt dolor laoreet. Phasellus ornare blandit nisl, sed mattis augue accumsan viverra. Sed aliquam justo ut urna tristique porta. Praesent est augue, ultrices eu velit vitae, euismod interdum sem. Cras finibus eros vel quam mollis, id maximus sem tempus. Quisque vel mi tincidunt ante laoreet mollis. Vivamus dictum mi lacus, at egestas dolor posuere sed. Morbi mattis porttitor est, id egestas urna porttitor at.", 75 | style = MaterialTheme.typography.bodySmall, 76 | modifier = Modifier.padding(20.dp), 77 | ) 78 | Row( 79 | modifier = Modifier.padding(horizontal = 4.dp, vertical = 20.dp), 80 | verticalAlignment = Alignment.CenterVertically 81 | ) { 82 | Checkbox(checked = accepted, onCheckedChange = { accepted = it }) 83 | Text( 84 | text = "I agree with the Terms and Conditions", 85 | style = MaterialTheme.typography.labelLarge 86 | ) 87 | } 88 | Spacer(modifier = Modifier.weight(1f)) 89 | Button( 90 | onClick = onContinue, 91 | enabled = accepted, 92 | modifier = Modifier 93 | .fillMaxWidth() 94 | .padding(horizontal = 12.dp, vertical = 16.dp), 95 | ) { 96 | Text(text = "Continue") 97 | } 98 | } 99 | } 100 | } 101 | 102 | @Preview 103 | @Composable 104 | private fun TermsAndConditionsPreview() { 105 | DomainDrivenAndroidTheme { 106 | TermsAndConditionsScreen(state = TermsAndConditionsUiState(), onBack = {}, onContinue = {}) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/termsandconditions/TermsAndConditionsUiState.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.termsandconditions 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.maruchin.domaindrivenandroid.data.registrationrequest.RegistrationRequest 5 | 6 | @Immutable 7 | data class TermsAndConditionsUiState( 8 | val registrationRequest: RegistrationRequest? = null, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/termsandconditions/TermsAndConditionsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.termsandconditions 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.maruchin.domaindrivenandroid.data.registrationrequest.RegistrationRequestRepository 6 | import com.maruchin.domaindrivenandroid.domain.registrationrequest.AcceptRegistrationTermsAndConditionsUseCase 7 | import com.maruchin.domaindrivenandroid.ui.logError 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.SharingStarted 10 | import kotlinx.coroutines.flow.StateFlow 11 | import kotlinx.coroutines.flow.catch 12 | import kotlinx.coroutines.flow.map 13 | import kotlinx.coroutines.flow.stateIn 14 | import kotlinx.coroutines.launch 15 | import javax.inject.Inject 16 | 17 | @HiltViewModel 18 | class TermsAndConditionsViewModel @Inject constructor( 19 | private val registrationRequestRepository: RegistrationRequestRepository, 20 | private val acceptRegistrationTermsAndConditionsUseCase: AcceptRegistrationTermsAndConditionsUseCase, 21 | ) : ViewModel() { 22 | 23 | private val request = registrationRequestRepository.getRegistrationRequest() 24 | 25 | val uiState: StateFlow = request.map { request -> 26 | TermsAndConditionsUiState(registrationRequest = request) 27 | }.catch { error -> 28 | logError(error) 29 | }.stateIn(viewModelScope, SharingStarted.Lazily, TermsAndConditionsUiState()) 30 | 31 | fun proceed() = viewModelScope.launch { 32 | acceptRegistrationTermsAndConditionsUseCase() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/welcome/WelcomeDestination.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.welcome 2 | 3 | import androidx.navigation.NavGraphBuilder 4 | import androidx.navigation.compose.composable 5 | 6 | const val WELCOME_ROUTE = "welcome" 7 | 8 | fun NavGraphBuilder.welcomeScreen(onSignUp: () -> Unit, onSignIn: () -> Unit) { 9 | composable(WELCOME_ROUTE) { 10 | WelcomeScreen(onSignUp = onSignUp, onSignIn = onSignIn) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/maruchin/domaindrivenandroid/ui/welcome/WelcomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.maruchin.domaindrivenandroid.ui.welcome 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.verticalScroll 11 | import androidx.compose.material3.Button 12 | import androidx.compose.material3.ExperimentalMaterial3Api 13 | import androidx.compose.material3.FilledTonalButton 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Scaffold 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.layout.ContentScale 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.text.style.TextAlign 23 | import androidx.compose.ui.tooling.preview.Preview 24 | import androidx.compose.ui.unit.dp 25 | import com.maruchin.domaindrivenandroid.R 26 | import com.maruchin.domaindrivenandroid.ui.DomainDrivenAndroidTheme 27 | 28 | @OptIn(ExperimentalMaterial3Api::class) 29 | @Composable 30 | fun WelcomeScreen(onSignUp: () -> Unit, onSignIn: () -> Unit) { 31 | Scaffold { padding -> 32 | Column( 33 | horizontalAlignment = Alignment.CenterHorizontally, 34 | modifier = Modifier 35 | .fillMaxSize() 36 | .padding(padding) 37 | .verticalScroll(rememberScrollState()), 38 | ) { 39 | Text( 40 | text = "Welcome", 41 | style = MaterialTheme.typography.displayMedium, 42 | textAlign = TextAlign.Center, 43 | modifier = Modifier 44 | .fillMaxWidth() 45 | .padding(vertical = 48.dp), 46 | ) 47 | Image( 48 | painter = painterResource(R.drawable.welcome), 49 | contentDescription = null, 50 | contentScale = ContentScale.FillWidth, 51 | modifier = Modifier.fillMaxWidth(), 52 | ) 53 | Spacer(modifier = Modifier.weight(1f)) 54 | Button( 55 | onClick = onSignUp, 56 | modifier = Modifier 57 | .padding(horizontal = 12.dp) 58 | .padding(top = 16.dp, bottom = 6.dp) 59 | .fillMaxWidth(), 60 | ) { 61 | Text(text = "Sign Up") 62 | } 63 | FilledTonalButton( 64 | onClick = onSignIn, 65 | modifier = Modifier 66 | .padding(horizontal = 12.dp) 67 | .padding(bottom = 16.dp, top = 6.dp) 68 | .fillMaxWidth() 69 | ) { 70 | Text(text = "Sign In") 71 | } 72 | } 73 | } 74 | } 75 | 76 | @Preview 77 | @Composable 78 | private fun WelcomePreview() { 79 | DomainDrivenAndroidTheme { 80 | WelcomeScreen(onSignUp = {}, onSignIn = {}) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/all_good.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/drawable/all_good.jpeg -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/welcome.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/drawable/welcome.jpeg -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Maruchin1/domain-driven-android/aa965d720721576568a549f121eb623dcc05f9d9/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 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Domain Driven Android 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |