├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── deploymentTargetDropDown.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jarRepositories.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── google-services.json ├── proguard-rules.pro ├── screenshots │ ├── 01_AddEmployeeScreen_Empty_Form.png │ ├── 01_AddEmployeeScreen_Failure.png │ └── 01_AddEmployeeScreen_Filled_Form.png └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── patrykkosieradzki │ │ └── androidmviexample │ │ ├── ui │ │ └── AddEmployeeScreenTest.kt │ │ └── utils │ │ ├── BaseComposeViewModelDefaultAnswer.kt │ │ ├── ComposeRobot.kt │ │ ├── Robot.kt │ │ ├── ScreenshotProcessor.kt │ │ └── ScreenshotsRunner.kt │ ├── debug │ └── AndroidManifest.xml │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── patrykkosieradzki │ │ │ └── androidmviexample │ │ │ ├── AndroidMVIExampleApplication.kt │ │ │ ├── ExampleAppConfiguration.kt │ │ │ ├── di │ │ │ └── AppModule.kt │ │ │ ├── ui │ │ │ ├── App.kt │ │ │ ├── AppDrawer.kt │ │ │ ├── MainActivity.kt │ │ │ ├── NavGraph.kt │ │ │ ├── composables │ │ │ │ ├── BaseScreen.kt │ │ │ │ ├── CenteredCircularProgressIndicator.kt │ │ │ │ ├── LifecycleUtils.kt │ │ │ │ └── snackbar │ │ │ │ │ └── HandleSnackbarIfSupported.kt │ │ │ ├── features │ │ │ │ ├── add │ │ │ │ │ ├── AddEmployeeContract.kt │ │ │ │ │ ├── AddEmployeeScreen.kt │ │ │ │ │ ├── AddEmployeeViewModel.kt │ │ │ │ │ └── components │ │ │ │ │ │ ├── AddEmployeeScreenBody.kt │ │ │ │ │ │ ├── EmployeeAddressList.kt │ │ │ │ │ │ └── EmployeeGenderRadioButtons.kt │ │ │ │ ├── details │ │ │ │ │ ├── EmployeeDetailsContract.kt │ │ │ │ │ ├── EmployeeDetailsFragment.kt │ │ │ │ │ └── EmployeeDetailsViewModel.kt │ │ │ │ └── employees │ │ │ │ │ ├── EmployeeListContract.kt │ │ │ │ │ ├── EmployeeListFragment.kt │ │ │ │ │ ├── EmployeeListScreen.kt │ │ │ │ │ └── EmployeeListViewModel.kt │ │ │ └── theme │ │ │ │ ├── AppColors.kt │ │ │ │ └── AppTheme.kt │ │ │ └── utils │ │ │ ├── Async.kt │ │ │ ├── BaseViewModel.kt │ │ │ ├── FlowObserver.kt │ │ │ ├── NavigationEvent.kt │ │ │ ├── OnItemClickListener.kt │ │ │ ├── SnackbarState.kt │ │ │ ├── UiEffect.kt │ │ │ ├── UiEvent.kt │ │ │ ├── UiState.kt │ │ │ ├── delegates │ │ │ └── CanDisplaySnackbar.kt │ │ │ └── extensions │ │ │ ├── FragmentExtensions.kt │ │ │ ├── LiveDataExtensions.kt │ │ │ └── ViewExtensions.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_add.xml │ │ ├── ic_arrow_back.xml │ │ ├── ic_close.xml │ │ └── ic_launcher_background.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── raw │ │ └── lottie_loading_animation.json │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimen.xml │ │ ├── ids.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── patrykkosieradzki │ └── androidmviexample │ ├── ui │ └── features │ │ └── add │ │ └── AddEmployeeViewModelTest.kt │ └── utils │ ├── BaseJunit4Test.kt │ └── MainCoroutineRule.kt ├── build.gradle ├── domain ├── .gitignore ├── build.gradle └── src │ └── main │ └── java │ └── com │ └── patrykkosieradzki │ └── androidmviexample │ └── domain │ ├── AppConfiguration.kt │ ├── DemoDataGenerator.kt │ ├── model │ ├── Address.kt │ ├── Employee.kt │ └── Gender.kt │ ├── repositories │ └── EmployeeRepository.kt │ └── usecases │ ├── DeleteEmployeeUseCase.kt │ ├── GetEmployeesUseCase.kt │ ├── GetGendersUseCase.kt │ ├── GetSingleEmployeeUseCase.kt │ ├── SaveEmployeeUseCase.kt │ └── UpdateEmployeeUseCase.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle ├── storage ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── patrykkosieradzki │ │ └── androidmviexample │ │ └── storage │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── patrykkosieradzki │ │ └── androidmviexample │ │ └── storage │ │ ├── DatabaseDemoDataGenerator.kt │ │ ├── dao │ │ └── EmployeeDao.kt │ │ ├── db │ │ └── AppDatabase.kt │ │ ├── di │ │ └── StorageModule.kt │ │ ├── entity │ │ ├── AddressEntity.kt │ │ ├── EmployeeEntity.kt │ │ └── GenderEntity.kt │ │ ├── model │ │ └── EmployeeWithGenderAndAddresses.kt │ │ └── repositories │ │ └── LocalEmployeeRepository.kt │ └── test │ └── java │ └── com │ └── patrykkosieradzki │ └── androidmviexample │ └── storage │ └── ExampleUnitTest.kt ├── utils ├── .gitignore ├── build.gradle └── src │ └── main │ └── java │ └── com │ └── patrykkosieradzki │ └── androidmviexample │ └── utils │ ├── AllOpen.kt │ └── MVIExampleDispatchers.kt └── versions.gradle /.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/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | xmlns:android 17 | 18 | ^$ 19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 | xmlns:.* 28 | 29 | ^$ 30 | 31 | 32 | BY_NAME 33 | 34 |
35 |
36 | 37 | 38 | 39 | .*:id 40 | 41 | http://schemas.android.com/apk/res/android 42 | 43 | 44 | 45 |
46 |
47 | 48 | 49 | 50 | .*:name 51 | 52 | http://schemas.android.com/apk/res/android 53 | 54 | 55 | 56 |
57 |
58 | 59 | 60 | 61 | name 62 | 63 | ^$ 64 | 65 | 66 | 67 |
68 |
69 | 70 | 71 | 72 | style 73 | 74 | ^$ 75 | 76 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | .* 84 | 85 | ^$ 86 | 87 | 88 | BY_NAME 89 | 90 |
91 |
92 | 93 | 94 | 95 | .* 96 | 97 | http://schemas.android.com/apk/res/android 98 | 99 | 100 | ANDROID_ATTRIBUTE_ORDER 101 | 102 |
103 |
104 | 105 | 106 | 107 | .* 108 | 109 | .* 110 | 111 | 112 | BY_NAME 113 | 114 |
115 |
116 |
117 |
118 | 119 | 121 |
122 |
-------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.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 | 23 | 24 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AndroidMVIExample 2 | Example Jetpack Compose Android App, that uses the newest mechanisms, like StateFlow, SharedFlow, etc. to manage states and handle events. ViewModel, UI and Screenshot tests included :) 3 | 4 | 5 | 6 | ## Architecture 7 | - Clean Architecture 8 | - Jetpack Compose 9 | - MVI 10 | - Flows 11 | 12 | ## Stack 13 | - Kotlin 14 | - Coroutines 15 | - Architecture Components 16 | * Jetpack Compose 17 | * ViewModel 18 | * Room 19 | * Paging 20 | - Koin (Dependency Injection) 21 | 22 | ## Firebase 23 | - Analytics 24 | - Crashlytics 25 | 26 | ## Testing 27 | - Unit tests 28 | * ViewModel and Bussiness logic tests 29 | * Junit 4 30 | * Mockito-Kotlin 31 | * Google Truth 32 | - UI tests 33 | * Using createComposeRule for composable tests 34 | * Robot pattern 35 | - Screenshot tests 36 | * node.captureToImage().asAndroidBitmap() for taking composables screenshots 37 | * Custom gradle tasks for pulling screenshots from the device 38 | * **Run UI tests on Android Emulator API 30 without Google Play! (Google APIs is OK)** 39 | 40 | # License 41 | 42 | Copyright 2021 Patryk Marciszek-Kosieradzki 43 | 44 | Licensed under the Apache License, Version 2.0 (the "License"); 45 | 46 | you may not use this file except in compliance with the License. 47 | 48 | You may obtain a copy of the License at 49 | 50 | http://www.apache.org/licenses/LICENSE-2.0 51 | 52 | Unless required by applicable law or agreed to in writing, software 53 | 54 | distributed under the License is distributed on an "AS IS" BASIS, 55 | 56 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 57 | 58 | See the License for the specific language governing permissions and 59 | 60 | limitations under the License. 61 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | google-services.json -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | ext.screenshotsEnabled = gradle.startParameter.taskNames.size() > 0 && gradle.startParameter.taskNames.get(0) == "connectedDebugAndroidTest" 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath "org.jetbrains.kotlin:kotlin-allopen:$versions.kotlin" 10 | } 11 | } 12 | 13 | apply plugin: 'com.android.application' 14 | apply plugin: 'kotlin-android' 15 | apply plugin: 'kotlin-parcelize' 16 | apply plugin: "androidx.navigation.safeargs.kotlin" 17 | apply plugin: "kotlin-allopen" 18 | apply plugin: 'kotlin-kapt' 19 | apply plugin: 'com.google.gms.google-services' 20 | apply plugin: 'com.google.firebase.crashlytics' 21 | 22 | def appId = "com.patrykkosieradzki.androidmviexample" 23 | 24 | android { 25 | compileSdkVersion 30 26 | buildToolsVersion "30.0.3" 27 | 28 | defaultConfig { 29 | applicationId appId 30 | minSdkVersion 24 31 | targetSdkVersion 30 32 | versionCode 1 33 | versionName "1.0" 34 | 35 | testInstrumentationRunnerArguments clearPackageData: 'true' 36 | testInstrumentationRunner "com.patrykkosieradzki.androidmviexample.utils.ScreenshotsRunner" 37 | if (project.ext.screenshotsEnabled == true) { 38 | testInstrumentationRunnerArguments screenshotsEnabled: 'true' 39 | } 40 | 41 | vectorDrawables { 42 | useSupportLibrary = true 43 | } 44 | } 45 | 46 | testOptions { 47 | if (project.ext.screenshotsEnabled == true) { 48 | if (!isIdeBuild) { 49 | execution 'ANDROIDX_TEST_ORCHESTRATOR' 50 | } 51 | } 52 | animationsDisabled = true 53 | } 54 | 55 | buildTypes { 56 | debug { 57 | minifyEnabled false 58 | } 59 | release { 60 | minifyEnabled true 61 | shrinkResources true 62 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 63 | } 64 | } 65 | 66 | adbOptions { 67 | installOptions '-g', '-r' 68 | } 69 | 70 | buildFeatures { 71 | dataBinding true 72 | compose true 73 | } 74 | 75 | composeOptions { 76 | kotlinCompilerVersion "1.5.10" 77 | kotlinCompilerExtensionVersion "1.0.0-beta09" 78 | } 79 | 80 | compileOptions { 81 | sourceCompatibility JavaVersion.VERSION_1_8 82 | targetCompatibility JavaVersion.VERSION_1_8 83 | } 84 | 85 | kotlinOptions { 86 | jvmTarget = JavaVersion.VERSION_1_8.toString() 87 | freeCompilerArgs += [ 88 | "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", 89 | "-Xuse-experimental=kotlinx.coroutines.ObsoleteCoroutinesApi"] 90 | } 91 | 92 | packagingOptions { 93 | exclude "**/attach_hotspot_windows.dll" 94 | exclude "META-INF/licenses/**" 95 | exclude "META-INF/AL2.0" 96 | exclude "META-INF/LGPL2.1" 97 | } 98 | } 99 | 100 | allOpen { 101 | annotation("com.patrykkosieradzki.androidmviexample.utils.AllOpen") 102 | } 103 | 104 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 105 | kotlinOptions { 106 | jvmTarget = "1.8" 107 | } 108 | } 109 | 110 | dependencies { 111 | implementation(project(":domain")) 112 | implementation(project(":storage")) 113 | implementation(project(":utils")) 114 | 115 | implementation fileTree(dir: 'libs', include: ['*.jar']) 116 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin" 117 | implementation "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin" 118 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1' 119 | 120 | implementation 'com.google.android.material:material:1.4.0' 121 | implementation 'androidx.appcompat:appcompat:1.3.1' 122 | 123 | implementation 'androidx.core:core-ktx:1.6.0' 124 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 125 | 126 | implementation deps.lifecycle.runtime 127 | implementation deps.lifecycle.livedata_ktx 128 | implementation deps.lifecycle.extensions 129 | implementation deps.lifecycle.viewmodel_ktx 130 | 131 | testImplementation 'junit:junit:4.13.2' 132 | 133 | implementation "androidx.fragment:fragment-ktx:1.3.6" 134 | 135 | implementation "androidx.navigation:navigation-runtime-ktx:2.3.5" 136 | implementation "androidx.navigation:navigation-fragment-ktx:2.3.5" 137 | implementation "androidx.navigation:navigation-ui-ktx:2.3.5" 138 | implementation "androidx.navigation:navigation-compose:2.4.0-alpha05" 139 | 140 | def koin_version = "3.1.0" 141 | implementation "io.insert-koin:koin-core:$koin_version" 142 | implementation "io.insert-koin:koin-androidx-compose:$koin_version" 143 | 144 | // Testing 145 | testImplementation "com.google.truth:truth:1.1.2" 146 | testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" 147 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0' 148 | testImplementation 'androidx.arch.core:core-testing:2.1.0' 149 | 150 | // AndroidTests 151 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 152 | androidTestImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") { 153 | exclude group: 'org.mockito' 154 | } 155 | androidTestImplementation "com.google.truth:truth:1.1.2" 156 | 157 | androidTestImplementation 'org.mockito:mockito-android:3.8.0' 158 | androidTestUtil 'androidx.test:orchestrator:1.4.0' 159 | androidTestImplementation 'androidx.test:core:1.4.0' 160 | androidTestImplementation 'androidx.test:runner:1.4.0' 161 | androidTestImplementation 'androidx.test:rules:1.4.0' 162 | 163 | implementation 'com.jakewharton.timber:timber:4.7.1' 164 | implementation "com.github.hadilq.liveevent:liveevent:1.2.0" 165 | 166 | implementation("org.jetbrains.kotlin:kotlin-stdlib") 167 | implementation 'com.airbnb.android:lottie:3.6.1' 168 | 169 | // Firebase 170 | implementation platform('com.google.firebase:firebase-bom:27.1.0') 171 | implementation 'com.google.firebase:firebase-crashlytics-ktx' 172 | implementation 'com.google.firebase:firebase-analytics-ktx' 173 | 174 | def paging_version = "3.0.0-beta01" 175 | implementation "androidx.paging:paging-runtime-ktx:$paging_version" 176 | 177 | implementation 'androidx.compose.ui:ui:1.0.0' 178 | implementation 'androidx.compose.ui:ui-tooling:1.0.0' 179 | implementation 'androidx.compose.foundation:foundation:1.0.0' 180 | implementation 'androidx.compose.material:material:1.0.0' 181 | implementation 'androidx.compose.material:material-icons-core:1.0.0' 182 | implementation 'androidx.compose.material:material-icons-extended:1.0.0' 183 | implementation 'androidx.paging:paging-compose:1.0.0-alpha12' 184 | implementation 'androidx.activity:activity-compose:1.3.0' 185 | implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07' 186 | implementation 'androidx.compose.runtime:runtime-livedata:1.0.0' 187 | 188 | implementation "androidx.compose.compiler:compiler:1.0.0" 189 | 190 | def accompanist_version = "0.12.0" 191 | implementation "com.google.accompanist:accompanist-swiperefresh:$accompanist_version" 192 | implementation "com.google.accompanist:accompanist-insets:$accompanist_version" 193 | implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" 194 | 195 | androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.0.0") 196 | debugImplementation("androidx.compose.ui:ui-test-manifest:1.0.0") 197 | 198 | debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' 199 | } 200 | 201 | def projectScreenshotsDirectory = "$projectDir/screenshots" 202 | def deviceScreenshotsDirectory = '/sdcard/Pictures/' + appId + '/debug/screenshots' 203 | 204 | def clearScreenshotsTask = task('clearScreenshots', type: Exec) { 205 | executable "${android.getAdbExe().toString()}" 206 | args 'shell', 'rm', '-r', deviceScreenshotsDirectory 207 | } 208 | 209 | def createScreenshotDirectoryTask = task('createScreenshotDirectory', type: Exec, group: 'reporting') { 210 | executable "${android.getAdbExe().toString()}" 211 | args 'shell', 'mkdir', '-p', deviceScreenshotsDirectory 212 | } 213 | 214 | def fetchScreenshotsTask = task('fetchScreenshots', type: Exec, group: 'reporting') { 215 | executable "${android.getAdbExe().toString()}" 216 | args 'pull', deviceScreenshotsDirectory + '/.', projectScreenshotsDirectory 217 | finalizedBy { 218 | clearScreenshotsTask 219 | } 220 | 221 | dependsOn { 222 | createScreenshotDirectoryTask 223 | } 224 | 225 | doFirst { 226 | new File(projectScreenshotsDirectory).mkdirs() 227 | } 228 | } 229 | 230 | tasks.whenTaskAdded { task -> 231 | if (task.name == 'connectedDebugAndroidTest') { 232 | task.finalizedBy { 233 | fetchScreenshotsTask 234 | } 235 | } 236 | } -------------------------------------------------------------------------------- /app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "848139007731", 4 | "project_id": "androidmviexample", 5 | "storage_bucket": "androidmviexample.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:848139007731:android:e96a031114ca9b4c77a986", 11 | "android_client_info": { 12 | "package_name": "com.patrykkosieradzki.androidmviexample" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "848139007731-30do3ruqut1mhjctgbdpvna7p2i7htpf.apps.googleusercontent.com", 18 | "client_type": 3 19 | } 20 | ], 21 | "api_key": [ 22 | { 23 | "current_key": "AIzaSyBuhK29kYNTT8qmUM9tQSzgrkNqMsnyC6M" 24 | } 25 | ], 26 | "services": { 27 | "appinvite_service": { 28 | "other_platform_oauth_client": [ 29 | { 30 | "client_id": "848139007731-30do3ruqut1mhjctgbdpvna7p2i7htpf.apps.googleusercontent.com", 31 | "client_type": 3 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | ], 38 | "configuration_version": "1" 39 | } -------------------------------------------------------------------------------- /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/screenshots/01_AddEmployeeScreen_Empty_Form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/screenshots/01_AddEmployeeScreen_Empty_Form.png -------------------------------------------------------------------------------- /app/screenshots/01_AddEmployeeScreen_Failure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/screenshots/01_AddEmployeeScreen_Failure.png -------------------------------------------------------------------------------- /app/screenshots/01_AddEmployeeScreen_Filled_Form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/screenshots/01_AddEmployeeScreen_Filled_Form.png -------------------------------------------------------------------------------- /app/src/androidTest/java/com/patrykkosieradzki/androidmviexample/ui/AddEmployeeScreenTest.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui 2 | 3 | import androidx.compose.ui.test.junit4.ComposeContentTestRule 4 | import androidx.compose.ui.test.junit4.createComposeRule 5 | import com.nhaarman.mockitokotlin2.mock 6 | import com.patrykkosieradzki.androidmviexample.domain.model.Address 7 | import com.patrykkosieradzki.androidmviexample.domain.model.Gender 8 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeContract 9 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeScreen 10 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeViewModel 11 | import com.patrykkosieradzki.androidmviexample.utils.ComposeRobot 12 | import com.patrykkosieradzki.androidmviexample.utils.RobotTest 13 | import com.patrykkosieradzki.androidmviexample.utils.UiState 14 | import com.patrykkosieradzki.androidmviexample.utils.assertTextDisplayed 15 | import org.junit.Rule 16 | import org.junit.Test 17 | import java.lang.Exception 18 | 19 | class AddEmployeeScreenTest : RobotTest() { 20 | 21 | @get:Rule 22 | val composeTestRule = createComposeRule() 23 | 24 | @Test 25 | fun shouldShowFormOnSuccessState() { 26 | withRobot { 27 | startScreen(initialUiState = UiState.Success(AddEmployeeContract.State())) 28 | loadGenders() 29 | capture("01_AddEmployeeScreen_Empty_Form") 30 | fillForm() 31 | capture("01_AddEmployeeScreen_Filled_Form") 32 | } 33 | } 34 | 35 | @Test 36 | fun shouldShowErrorState() { 37 | withRobot { 38 | startScreen(initialUiState = UiState.Failure(Exception())) 39 | capture("01_AddEmployeeScreen_Failure") 40 | assertTextDisplayed("Error occurred") 41 | } 42 | } 43 | 44 | override fun createRobot() = AddEmployeeScreenTestRobot(composeTestRule) 45 | } 46 | 47 | class AddEmployeeScreenTestRobot( 48 | composeTestRule: ComposeContentTestRule 49 | ) : 50 | ComposeRobot( 51 | AddEmployeeViewModel::class.java, 52 | composeTestRule 53 | ) { 54 | 55 | fun startScreen(initialUiState: UiState) { 56 | setViewModel( 57 | AddEmployeeViewModel( 58 | getGendersUseCase = mock(), 59 | saveEmployeeUseCase = mock() 60 | ), 61 | initialUiState = initialUiState 62 | ) 63 | setContent { 64 | AddEmployeeScreen(viewModel = it) 65 | } 66 | } 67 | 68 | fun loadGenders() { 69 | setUiState( 70 | UiState.Success( 71 | AddEmployeeContract.State( 72 | genders = listOf( 73 | Gender(id = 1, name = "Male"), 74 | Gender(id = 2, name = "Female") 75 | ) 76 | ) 77 | ) 78 | ) 79 | } 80 | 81 | fun fillForm() { 82 | setUiState( 83 | UiState.Success( 84 | AddEmployeeContract.State( 85 | genders = listOf( 86 | Gender(id = 1, name = "Male"), 87 | Gender(id = 2, name = "Female") 88 | ), 89 | gender = "Male", 90 | firstName = "John", 91 | lastName = "Doe", 92 | addresses = listOf( 93 | Address(name = "SomeStreet 1/2"), 94 | Address(name = "SecondStreet 4/3"), 95 | Address(name = "SuperStreet 11/5"), 96 | ) 97 | ) 98 | ) 99 | ) 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/patrykkosieradzki/androidmviexample/utils/BaseComposeViewModelDefaultAnswer.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import org.mockito.Answers 6 | import org.mockito.invocation.InvocationOnMock 7 | import org.mockito.stubbing.Answer 8 | 9 | class BaseComposeViewModelDefaultAnswer( 10 | defaultViewState: UiState<*> = UiState.Loading, 11 | defaultSnackbarState: SnackbarState = SnackbarState() 12 | ) : Answer { 13 | private val uiState = MutableStateFlow(defaultViewState) 14 | private val snackbarState = MutableStateFlow(defaultSnackbarState) 15 | private val eventHandler: (EVENT) -> Unit = {} 16 | 17 | override fun answer(invocation: InvocationOnMock?): Any? { 18 | return when (invocation!!.method.returnType) { 19 | StateFlow::class.java -> when (invocation.method.name) { 20 | "getUiState" -> uiState 21 | "getSnackbarState" -> snackbarState 22 | else -> Answers.RETURNS_DEFAULTS.answer(invocation) 23 | } 24 | Function1::class.java -> when (invocation.method.name) { 25 | "getEventHandler" -> eventHandler 26 | else -> Answers.RETURNS_DEFAULTS.answer(invocation) 27 | } 28 | else -> Answers.RETURNS_DEFAULTS.answer(invocation) 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/patrykkosieradzki/androidmviexample/utils/ComposeRobot.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.test.assertIsDisplayed 5 | import androidx.compose.ui.test.junit4.ComposeContentTestRule 6 | import androidx.compose.ui.test.onNodeWithText 7 | import androidx.compose.ui.test.onRoot 8 | import com.nhaarman.mockitokotlin2.whenever 9 | import com.patrykkosieradzki.androidmviexample.ui.theme.AppTheme 10 | import org.mockito.Mockito.mock 11 | 12 | open class ComposeRobot>( 13 | private val clazz: Class, 14 | val composeTestRule: ComposeContentTestRule 15 | ) : Robot() { 16 | private lateinit var viewModel: VM 17 | 18 | fun createAndSetMockViewModel( 19 | initialUiState: UiState = UiState.Loading, 20 | snackbarState: SnackbarState = SnackbarState() 21 | ) { 22 | viewModel = mock(clazz, BaseComposeViewModelDefaultAnswer(initialUiState)) 23 | whenever(viewModel.initialState).thenReturn(initialUiState) 24 | whenever(viewModel.initialSnackbarState).thenReturn(snackbarState) 25 | } 26 | 27 | fun setViewModel(viewModel: VM, initialUiState: UiState) { 28 | this.viewModel = viewModel 29 | setUiState(initialUiState) 30 | } 31 | 32 | fun setContent(content: @Composable (viewModel: VM) -> Unit) { 33 | composeTestRule.setContent { 34 | AppTheme { 35 | content(viewModel) 36 | } 37 | } 38 | } 39 | 40 | fun onViewModel(actions: VM.() -> Unit) { 41 | composeTestRule.runOnUiThread { 42 | if (::viewModel.isInitialized) { 43 | actions(viewModel) 44 | } 45 | } 46 | } 47 | 48 | fun setUiState(newUiState: UiState) { 49 | onViewModel { 50 | updateUiState { 51 | newUiState 52 | } 53 | } 54 | } 55 | 56 | fun capture(screenshotName: String) { 57 | captureAndCompare( 58 | screenshotName = screenshotName, 59 | node = composeTestRule.onRoot() 60 | ) 61 | } 62 | } 63 | 64 | fun ComposeRobot<*, *, *>.assertTextDisplayed(text: String) { 65 | composeTestRule.onNodeWithText(text).assertIsDisplayed() 66 | } 67 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/patrykkosieradzki/androidmviexample/utils/Robot.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import java.util.concurrent.TimeUnit 4 | 5 | open class Robot { 6 | fun wait(seconds: Int) = TimeUnit.SECONDS.sleep(seconds.toLong()) 7 | 8 | companion object { 9 | var screenshotsEnabled: Boolean = false 10 | } 11 | } 12 | 13 | abstract class RobotTest { 14 | 15 | protected fun withRobot(steps: R.() -> Unit) { 16 | createRobot().apply(steps) 17 | } 18 | 19 | abstract fun createRobot(): R 20 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/patrykkosieradzki/androidmviexample/utils/ScreenshotProcessor.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import android.graphics.Bitmap 4 | import android.os.Build 5 | import android.os.Environment.DIRECTORY_PICTURES 6 | import android.os.Environment.getExternalStoragePublicDirectory 7 | import androidx.annotation.RequiresApi 8 | import androidx.compose.ui.graphics.asAndroidBitmap 9 | import androidx.compose.ui.test.SemanticsNodeInteraction 10 | import androidx.compose.ui.test.captureToImage 11 | import com.patrykkosieradzki.androidmviexample.BuildConfig 12 | import java.io.File 13 | import java.io.FileOutputStream 14 | 15 | @RequiresApi(Build.VERSION_CODES.O) 16 | fun captureAndCompare( 17 | screenshotName: String, 18 | node: SemanticsNodeInteraction 19 | ) { 20 | if (Robot.screenshotsEnabled) { 21 | val bitmap = node.captureToImage().asAndroidBitmap() 22 | saveScreenshot(screenshotName, bitmap) 23 | // val golden = InstrumentationRegistry.getInstrumentation() 24 | // .context.resources.assets.open("$goldenName.png").use { BitmapFactory.decodeStream(it) } 25 | // 26 | // golden.compare(bitmap) 27 | } 28 | } 29 | 30 | @Suppress("DEPRECATION") 31 | private fun saveScreenshot(filename: String, bmp: Bitmap) { 32 | val picturesPath = getExternalStoragePublicDirectory(DIRECTORY_PICTURES).canonicalPath 33 | val folderPath = 34 | "$picturesPath/${BuildConfig.APPLICATION_ID}/${BuildConfig.BUILD_TYPE}/screenshots" 35 | val folder = File(folderPath).apply { mkdirs() } 36 | val file = File(folder, "$filename.png").apply { createNewFile() } 37 | FileOutputStream(file).use { out -> 38 | bmp.compress(Bitmap.CompressFormat.PNG, 100, out) 39 | } 40 | println("Saved screenshot to ${file.canonicalPath}") 41 | } 42 | 43 | private fun Bitmap.compare(other: Bitmap) { 44 | if (this.width != other.width || this.height != other.height) { 45 | throw AssertionError("Size of screenshot does not match golden file (check device density)") 46 | } 47 | // Compare row by row to save memory on device 48 | val row1 = IntArray(width) 49 | val row2 = IntArray(width) 50 | for (column in 0 until height) { 51 | // Read one row per bitmap and compare 52 | this.getRow(row1, column) 53 | other.getRow(row2, column) 54 | if (!row1.contentEquals(row2)) { 55 | throw AssertionError("Sizes match but bitmap content has differences") 56 | } 57 | } 58 | } 59 | 60 | private fun Bitmap.getRow(pixels: IntArray, column: Int) { 61 | this.getPixels(pixels, 0, width, 0, column, width, 1) 62 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/patrykkosieradzki/androidmviexample/utils/ScreenshotsRunner.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import android.os.Bundle 4 | import androidx.test.runner.AndroidJUnitRunner 5 | 6 | class ScreenshotsRunner : AndroidJUnitRunner() { 7 | override fun onCreate(arguments: Bundle) { 8 | super.onCreate(arguments) 9 | Robot.screenshotsEnabled = 10 | "true" == arguments.getString("screenshotsEnabled", "false") 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/AndroidMVIExampleApplication.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample 2 | 3 | import android.app.Application 4 | import com.google.firebase.FirebaseApp 5 | import com.patrykkosieradzki.androidmviexample.di.appModule 6 | import com.patrykkosieradzki.androidmviexample.storage.di.storageModule 7 | import org.koin.android.ext.koin.androidContext 8 | import org.koin.core.context.startKoin 9 | import timber.log.Timber 10 | 11 | class AndroidMVIExampleApplication : Application() { 12 | override fun onCreate() { 13 | super.onCreate() 14 | // FirebaseApp.initializeApp(this) 15 | 16 | if (BuildConfig.DEBUG) { 17 | Timber.plant(Timber.DebugTree()) 18 | } 19 | 20 | startKoin { 21 | androidContext(this@AndroidMVIExampleApplication) 22 | modules(appModule, storageModule) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ExampleAppConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample 2 | 3 | import com.patrykkosieradzki.androidmviexample.domain.AppConfiguration 4 | 5 | class ExampleAppConfiguration : AppConfiguration { 6 | override val debug = BuildConfig.DEBUG 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.di 2 | 3 | import com.patrykkosieradzki.androidmviexample.ExampleAppConfiguration 4 | import com.patrykkosieradzki.androidmviexample.domain.AppConfiguration 5 | import com.patrykkosieradzki.androidmviexample.domain.usecases.GetGendersUseCase 6 | import com.patrykkosieradzki.androidmviexample.domain.usecases.GetGendersUseCaseImpl 7 | import com.patrykkosieradzki.androidmviexample.domain.usecases.SaveEmployeeUseCase 8 | import com.patrykkosieradzki.androidmviexample.domain.usecases.SaveEmployeeUseCaseImpl 9 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeViewModel 10 | import com.patrykkosieradzki.androidmviexample.ui.features.details.EmployeeDetailsViewModel 11 | import com.patrykkosieradzki.androidmviexample.ui.features.employees.EmployeeListViewModel 12 | import org.koin.androidx.viewmodel.dsl.viewModel 13 | import org.koin.dsl.module 14 | 15 | val appModule = module { 16 | 17 | single { 18 | ExampleAppConfiguration() 19 | } 20 | 21 | factory { 22 | GetGendersUseCaseImpl( 23 | employeeRepository = get() 24 | ) 25 | } 26 | 27 | factory { 28 | SaveEmployeeUseCaseImpl( 29 | employeeRepository = get() 30 | ) 31 | } 32 | 33 | viewModel { 34 | EmployeeListViewModel( 35 | employeeDao = get() 36 | ) 37 | } 38 | 39 | viewModel { 40 | AddEmployeeViewModel( 41 | getGendersUseCase = get(), 42 | saveEmployeeUseCase = get() 43 | ) 44 | } 45 | 46 | viewModel { 47 | EmployeeDetailsViewModel() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/App.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui 2 | 3 | import androidx.compose.material.Scaffold 4 | import androidx.compose.material.Text 5 | import androidx.compose.material.TopAppBar 6 | import androidx.compose.material.rememberScaffoldState 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.navigation.compose.currentBackStackEntryAsState 10 | import androidx.navigation.compose.rememberNavController 11 | import com.google.accompanist.insets.ProvideWindowInsets 12 | import com.patrykkosieradzki.androidmviexample.ui.theme.AppTheme 13 | 14 | @Composable 15 | fun App() { 16 | AppTheme { 17 | ProvideWindowInsets { 18 | val navController = rememberNavController() 19 | val scaffoldState = rememberScaffoldState() 20 | val navBackStackEntry by navController.currentBackStackEntryAsState() 21 | val currentRoute = 22 | navBackStackEntry?.destination?.route ?: MyDestination.ADD_EMPLOYEE_PATH 23 | 24 | Scaffold( 25 | scaffoldState = scaffoldState, 26 | topBar = { 27 | TopAppBar( 28 | title = { Text(getTitleForRoute(currentRoute)) } 29 | ) 30 | }, 31 | drawerContent = { 32 | AppDrawer( 33 | currentRoute = getTitleForRoute(currentRoute), 34 | navController = navController 35 | ) 36 | } 37 | ) { 38 | NavGraph( 39 | navController = navController, 40 | scaffoldState = scaffoldState, 41 | ) 42 | } 43 | } 44 | } 45 | } 46 | 47 | fun getTitleForRoute(route: String): String { 48 | return when (route) { 49 | MyDestination.ADD_EMPLOYEE_PATH -> "Add Employee" 50 | MyDestination.EMPLOYEE_LIST_PATH -> "Employee list" 51 | else -> "Unknown route" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/AppDrawer.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui 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.height 7 | import androidx.compose.material.Text 8 | import androidx.compose.material.TextButton 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | import androidx.navigation.NavController 13 | 14 | @Composable 15 | fun AppDrawer( 16 | currentRoute: String = "", 17 | navController: NavController 18 | ) { 19 | Column(modifier = Modifier.fillMaxSize()) { 20 | Spacer(Modifier.height(24.dp)) 21 | TextButton( 22 | onClick = { 23 | navController.navigate(MyDestination.ADD_EMPLOYEE_PATH) 24 | } 25 | ) { 26 | Text(text = "Add employee") 27 | } 28 | 29 | TextButton( 30 | onClick = { 31 | navController.navigate(MyDestination.EMPLOYEE_LIST_PATH) 32 | } 33 | ) { 34 | Text(text = "Employee list") 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.core.view.WindowCompat 7 | import com.patrykkosieradzki.androidmviexample.R 8 | import com.patrykkosieradzki.androidmviexample.domain.DemoDataGenerator 9 | import kotlinx.coroutines.DelicateCoroutinesApi 10 | import kotlinx.coroutines.GlobalScope 11 | import kotlinx.coroutines.launch 12 | import org.koin.android.ext.android.inject 13 | 14 | class MainActivity : AppCompatActivity() { 15 | private val demoDataGenerator: DemoDataGenerator by inject() 16 | 17 | @DelicateCoroutinesApi 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | 21 | GlobalScope.launch { 22 | demoDataGenerator.loadDemoDataIntoDB() 23 | } 24 | 25 | setContent { 26 | App() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/NavGraph.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui 2 | 3 | import androidx.compose.material.ScaffoldState 4 | import androidx.compose.material.rememberScaffoldState 5 | import androidx.navigation.compose.rememberNavController 6 | import androidx.compose.runtime.Composable 7 | import androidx.navigation.NavHostController 8 | import androidx.navigation.compose.NavHost 9 | import androidx.navigation.compose.composable 10 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeScreen 11 | import com.patrykkosieradzki.androidmviexample.ui.features.employees.EmployeeListScreen 12 | 13 | object MyDestination { 14 | const val ADD_EMPLOYEE_PATH = "add-employee" 15 | const val EMPLOYEE_LIST_PATH = "employee-list" 16 | } 17 | 18 | @Composable 19 | fun NavGraph( 20 | navController: NavHostController = rememberNavController(), 21 | scaffoldState: ScaffoldState = rememberScaffoldState(), 22 | startDestination: String = MyDestination.ADD_EMPLOYEE_PATH 23 | ) { 24 | NavHost( 25 | navController = navController, 26 | startDestination = startDestination 27 | ) { 28 | composable(MyDestination.ADD_EMPLOYEE_PATH) { 29 | AddEmployeeScreen(scaffoldState = scaffoldState) 30 | } 31 | composable(MyDestination.EMPLOYEE_LIST_PATH) { 32 | EmployeeListScreen(scaffoldState = scaffoldState) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/composables/BaseScreen.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.composables 2 | 3 | import androidx.compose.material.Scaffold 4 | import androidx.compose.material.ScaffoldState 5 | import androidx.compose.material.Text 6 | import androidx.compose.material.rememberScaffoldState 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.ui.platform.LocalLifecycleOwner 10 | import com.patrykkosieradzki.androidmviexample.ui.composables.snackbar.HandleSnackbarIfSupported 11 | import com.patrykkosieradzki.androidmviexample.utils.BaseViewModel 12 | import com.patrykkosieradzki.androidmviexample.utils.UiEvent 13 | import com.patrykkosieradzki.androidmviexample.utils.UiState 14 | 15 | @Composable 16 | fun > BaseComposeScreen( 17 | scaffoldState: ScaffoldState = rememberScaffoldState(), 18 | viewModel: VM, 19 | renderOnLoading: @Composable (eventHandler: (EVENT) -> Unit) -> Unit = { 20 | Scaffold { 21 | CenteredCircularProgressIndicator() 22 | } 23 | }, 24 | renderOnFailure: @Composable (eventHandler: (EVENT) -> Unit) -> Unit = { 25 | Scaffold { 26 | Text("Error occurred") 27 | } 28 | }, 29 | renderOnSuccess: @Composable (state: UiState, eventHandler: (EVENT) -> Unit) -> Unit 30 | ) { 31 | val lifecycleOwner = LocalLifecycleOwner.current 32 | 33 | HandleSnackbarIfSupported(lifecycleOwner, viewModel, scaffoldState) 34 | 35 | val state by lifecycleAwareState( 36 | lifecycleOwner = lifecycleOwner, 37 | stateFlow = viewModel.uiState, 38 | initialState = viewModel.initialState 39 | ) 40 | 41 | when (state) { 42 | is UiState.Loading -> renderOnLoading.invoke(viewModel.eventHandler) 43 | is UiState.Success -> { 44 | renderOnSuccess.invoke(state, viewModel.eventHandler) 45 | } 46 | is UiState.Failure -> renderOnFailure.invoke(viewModel.eventHandler) 47 | else -> renderOnLoading.invoke(viewModel.eventHandler) 48 | } 49 | } 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/composables/CenteredCircularProgressIndicator.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.composables 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.CircularProgressIndicator 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | 10 | @Composable 11 | fun CenteredCircularProgressIndicator() { 12 | Row( 13 | modifier = Modifier 14 | .fillMaxSize() 15 | .padding(50.dp), 16 | horizontalArrangement = Arrangement.Center 17 | ) { 18 | CircularProgressIndicator( 19 | color = MaterialTheme.colors.primary 20 | ) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/composables/LifecycleUtils.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.composables 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.remember 7 | import androidx.lifecycle.Lifecycle 8 | import androidx.lifecycle.LifecycleOwner 9 | import androidx.lifecycle.flowWithLifecycle 10 | import kotlinx.coroutines.flow.StateFlow 11 | 12 | @Composable 13 | fun lifecycleAwareState( 14 | lifecycleOwner: LifecycleOwner, 15 | stateFlow: StateFlow, 16 | initialState: T 17 | ): State { 18 | val lifecycleAwareStateFlow = remember(stateFlow, lifecycleOwner) { 19 | stateFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED) 20 | } 21 | return lifecycleAwareStateFlow.collectAsState(initialState) 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/composables/snackbar/HandleSnackbarIfSupported.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.composables.snackbar 2 | 3 | import androidx.compose.material.ScaffoldState 4 | import androidx.compose.material.SnackbarResult 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.LaunchedEffect 7 | import androidx.compose.runtime.getValue 8 | import androidx.lifecycle.LifecycleOwner 9 | import androidx.lifecycle.ViewModel 10 | import com.patrykkosieradzki.androidmviexample.ui.composables.lifecycleAwareState 11 | import com.patrykkosieradzki.androidmviexample.utils.delegates.CanDisplaySnackbar 12 | 13 | @Composable 14 | fun HandleSnackbarIfSupported( 15 | lifecycleOwner: LifecycleOwner, 16 | viewModel: ViewModel, 17 | scaffoldState: ScaffoldState 18 | ) { 19 | if (viewModel is CanDisplaySnackbar) { 20 | val snackbarState by lifecycleAwareState( 21 | lifecycleOwner = lifecycleOwner, 22 | stateFlow = viewModel.snackbarState, 23 | initialState = viewModel.initialSnackbarState 24 | ) 25 | 26 | if (snackbarState.isShown) { 27 | LaunchedEffect(scaffoldState.snackbarHostState) { 28 | when (scaffoldState.snackbarHostState.showSnackbar(snackbarState.message)) { 29 | SnackbarResult.Dismissed -> viewModel.dismissSnackbar() 30 | SnackbarResult.ActionPerformed -> { 31 | } 32 | else -> { 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/add/AddEmployeeContract.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.add 2 | 3 | import com.patrykkosieradzki.androidmviexample.domain.model.Address 4 | import com.patrykkosieradzki.androidmviexample.domain.model.Gender 5 | import com.patrykkosieradzki.androidmviexample.utils.UiEffect 6 | import com.patrykkosieradzki.androidmviexample.utils.UiEvent 7 | 8 | class AddEmployeeContract { 9 | sealed class Event : UiEvent { 10 | object AddAddressEvent : Event() 11 | class RemoveAddressEvent(val address: Address) : Event() 12 | class UpdateFormEvent( 13 | val firstName: String? = null, 14 | val lastName: String? = null, 15 | val gender: String? = null, 16 | val address: String? = null, 17 | ) : Event() 18 | 19 | object SaveEmployeeEvent : Event() 20 | } 21 | 22 | data class State( 23 | val firstName: String = "", 24 | val lastName: String = "", 25 | val age: Int = 0, 26 | val gender: String = "", 27 | val address: String = "", 28 | val addresses: List
= emptyList(), 29 | val genders: List = emptyList(), 30 | ) 31 | 32 | sealed class Effect : UiEffect 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/add/AddEmployeeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.add 2 | 3 | import androidx.compose.material.Scaffold 4 | import androidx.compose.material.ScaffoldState 5 | import androidx.compose.material.rememberScaffoldState 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import com.patrykkosieradzki.androidmviexample.ui.features.add.components.AddEmployeeScreenBody 9 | import com.patrykkosieradzki.androidmviexample.ui.composables.BaseComposeScreen 10 | import com.patrykkosieradzki.androidmviexample.utils.successData 11 | import org.koin.androidx.compose.getViewModel 12 | 13 | @Preview 14 | @Composable 15 | fun AddEmployeeScreen( 16 | scaffoldState: ScaffoldState = rememberScaffoldState(), 17 | viewModel: AddEmployeeViewModel = getViewModel() 18 | ) { 19 | BaseComposeScreen( 20 | scaffoldState = scaffoldState, 21 | viewModel = viewModel 22 | ) { state, eventHandler -> 23 | Scaffold { 24 | AddEmployeeScreenBody( 25 | state = state.successData, 26 | eventHandler = eventHandler 27 | ) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/add/AddEmployeeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.add 2 | 3 | import com.patrykkosieradzki.androidmviexample.domain.model.Address 4 | import com.patrykkosieradzki.androidmviexample.domain.model.Employee 5 | import com.patrykkosieradzki.androidmviexample.domain.usecases.GetGendersUseCase 6 | import com.patrykkosieradzki.androidmviexample.domain.usecases.SaveEmployeeUseCase 7 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeContract.Event.* 8 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeContract.State 9 | import com.patrykkosieradzki.androidmviexample.utils.BaseViewModel 10 | import com.patrykkosieradzki.androidmviexample.utils.UiState 11 | import com.patrykkosieradzki.androidmviexample.utils.delegates.CanDisplaySnackbar 12 | import com.patrykkosieradzki.androidmviexample.utils.delegates.CanDisplaySnackbarImpl 13 | import com.patrykkosieradzki.androidmviexample.utils.successData 14 | 15 | class AddEmployeeViewModel( 16 | private val getGendersUseCase: GetGendersUseCase, 17 | private val saveEmployeeUseCase: SaveEmployeeUseCase, 18 | private val canDisplaySnackbar: CanDisplaySnackbar = CanDisplaySnackbarImpl() 19 | ) : 20 | BaseViewModel( 21 | initialState = UiState.Loading 22 | ), CanDisplaySnackbar by canDisplaySnackbar { 23 | 24 | init { 25 | loadGenders() 26 | } 27 | 28 | override fun handleEvent(event: AddEmployeeContract.Event) { 29 | when (event) { 30 | is UpdateFormEvent -> handleUpdateFormEvent(event) 31 | is AddAddressEvent -> handleAddAddressEvent() 32 | is RemoveAddressEvent -> handleRemoveAddressEvent(event) 33 | is SaveEmployeeEvent -> handleSaveEmployeeEvent() 34 | } 35 | } 36 | 37 | private fun handleUpdateFormEvent(event: UpdateFormEvent) { 38 | updateUiSuccessState { 39 | it.copy( 40 | firstName = event.firstName ?: it.firstName, 41 | lastName = event.lastName ?: it.lastName, 42 | gender = event.gender ?: it.gender, 43 | address = event.address ?: it.address 44 | ) 45 | } 46 | } 47 | 48 | private fun handleAddAddressEvent() { 49 | updateUiSuccessState { 50 | it.copy( 51 | address = "", 52 | addresses = it.addresses.plus(Address(it.address)) 53 | ) 54 | } 55 | } 56 | 57 | private fun handleRemoveAddressEvent(event: RemoveAddressEvent) { 58 | val addresses = currentState.successData.addresses.toMutableList() 59 | addresses.remove(event.address) 60 | updateUiSuccessState { 61 | it.copy( 62 | addresses = addresses 63 | ) 64 | } 65 | } 66 | 67 | private fun handleSaveEmployeeEvent() { 68 | currentState.successData.run { 69 | val isValid = 70 | firstName.isNotEmpty() && lastName.isNotEmpty() && gender.isNotEmpty() && addresses.isNotEmpty() 71 | if (isValid) { 72 | safeLaunch { 73 | saveEmployeeUseCase( 74 | Employee( 75 | firstName = firstName, 76 | lastName = lastName, 77 | age = age, 78 | gender = gender, 79 | addresses = addresses 80 | ) 81 | ) 82 | showSnackbar("New employee saved! :)") 83 | clearForm() 84 | } 85 | } else { 86 | showSnackbar("Form is not valid") 87 | } 88 | } 89 | } 90 | 91 | private fun loadGenders() { 92 | safeLaunch { 93 | val genders = getGendersUseCase() 94 | updateUiState { UiState.Success(State(genders = genders)) } 95 | } 96 | } 97 | 98 | private fun clearForm() { 99 | updateUiSuccessState { 100 | it.copy( 101 | firstName = "", 102 | lastName = "", 103 | gender = "", 104 | address = "", 105 | addresses = emptyList() 106 | ) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/add/components/AddEmployeeScreenBody.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.add.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.* 5 | import androidx.compose.runtime.* 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.compose.ui.unit.dp 10 | import com.patrykkosieradzki.androidmviexample.R 11 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeContract 12 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeContract.Event.UpdateFormEvent 13 | 14 | @Composable 15 | fun AddEmployeeScreenBody( 16 | state: AddEmployeeContract.State, 17 | eventHandler: (AddEmployeeContract.Event) -> Unit 18 | ) { 19 | Box(modifier = Modifier.fillMaxSize()) { 20 | Column( 21 | modifier = Modifier 22 | .fillMaxSize() 23 | .padding(horizontal = 20.dp), 24 | horizontalAlignment = Alignment.CenterHorizontally, 25 | ) { 26 | AddEmployeeForm( 27 | state = state, 28 | eventHandler = eventHandler 29 | ) 30 | Button( 31 | onClick = { 32 | eventHandler.invoke(AddEmployeeContract.Event.SaveEmployeeEvent) 33 | }, 34 | modifier = Modifier.padding(top = 20.dp) 35 | ) { 36 | Text(text = stringResource(id = R.string.save_employee)) 37 | } 38 | } 39 | } 40 | } 41 | 42 | @Composable 43 | fun AddEmployeeForm( 44 | state: AddEmployeeContract.State, 45 | eventHandler: (AddEmployeeContract.Event) -> Unit 46 | ) { 47 | Column { 48 | OutlinedTextField( 49 | label = stringResource(id = R.string.first_name), 50 | value = state.firstName, 51 | onChange = { 52 | eventHandler.invoke(UpdateFormEvent(firstName = it)) 53 | } 54 | ) 55 | OutlinedTextField( 56 | label = stringResource(id = R.string.last_name), 57 | value = state.lastName, 58 | onChange = { 59 | eventHandler.invoke(UpdateFormEvent(lastName = it)) 60 | } 61 | ) 62 | EmployeeGenderRadioButtons( 63 | currentGender = state.gender, 64 | genders = state.genders, 65 | eventHandler = eventHandler 66 | ) 67 | Row( 68 | horizontalArrangement = Arrangement.SpaceBetween, 69 | verticalAlignment = Alignment.CenterVertically, 70 | ) { 71 | Box( 72 | Modifier.weight(2f) 73 | ) { 74 | OutlinedTextField( 75 | label = stringResource(id = R.string.address), 76 | value = state.address, 77 | onChange = { 78 | eventHandler.invoke(UpdateFormEvent(address = it)) 79 | } 80 | ) 81 | } 82 | Spacer(Modifier.size(10.dp)) 83 | Box( 84 | Modifier.weight(1f) 85 | ) { 86 | Button( 87 | onClick = { 88 | eventHandler.invoke(AddEmployeeContract.Event.AddAddressEvent) 89 | }, 90 | ) { 91 | Text(text = stringResource(id = R.string.add_address)) 92 | } 93 | } 94 | } 95 | EmployeeAddressList( 96 | addresses = state.addresses, 97 | eventHandler = eventHandler 98 | ) 99 | } 100 | } 101 | 102 | @Composable 103 | fun OutlinedTextField( 104 | label: String = "", 105 | value: String = "", 106 | onChange: (String) -> Unit 107 | ) { 108 | OutlinedTextField( 109 | modifier = Modifier.fillMaxWidth(), 110 | value = value, 111 | onValueChange = onChange, 112 | label = { Text(text = label) } 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/add/components/EmployeeAddressList.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.add.components 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.lazy.LazyColumn 5 | import androidx.compose.foundation.lazy.itemsIndexed 6 | import androidx.compose.foundation.shape.CornerSize 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.material.Button 9 | import androidx.compose.material.Card 10 | import androidx.compose.material.Divider 11 | import androidx.compose.material.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.unit.dp 18 | import com.patrykkosieradzki.androidmviexample.R 19 | import com.patrykkosieradzki.androidmviexample.domain.model.Address 20 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeContract 21 | 22 | @Composable 23 | fun EmployeeAddressList( 24 | addresses: List
, 25 | eventHandler: (AddEmployeeContract.Event) -> Unit 26 | ) { 27 | LazyColumn( 28 | contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), 29 | modifier = Modifier.heightIn(max = 200.dp) 30 | ) { 31 | itemsIndexed(addresses) { index, item -> 32 | EmployeeAddressListItem( 33 | name = item.name, 34 | onRemoveClicked = { 35 | eventHandler.invoke(AddEmployeeContract.Event.RemoveAddressEvent(item)) 36 | } 37 | ) 38 | if (index < addresses.size - 1) 39 | Divider(color = Color.Transparent, thickness = 10.dp) 40 | } 41 | } 42 | } 43 | 44 | @Composable 45 | fun EmployeeAddressListItem( 46 | name: String, 47 | onRemoveClicked: () -> Unit 48 | ) { 49 | Card( 50 | elevation = 2.dp, 51 | backgroundColor = Color.White, 52 | shape = RoundedCornerShape(corner = CornerSize(16.dp)) 53 | ) { 54 | Row( 55 | horizontalArrangement = Arrangement.SpaceBetween, 56 | modifier = Modifier 57 | .padding(horizontal = 8.dp, vertical = 8.dp) 58 | .fillMaxWidth(), 59 | verticalAlignment = Alignment.CenterVertically, 60 | ) { 61 | Text(text = name) 62 | Button( 63 | onClick = onRemoveClicked, 64 | modifier = Modifier.padding(top = 20.dp) 65 | ) { 66 | Text(text = stringResource(id = R.string.remove)) 67 | } 68 | } 69 | } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/add/components/EmployeeGenderRadioButtons.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.add.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.* 6 | import androidx.compose.runtime.* 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.compose.ui.unit.dp 11 | import com.patrykkosieradzki.androidmviexample.R 12 | import com.patrykkosieradzki.androidmviexample.domain.model.Gender 13 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeContract 14 | import com.patrykkosieradzki.androidmviexample.ui.features.add.AddEmployeeContract.Event.UpdateFormEvent 15 | 16 | @Preview 17 | @Composable 18 | fun EmployeeGenderRadioButtons( 19 | currentGender: String = "", 20 | genders: List = emptyList(), 21 | eventHandler: (AddEmployeeContract.Event) -> Unit = {} 22 | ) { 23 | fun onClick(genderName: String) { 24 | eventHandler.invoke(UpdateFormEvent(gender = genderName)) 25 | } 26 | 27 | Column( 28 | modifier = Modifier.padding(top = 15.dp) 29 | ) { 30 | Text(text = stringResource(id = R.string.select_gender)) 31 | Row( 32 | modifier = Modifier 33 | .padding(15.dp) 34 | .fillMaxWidth(), 35 | horizontalArrangement = Arrangement.SpaceAround, 36 | ) { 37 | genders.forEach { gender -> 38 | Row(modifier = Modifier.clickable { onClick(gender.name) }) { 39 | RadioButton( 40 | selected = gender.name == currentGender, 41 | onClick = { onClick(gender.name) } 42 | ) 43 | Spacer(modifier = Modifier.width(5.dp)) 44 | Text( 45 | text = gender.name 46 | ) 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/details/EmployeeDetailsContract.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.details 2 | 3 | import com.patrykkosieradzki.androidmviexample.utils.UiEffect 4 | import com.patrykkosieradzki.androidmviexample.utils.UiEvent 5 | 6 | interface EmployeeDetailsContract { 7 | sealed class Event : UiEvent 8 | 9 | data class State( 10 | val name: String = "" 11 | ) 12 | 13 | sealed class Effect : UiEffect 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/details/EmployeeDetailsFragment.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.details 2 | 3 | //class EmployeeDetailsFragment : 4 | // BaseFragment( 6 | // R.layout.employee_details_fragment, 7 | // EmployeeDetailsViewModel::class 8 | // ) { 9 | // 10 | // override fun handleState(it: EmployeeDetailsContract.State) { 11 | // TODO("Not yet implemented") 12 | // } 13 | //} 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/details/EmployeeDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.details 2 | 3 | import com.patrykkosieradzki.androidmviexample.utils.BaseViewModel 4 | import com.patrykkosieradzki.androidmviexample.utils.UiState 5 | 6 | class EmployeeDetailsViewModel : 7 | BaseViewModel( 8 | initialState = UiState.Loading 9 | ) { 10 | override fun handleEvent(event: EmployeeDetailsContract.Event) { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/employees/EmployeeListContract.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.employees 2 | 3 | import com.patrykkosieradzki.androidmviexample.utils.UiEffect 4 | import com.patrykkosieradzki.androidmviexample.utils.UiEvent 5 | 6 | interface EmployeeListContract { 7 | 8 | sealed class Event : UiEvent 9 | 10 | data class State( 11 | val name: String = "" 12 | ) 13 | 14 | sealed class Effect : UiEffect 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/employees/EmployeeListFragment.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.employees 2 | 3 | //@InternalCoroutinesApi 4 | //class EmployeeListFragment : 5 | // BaseFragment( 7 | // R.layout.employee_list_fragment, 8 | // EmployeeListViewModel::class 9 | // ) { 10 | // 11 | // private lateinit var adapter: EmployeeListAdapter 12 | // private lateinit var layoutManager: LinearLayoutManager 13 | // 14 | // override fun setupViews(view: View) { 15 | // super.setupViews(view) 16 | // onBackEvent = { requireActivity().moveTaskToBack(true) } 17 | // adapter = EmployeeListAdapter { 18 | //// navigateTo(EmployeeListFragmentDirections.toEmployeeDetailsFragment()) 19 | // } 20 | // layoutManager = LinearLayoutManager(requireContext()) 21 | // with(binding) { 22 | // employeesRecyclerView.apply { 23 | // layoutManager = this@EmployeeListFragment.layoutManager 24 | // adapter = this@EmployeeListFragment.adapter 25 | // addItemDecoration( 26 | // DividerItemDecoration( 27 | // employeesRecyclerView.context, 28 | // this@EmployeeListFragment.layoutManager.orientation 29 | // ) 30 | // ) 31 | // } 32 | // fab.setOnClickListener { 33 | // viewModel.onAddEmployeeButtonClicked() 34 | // } 35 | // } 36 | // with(viewModel) { 37 | // adapter.loadStateFlow 38 | // .map { it.refresh } 39 | // .distinctUntilChanged() 40 | // .onEach { 41 | // when (it) { 42 | // is LoadState.Loading -> setLoadingState() 43 | // is LoadState.NotLoading -> { 44 | // if (adapter.itemCount == 0) { 45 | // setEmptyState() 46 | // } else { 47 | // setSuccessState() 48 | // } 49 | // } 50 | // is LoadState.Error -> setErrorState() 51 | // } 52 | // } 53 | // .observeInLifecycle(viewLifecycleOwner) 54 | // 55 | // employees 56 | // .onEach { adapter.submitData(it) } 57 | // .observeInLifecycle(viewLifecycleOwner) 58 | // } 59 | // } 60 | // 61 | // override fun handleState(it: EmployeeListContract.State) { 62 | // if (it is EmployeeListContract.State.Empty) { 63 | // view?.findViewById(R.id.empty_text)?.visibility = View.VISIBLE 64 | // } else { 65 | // view?.findViewById(R.id.empty_text)?.visibility = View.GONE 66 | // } 67 | // } 68 | //} 69 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/employees/EmployeeListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.employees 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.material.Scaffold 5 | import androidx.compose.material.ScaffoldState 6 | import androidx.compose.material.Text 7 | import androidx.compose.material.rememberScaffoldState 8 | import androidx.compose.runtime.Composable 9 | import androidx.paging.PagingData 10 | import androidx.paging.compose.LazyPagingItems 11 | import androidx.paging.compose.collectAsLazyPagingItems 12 | import androidx.paging.compose.items 13 | import com.patrykkosieradzki.androidmviexample.storage.model.EmployeeWithGenderAndAddresses 14 | import com.patrykkosieradzki.androidmviexample.ui.composables.BaseComposeScreen 15 | import kotlinx.coroutines.flow.Flow 16 | import org.koin.androidx.compose.getViewModel 17 | 18 | @Composable 19 | fun EmployeeListScreen( 20 | scaffoldState: ScaffoldState = rememberScaffoldState(), 21 | viewModel: EmployeeListViewModel = getViewModel() 22 | ) { 23 | BaseComposeScreen( 24 | scaffoldState = scaffoldState, 25 | viewModel = viewModel 26 | ) { state, eventHandler -> 27 | Scaffold { 28 | EmployeeListScreenBody(employees = viewModel.employees) 29 | } 30 | } 31 | } 32 | 33 | @Composable 34 | fun EmployeeListScreenBody(employees: Flow>) { 35 | val lazyEmployeeItems: LazyPagingItems = 36 | employees.collectAsLazyPagingItems() 37 | 38 | LazyColumn { 39 | items(lazyEmployeeItems) { employee -> 40 | Text(text = employee?.employee?.lastName.orEmpty()) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/features/employees/EmployeeListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.employees 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import androidx.paging.Pager 5 | import androidx.paging.PagingConfig 6 | import androidx.paging.PagingData 7 | import androidx.paging.cachedIn 8 | import com.patrykkosieradzki.androidmviexample.storage.dao.EmployeeDao 9 | import com.patrykkosieradzki.androidmviexample.storage.model.EmployeeWithGenderAndAddresses 10 | import com.patrykkosieradzki.androidmviexample.utils.BaseViewModel 11 | import com.patrykkosieradzki.androidmviexample.utils.UiState 12 | import kotlinx.coroutines.flow.Flow 13 | 14 | class EmployeeListViewModel( 15 | private val employeeDao: EmployeeDao 16 | ) : 17 | BaseViewModel( 18 | initialState = UiState.Success(EmployeeListContract.State()) 19 | ) { 20 | 21 | val employees: Flow> = Pager( 22 | PagingConfig( 23 | enablePlaceholders = true, 24 | pageSize = EMPLOYEES_PAGE_SIZE, 25 | initialLoadSize = EMPLOYEES_PAGE_SIZE, 26 | prefetchDistance = EMPLOYEES_PAGE_SIZE, 27 | ), 28 | pagingSourceFactory = { employeeDao.pagingSource() } 29 | ).flow.cachedIn(viewModelScope) 30 | 31 | override fun handleEvent(event: EmployeeListContract.Event) {} 32 | 33 | fun onAddEmployeeButtonClicked() { 34 | // navigateTo(EmployeeListFragmentDirections.toAddEmployeeFragment()) 35 | } 36 | 37 | companion object { 38 | const val EMPLOYEES_PAGE_SIZE = 10 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/theme/AppColors.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Red200 = Color(0xfff297a2) 6 | val Red300 = Color(0xffea6d7e) 7 | val Red700 = Color(0xffdd0d3c) 8 | val Red800 = Color(0xffd00036) 9 | val Red900 = Color(0xffc20029) -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/ui/theme/AppTheme.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.graphics.Color 9 | 10 | private val LightThemeColors = lightColors( 11 | primary = Red700, 12 | primaryVariant = Red900, 13 | onPrimary = Color.White, 14 | secondary = Red700, 15 | secondaryVariant = Red900, 16 | onSecondary = Color.White, 17 | error = Red800 18 | ) 19 | 20 | private val DarkThemeColors = darkColors( 21 | primary = Red300, 22 | primaryVariant = Red700, 23 | onPrimary = Color.Black, 24 | secondary = Red300, 25 | onSecondary = Color.Black, 26 | error = Red200 27 | ) 28 | 29 | @Composable 30 | fun AppTheme( 31 | darkTheme: Boolean = isSystemInDarkTheme(), 32 | content: @Composable () -> Unit 33 | ) { 34 | MaterialTheme( 35 | colors = if (darkTheme) DarkThemeColors else LightThemeColors, 36 | content = content 37 | ) 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/Async.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | sealed class Async(val complete: Boolean, val shouldLoad: Boolean, private val value: T?) { 4 | open operator fun invoke(): T? = value 5 | 6 | companion object { 7 | fun Success<*>.setMetadata(metadata: T) { 8 | this.metadata = metadata 9 | } 10 | 11 | fun Success<*>.getMetadata(): T? = this.metadata as T? 12 | } 13 | } 14 | 15 | object Uninitialized : Async(complete = false, shouldLoad = true, value = null), Incomplete 16 | 17 | data class Loading(private val value: T? = null) : 18 | Async(complete = false, shouldLoad = false, value = value), Incomplete 19 | 20 | data class Success(private val value: T) : 21 | Async(complete = true, shouldLoad = false, value = value) { 22 | override operator fun invoke(): T = value 23 | 24 | var metadata: Any? = null 25 | } 26 | 27 | data class Fail(val error: Throwable, private val value: T? = null) : 28 | Async(complete = true, shouldLoad = true, value = value) { 29 | override fun equals(other: Any?): Boolean { 30 | if (other !is Fail<*>) return false 31 | 32 | val otherError = other.error 33 | return error::class == otherError::class && 34 | error.message == otherError.message && 35 | error.stackTrace.firstOrNull() == otherError.stackTrace.firstOrNull() 36 | } 37 | 38 | override fun hashCode(): Int = 39 | arrayOf(error::class, error.message, error.stackTrace.firstOrNull()).contentHashCode() 40 | } 41 | 42 | interface Incomplete 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import kotlinx.coroutines.CoroutineExceptionHandler 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.channels.Channel 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.asStateFlow 10 | import kotlinx.coroutines.flow.receiveAsFlow 11 | import kotlinx.coroutines.flow.update 12 | import kotlinx.coroutines.launch 13 | import timber.log.Timber 14 | 15 | @AllOpen 16 | abstract class BaseViewModel( 17 | val initialState: UiState = UiState.Loading, 18 | ) : ViewModel() { 19 | 20 | // Get Current State 21 | val currentState: UiState 22 | get() = uiState.value 23 | 24 | private val _uiState: MutableStateFlow> by lazy { 25 | MutableStateFlow(initialState) 26 | } 27 | val uiState = _uiState.asStateFlow() 28 | 29 | val eventHandler: (EVENT) -> Unit = ::handleEvent 30 | 31 | private val _navigationCommandEvent = Channel(Channel.BUFFERED) 32 | val navigationCommandEvent = _navigationCommandEvent.receiveAsFlow() 33 | 34 | protected val handler = CoroutineExceptionHandler { _, exception -> 35 | Timber.e(exception, COROUTINE_EXCEPTION_HANDLER_MESSAGE) 36 | } 37 | 38 | abstract fun handleEvent(event: EVENT) 39 | 40 | fun updateUiState(updateFunc: (UiState) -> UiState) { 41 | _uiState.update(updateFunc) 42 | } 43 | 44 | protected fun updateUiSuccessState(update: (STATE) -> STATE) { 45 | _uiState.update { 46 | UiState.Success(update(currentState.successData)) 47 | } 48 | } 49 | 50 | protected fun safeLaunch(block: suspend CoroutineScope.() -> Unit) { 51 | viewModelScope.launch(handler, block = block) 52 | } 53 | 54 | companion object { 55 | private const val COROUTINE_EXCEPTION_HANDLER_MESSAGE = "ExceptionHandler" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/FlowObserver.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleEventObserver 5 | import androidx.lifecycle.LifecycleOwner 6 | import androidx.lifecycle.lifecycleScope 7 | import kotlinx.coroutines.InternalCoroutinesApi 8 | import kotlinx.coroutines.Job 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.collect 11 | import kotlinx.coroutines.launch 12 | 13 | @InternalCoroutinesApi 14 | class FlowObserver( 15 | lifecycleOwner: LifecycleOwner, 16 | private val flow: Flow, 17 | private val collector: suspend (T) -> Unit 18 | ) { 19 | 20 | private var job: Job? = null 21 | 22 | init { 23 | lifecycleOwner.lifecycle.addObserver( 24 | LifecycleEventObserver { source: LifecycleOwner, event: Lifecycle.Event -> 25 | when (event) { 26 | Lifecycle.Event.ON_START -> { 27 | job = source.lifecycleScope.launch { 28 | flow.collect { collector(it) } 29 | } 30 | } 31 | Lifecycle.Event.ON_STOP -> { 32 | job?.cancel() 33 | job = null 34 | } 35 | else -> { 36 | } 37 | } 38 | } 39 | ) 40 | } 41 | } 42 | 43 | @InternalCoroutinesApi 44 | inline fun Flow.observeOnLifecycle( 45 | lifecycleOwner: LifecycleOwner, 46 | noinline collector: suspend (T) -> Unit 47 | ) = FlowObserver(lifecycleOwner, this, collector) 48 | 49 | @InternalCoroutinesApi 50 | fun Flow.observeInLifecycle( 51 | lifecycleOwner: LifecycleOwner 52 | ) = FlowObserver(lifecycleOwner, this, {}) 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/NavigationEvent.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import androidx.navigation.NavDirections 4 | 5 | sealed class NavigationCommand { 6 | data class To(val directions: NavDirections) : NavigationCommand() 7 | object Back : NavigationCommand() 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/OnItemClickListener.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | interface OnItemClickListener { 4 | fun onClick(item: T) 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/SnackbarState.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | data class SnackbarState( 4 | val isShown: Boolean = false, 5 | val message: String = "" 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/UiEffect.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | interface UiEffect 4 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/UiEvent.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | interface UiEvent 4 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/UiState.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | sealed class UiState { 4 | object Loading : UiState() 5 | object Retrying : UiState() 6 | object SwipeRefreshing : UiState() 7 | data class Success(val data: T) : UiState() 8 | data class Failure(val exception: Exception) : UiState() 9 | data class SwipeRefreshFailure(val exception: Exception) : UiState() 10 | } 11 | 12 | val UiState.successData: T 13 | get() = (this as UiState.Success).data -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/delegates/CanDisplaySnackbar.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils.delegates 2 | 3 | import com.patrykkosieradzki.androidmviexample.utils.SnackbarState 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.asStateFlow 7 | 8 | interface CanDisplaySnackbar { 9 | val initialSnackbarState: SnackbarState 10 | val _snackbarState: MutableStateFlow 11 | val snackbarState: StateFlow 12 | 13 | fun showSnackbar(message: String) 14 | 15 | fun dismissSnackbar() 16 | } 17 | 18 | class CanDisplaySnackbarImpl( 19 | override val initialSnackbarState: SnackbarState = SnackbarState() 20 | ) : CanDisplaySnackbar { 21 | 22 | override val _snackbarState: MutableStateFlow by lazy { 23 | MutableStateFlow(initialSnackbarState) 24 | } 25 | override val snackbarState: StateFlow = _snackbarState.asStateFlow() 26 | 27 | private val currentSnackbarState: SnackbarState 28 | get() = snackbarState.value 29 | 30 | override fun showSnackbar(message: String) { 31 | _snackbarState.value = currentSnackbarState.copy(isShown = true, message = message) 32 | } 33 | 34 | override fun dismissSnackbar() { 35 | _snackbarState.value = currentSnackbarState.copy(isShown = false) 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/extensions/FragmentExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils.extensions 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import androidx.fragment.app.Fragment 5 | import androidx.navigation.NavDirections 6 | import androidx.navigation.fragment.findNavController 7 | 8 | fun Fragment.navigateTo(directions: NavDirections) { 9 | findNavController().navigate(directions) 10 | } 11 | 12 | val Fragment.appCompatActivity: AppCompatActivity 13 | get() = requireActivity() as AppCompatActivity 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/extensions/LiveDataExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils.extensions 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import com.hadilq.liveevent.LiveEvent 6 | 7 | fun MutableLiveData.fireChange(t: T) { 8 | value = t 9 | } 10 | 11 | inline val MutableLiveData.readOnly: LiveData 12 | get() = this 13 | 14 | inline val LiveData.valueNN 15 | get() = this.value!! 16 | 17 | fun LiveEvent.fireEvent(event: T) { 18 | this.value = event 19 | } 20 | 21 | fun LiveEvent.fireEvent() { 22 | this.value = Unit 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/patrykkosieradzki/androidmviexample/utils/extensions/ViewExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils.extensions 2 | 3 | import android.animation.Animator 4 | import android.animation.AnimatorListenerAdapter 5 | import android.view.View 6 | import android.view.View.GONE 7 | import android.view.View.VISIBLE 8 | 9 | private const val FULL_OPACITY = 1f 10 | 11 | fun View.goneIfWithAnimation(shouldBeGone: Boolean) { 12 | val mediumAnimationDuration = resources.getInteger(android.R.integer.config_mediumAnimTime) 13 | if (shouldBeGone) { 14 | if (visibility == GONE) { 15 | return 16 | } 17 | animate() 18 | .alpha(0f) 19 | .setDuration(mediumAnimationDuration.toLong()) 20 | .setListener(object : AnimatorListenerAdapter() { 21 | override fun onAnimationEnd(animation: Animator) { 22 | visibility = GONE 23 | } 24 | }) 25 | } else { 26 | if (visibility == VISIBLE) { 27 | return 28 | } 29 | alpha = 0f 30 | visibility = VISIBLE 31 | 32 | animate() 33 | .alpha(FULL_OPACITY) 34 | .setDuration(mediumAnimationDuration.toLong()) 35 | .setListener(null) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_add.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_back.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_close.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/raw/lottie_loading_animation.json: -------------------------------------------------------------------------------- 1 | {"v":"5.5.2","fr":50,"ip":0,"op":100,"w":500,"h":500,"nm":"Biplane","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"cloud2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":18,"s":[60]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":27,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":64,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":68,"s":[60]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":82,"s":[60]},{"t":91,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[764.814,288.834,0],"to":[-31.5,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":27,"s":[575.814,288.834,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":64,"s":[764.814,288.834,0],"to":[0,0,0],"ti":[31.5,0,0]},{"t":91,"s":[575.814,288.834,0]}],"ix":2},"a":{"a":0,"k":[494.744,359.522,0],"ix":1},"s":{"a":0,"k":[30,30,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[6.742,0],[0.74,-0.141],[0,0.073],[13.485,0],[1.67,-0.361],[19.789,0],[3.317,-19.082],[2.691,0],[0,-13.485],[-0.049,-0.63],[2.404,0],[0,-6.743],[-6.742,0],[0,0],[0,6.742]],"o":[[-0.781,0],[0,-0.075],[0,-13.485],[-1.778,0],[-3.594,-18.742],[-20.031,0],[-2.421,-0.804],[-13.485,0],[0,0.642],[-1.89,-1.2],[-6.742,0],[0,6.742],[0,0],[6.742,0],[0,-6.743]],"v":[[75.133,16.175],[72.85,16.396],[72.856,16.175],[48.439,-8.242],[43.261,-7.686],[3.406,-40.592],[-36.572,-6.996],[-44.269,-8.242],[-68.685,16.175],[-68.604,18.079],[-75.133,16.175],[-87.341,28.383],[-75.133,40.591],[75.133,40.591],[87.341,28.383]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.816000007181,0.823999980852,0.827000038297,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[494.744,359.522],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":100,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Merged Shape Layer","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[755.168,247.552,0],"ix":2},"a":{"a":0,"k":[755.168,247.552,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":3,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":6,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":9,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":12,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":15,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":18,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":21,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":24,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":27,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":33,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":36,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":39,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":45,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":48,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":51,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":54,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":57,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":60,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":63,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":66,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":69,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":72,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":75,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":78,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":81,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":84,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":87,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":90,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":93,"s":[100,12,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":96,"s":[100,100,100]},{"t":99,"s":[100,12,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,39.968],[15.927,0],[0,-39.967],[-15.926,0]],"o":[[0,-39.967],[-15.926,0],[0,39.968],[15.927,0]],"v":[[28.838,0],[0,-72.368],[-28.838,0],[0,72.368]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.365000009537,0.407999992371,0.976000010967,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[868.936,345.265],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[755.168,260.268],"ix":2},"a":{"a":0,"k":[868.936,345.265],"ix":1},"s":{"a":0,"k":[18,18],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"toy Outlines - Group 3","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,39.967],[15.927,0],[0,-39.968],[-15.926,0]],"o":[[0,-39.968],[-15.926,0],[0,39.967],[15.927,0]],"v":[[28.838,0],[0,-72.368],[-28.838,0],[0,72.368]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.365000009537,0.407999992371,0.976000010967,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[868.936,203.976],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[755.168,234.836],"ix":2},"a":{"a":0,"k":[868.936,203.976],"ix":1},"s":{"a":0,"k":[18,18],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"toy Outlines - Group 4","np":1,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":100,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"toy Outlines - Group 6","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[688.335,252.643,0],"ix":2},"a":{"a":0,"k":[497.636,302.908,0],"ix":1},"s":{"a":0,"k":[18,18,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[67.547,0],[0,172.714]],"o":[[0,172.714],[-67.547,0],[0,0]],"v":[[128.899,-156.363],[-61.352,156.363],[-115.71,-156.363]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[0,0],[67.547,0],[0,172.714]],"o":[[0,172.714],[-67.547,0],[0,0]],"v":[[128.899,-156.363],[-55.796,86.919],[-115.71,-156.363]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[{"i":[[0,0],[67.547,0],[0,172.714]],"o":[[0,172.714],[-67.547,0],[0,0]],"v":[[128.899,-156.363],[-61.352,156.363],[-115.71,-156.363]],"c":true}]},{"t":99,"s":[{"i":[[0,0],[67.547,0],[0,172.714]],"o":[[0,172.714],[-67.547,0],[0,0]],"v":[[128.899,-156.363],[-61.352,156.363],[-115.71,-156.363]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.278430983599,0.29411797617,0.847059003045,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[491.562,459.271],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":100,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"toy Outlines - Group 7","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[639.093,247.717,0],"ix":2},"a":{"a":0,"k":[224.074,275.542,0],"ix":1},"s":{"a":0,"k":[18,18,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[46.724,0],[0,81.565]],"o":[[0,81.565],[-46.723,0],[0,0]],"v":[[89.162,-73.844],[-42.439,73.844],[-80.039,-73.844]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.278430983599,0.29411797617,0.847059003045,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[215.705,347.764],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":100,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Merged Shape Layer","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[684.757,242.431,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":25,"s":[684.757,217.431,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":50,"s":[684.757,242.431,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":75,"s":[684.757,262.431,0],"to":[0,0,0],"ti":[0,0,0]},{"t":99,"s":[684.757,242.431,0]}],"ix":2},"a":{"a":0,"k":[684.757,242.431,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.752,0],[0,0],[0,5.752],[-5.751,0],[0,0],[0,-5.752]],"o":[[0,0],[-5.751,0],[0,-5.752],[0,0],[5.752,0],[0,5.752]],"v":[[15.24,10.458],[-15.239,10.458],[-25.697,0],[-15.239,-10.458],[15.24,-10.458],[25.698,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.223528981209,0.192156970501,0.674510002136,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[840.441,274.621],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[750.039,247.552],"ix":2},"a":{"a":0,"k":[840.441,274.621],"ix":1},"s":{"a":0,"k":[18,18],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"toy Outlines - Group 5","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,57.528],[0,0],[58.027,8.924],[0.389,0.056],[0,0],[0,-42.229],[0,0],[-42.229,0],[0,0],[-0.457,0.104]],"o":[[0,0],[0,-60.423],[-0.387,-0.06],[0,0],[-42.229,0],[0,0],[0,42.228],[0,0],[0.459,-0.098],[53.973,-12.218]],"v":[[351.656,0.717],[351.656,0.717],[248.822,-119.097],[247.657,-119.262],[-269.669,-101.465],[-346.448,-24.686],[-346.448,-7.93],[-269.669,68.849],[255.691,119.261],[257.065,118.96]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.365000009537,0.407999992371,0.976000010967,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[436.674,274.399],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[677.361,247.512],"ix":2},"a":{"a":0,"k":[436.674,274.399],"ix":1},"s":{"a":0,"k":[18,18],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"toy Outlines - Group 8","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[66.669,0],[0,0],[0,-60.424],[53.972,-12.218],[0,0],[0,66.669]],"o":[[0,0],[58.028,8.925],[0,57.528],[0,0],[66.669,0],[0,-66.669]],"v":[[-43.39,-121.216],[-77.826,-119.815],[25.008,0],[-69.582,118.243],[-43.39,121.216],[77.826,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.278430998325,0.294117987156,0.847059011459,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[758.113,275.116],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[735.22,247.641],"ix":2},"a":{"a":0,"k":[758.113,275.116],"ix":1},"s":{"a":0,"k":[18,18],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"toy Outlines - Group 9","np":1,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[682.115,247.641],"ix":2},"a":{"a":0,"k":[682.115,247.641],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Merged Shape Layer","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[46.724,0],[0,-81.565]],"o":[[0,-81.565],[-46.724,0],[0,0]],"v":[[89.163,73.843],[-42.438,-73.843],[-80.039,73.843]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.365000009537,0.407999992371,0.976000010967,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[170.139,169.853],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[628.391,240.193],"ix":2},"a":{"a":0,"k":[164.618,233.742],"ix":1},"s":{"a":0,"k":[18,18],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"toy Outlines - Group 10","np":1,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[682.039,242.431],"ix":2},"a":{"a":0,"k":[682.039,242.431],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Merged Shape Layer","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":100,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"toy Outlines - Group 11","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[638.343,236.686,0],"ix":2},"a":{"a":0,"k":[219.907,214.256,0],"ix":1},"s":{"a":0,"k":[18,18,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[46.724,0],[0,-81.566]],"o":[[0,-81.566],[-46.723,0],[0,0]],"v":[[89.162,73.843],[-42.439,-73.843],[-80.039,73.843]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[0,0],[46.724,0],[0,-81.566]],"o":[[0,-81.566],[-46.723,0],[0,0]],"v":[[89.162,73.843],[-36.8,-42.678],[-80.039,73.843]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[{"i":[[0,0],[46.724,0],[0,-81.566]],"o":[[0,-81.566],[-46.723,0],[0,0]],"v":[[89.162,73.843],[-42.439,-73.843],[-80.039,73.843]],"c":true}]},{"t":99,"s":[{"i":[[0,0],[46.724,0],[0,-81.566]],"o":[[0,-81.566],[-46.723,0],[0,0]],"v":[[89.162,73.843],[-42.439,-73.843],[-80.039,73.843]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.223528977936,0.192156967462,0.674509983437,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[215.705,137.867],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 11","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":100,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"toy Outlines - Group 12","parent":5,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[688.334,240.807,0],"ix":2},"a":{"a":0,"k":[497.636,237.153,0],"ix":1},"s":{"a":0,"k":[18,18,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[{"i":[[0,0],[67.547,0],[0,-121.938]],"o":[[0,-121.938],[-67.547,0],[0,0]],"v":[[128.899,110.395],[-61.352,-110.395],[-115.71,110.395]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[0,0],[67.547,0],[0,-121.938]],"o":[[0,-121.938],[-67.547,0],[0,0]],"v":[[128.899,110.395],[-60.519,-52.062],[-115.71,110.395]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":50,"s":[{"i":[[0,0],[67.547,0],[0,-121.938]],"o":[[0,-121.938],[-67.547,0],[0,0]],"v":[[128.899,110.395],[-61.352,-110.395],[-115.71,110.395]],"c":true}]},{"t":99,"s":[{"i":[[0,0],[67.547,0],[0,-121.938]],"o":[[0,-121.938],[-67.547,0],[0,0]],"v":[[128.899,110.395],[-61.352,-110.395],[-115.71,110.395]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.223528977936,0.192156967462,0.674509983437,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[491.562,126.758],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 12","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":100,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"cloud1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":18,"s":[100]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":71,"s":[100]},{"t":85,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[588.07,213.748,0],"to":[34.833,0,0],"ti":[-34.833,0,0]},{"t":97,"s":[797.07,213.748,0]}],"ix":2},"a":{"a":0,"k":[212.831,247.932,0],"ix":1},"s":{"a":0,"k":[30,30,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[13.485,0],[4.38,-4.171],[21.913,0],[3.575,-18.766],[1.851,0],[0,-13.485],[-0.011,-0.291],[1.599,0],[0,-6.742],[-6.742,0],[0,0],[0,13.485]],"o":[[-6.526,0],[-0.793,-21.719],[-19.805,0],[-1.734,-0.391],[-13.485,0],[0,0.293],[-1.4,-0.559],[-6.742,0],[0,6.743],[0,0],[13.485,0],[0,-13.485]],"v":[[59.669,-8.241],[42.84,-1.505],[2.287,-40.591],[-37.576,-7.637],[-42.962,-8.241],[-67.378,16.175],[-67.356,17.05],[-71.878,16.175],[-84.086,28.383],[-71.878,40.591],[59.669,40.591],[84.086,16.175]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.816000007181,0.823999980852,0.827000038297,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[212.831,247.932],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":100,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Essential","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[227.835,232.597,0],"ix":2},"a":{"a":0,"k":[666.282,242.304,0],"ix":1},"s":{"a":0,"k":[133,133,100],"ix":6}},"ao":0,"w":1366,"h":768,"ip":0,"op":100,"st":0,"bm":0}],"markers":[]} -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /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 | #EEF3F3F3 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimen.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 0dp 4 | 1dp 5 | 2dp 6 | 3dp 7 | 4dp 8 | 5dp 9 | 6dp 10 | 6dp 11 | 8dp 12 | 10dp 13 | 15dp 14 | 17dp 15 | 18dp 16 | 19dp 17 | 20dp 18 | 22dp 19 | 24dp 20 | 26dp 21 | 29dp 22 | 33dp 23 | 40dp 24 | 47dp 25 | 65dp 26 | 71dp 27 | 106dp 28 | 135dp 29 | 155dp 30 | 160dp 31 | 201dp 32 | 33 | 12sp 34 | 14sp 35 | 16sp 36 | 22sp 37 | -------------------------------------------------------------------------------- /app/src/main/res/values/ids.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | AndroidMVIExample 3 | New Employee 4 | Add 5 | First name 6 | Enter first name 7 | Last name 8 | Enter last name 9 | Gender 10 | Select gender 11 | 12 | Address 13 | Add address 14 | Remove 15 | Enter new address 16 | Save employee 17 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/test/java/com/patrykkosieradzki/androidmviexample/ui/features/add/AddEmployeeViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.ui.features.add 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import com.nhaarman.mockitokotlin2.* 5 | import com.patrykkosieradzki.androidmviexample.domain.model.Address 6 | import com.patrykkosieradzki.androidmviexample.domain.model.Employee 7 | import com.patrykkosieradzki.androidmviexample.domain.model.Gender 8 | import com.patrykkosieradzki.androidmviexample.domain.usecases.GetGendersUseCase 9 | import com.patrykkosieradzki.androidmviexample.domain.usecases.SaveEmployeeUseCase 10 | import com.patrykkosieradzki.androidmviexample.utils.BaseJunit4Test 11 | import com.patrykkosieradzki.androidmviexample.utils.UiState 12 | import com.patrykkosieradzki.androidmviexample.utils.successData 13 | import kotlinx.coroutines.runBlocking 14 | import kotlinx.coroutines.test.runBlockingTest 15 | import org.junit.Before 16 | import org.junit.Test 17 | import org.mockito.Mock 18 | 19 | class AddEmployeeViewModelTest : BaseJunit4Test() { 20 | 21 | private val getGendersUseCase: GetGendersUseCase = mock { 22 | onBlocking { invoke() } doReturn GENDERS 23 | } 24 | 25 | @Mock 26 | private lateinit var saveEmployeeUseCase: SaveEmployeeUseCase 27 | 28 | private lateinit var viewModel: AddEmployeeViewModel 29 | 30 | @Before 31 | fun setUp() = runBlocking { 32 | viewModel = AddEmployeeViewModel( 33 | getGendersUseCase = getGendersUseCase, 34 | saveEmployeeUseCase = saveEmployeeUseCase 35 | ) 36 | } 37 | 38 | @Test 39 | fun `on init should load genders`() = runBlockingTest { 40 | verify(getGendersUseCase, times(1)).invoke() 41 | assertThat(viewModel.currentState.successData.genders).isEqualTo(GENDERS) 42 | } 43 | 44 | @Test 45 | fun `on UpdateFormEvent should update form`() { 46 | val firstName = "Alex" 47 | val lastName = "Tikitaka" 48 | viewModel.handleEvent( 49 | AddEmployeeContract.Event.UpdateFormEvent( 50 | firstName = firstName, 51 | lastName = lastName 52 | ) 53 | ) 54 | 55 | assertThat(viewModel.currentState.successData.firstName).isEqualTo(firstName) 56 | assertThat(viewModel.currentState.successData.lastName).isEqualTo(lastName) 57 | } 58 | 59 | @Test 60 | fun `on AddAddressEvent should add address`() { 61 | val streetName = "SomeStreet" 62 | viewModel.handleEvent(AddEmployeeContract.Event.UpdateFormEvent(address = streetName)) 63 | viewModel.handleEvent(AddEmployeeContract.Event.AddAddressEvent) 64 | 65 | assertThat(viewModel.currentState.successData.address).isEmpty() 66 | assertThat(viewModel.currentState.successData.addresses).isEqualTo( 67 | listOf( 68 | Address(name = streetName) 69 | ) 70 | ) 71 | } 72 | 73 | @Test 74 | fun `on RemoveAddressEvent should remove address`() { 75 | val address = Address(name = "SomeStreet 1") 76 | val addressToBeRemoved = Address(name = "SomeStreet 2") 77 | viewModel.setUiState { 78 | UiState.Success( 79 | AddEmployeeContract.State( 80 | addresses = listOf( 81 | address, 82 | addressToBeRemoved 83 | ) 84 | ) 85 | ) 86 | } 87 | 88 | viewModel.handleEvent(AddEmployeeContract.Event.RemoveAddressEvent(addressToBeRemoved)) 89 | 90 | assertThat(viewModel.currentState.successData.addresses).hasSize(1) 91 | assertThat(viewModel.currentState.successData.addresses[0]).isEqualTo(address) 92 | } 93 | 94 | @Test 95 | fun `on SaveEmployeeEvent when form is valid should save employee`() = runBlockingTest { 96 | val employeeAddresses = listOf(Address(name = "SomeStreet 1")) 97 | viewModel.setUiState { 98 | UiState.Success( 99 | AddEmployeeContract.State( 100 | firstName = "Adam", 101 | lastName = "Kowalski", 102 | gender = "Male", 103 | addresses = employeeAddresses 104 | ) 105 | ) 106 | } 107 | 108 | viewModel.handleEvent(AddEmployeeContract.Event.SaveEmployeeEvent) 109 | 110 | val argumentCaptor = argumentCaptor() 111 | verify(saveEmployeeUseCase, times(1)).invoke(argumentCaptor.capture()) 112 | 113 | val employee = argumentCaptor.firstValue 114 | assertThat(employee.firstName).isEqualTo("Adam") 115 | assertThat(employee.lastName).isEqualTo("Kowalski") 116 | assertThat(employee.gender).isEqualTo("Male") 117 | assertThat(employee.addresses).isEqualTo(employeeAddresses) 118 | } 119 | 120 | @Test 121 | fun `on SaveEmployeeEvent when form is valid should show snackbar and clear form`() = runBlockingTest { 122 | val employeeAddresses = listOf(Address(name = "SomeStreet 1")) 123 | viewModel.setUiState { 124 | UiState.Success( 125 | AddEmployeeContract.State( 126 | firstName = "Adam", 127 | lastName = "Kowalski", 128 | gender = "Male", 129 | addresses = employeeAddresses 130 | ) 131 | ) 132 | } 133 | 134 | viewModel.handleEvent(AddEmployeeContract.Event.SaveEmployeeEvent) 135 | 136 | with(viewModel) { 137 | with(currentSnackbarState) { 138 | assertThat(isShown).isTrue() 139 | assertThat(message).isEqualTo("New employee saved! :)") 140 | } 141 | with(currentState.successData) { 142 | assertThat(firstName).isEmpty() 143 | assertThat(lastName).isEmpty() 144 | assertThat(gender).isEmpty() 145 | assertThat(address).isEmpty() 146 | assertThat(addresses).isEmpty() 147 | } 148 | } 149 | } 150 | 151 | @Test 152 | fun `on SaveEmployeeEvent when form is not valid should show error snackbar`() = 153 | runBlockingTest { 154 | viewModel.setUiState { 155 | UiState.Success( 156 | AddEmployeeContract.State( 157 | firstName = "Adam", 158 | lastName = "Kowalski", 159 | gender = "", 160 | addresses = emptyList() 161 | ) 162 | ) 163 | } 164 | 165 | viewModel.handleEvent(AddEmployeeContract.Event.SaveEmployeeEvent) 166 | 167 | assertThat(viewModel.currentSnackbarState.isShown).isTrue() 168 | assertThat(viewModel.currentSnackbarState.message).isEqualTo("Form is not valid") 169 | } 170 | 171 | companion object { 172 | val GENDERS = listOf( 173 | Gender(id = 1, name = "Male"), 174 | Gender(id = 2, name = "Female") 175 | ) 176 | } 177 | } -------------------------------------------------------------------------------- /app/src/test/java/com/patrykkosieradzki/androidmviexample/utils/BaseJunit4Test.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineDispatcher 6 | import org.junit.After 7 | import org.junit.Before 8 | import org.junit.Rule 9 | import org.mockito.MockitoAnnotations 10 | 11 | @ExperimentalCoroutinesApi 12 | abstract class BaseJunit4Test { 13 | protected val testCoroutineDispatcher = TestCoroutineDispatcher() 14 | 15 | @get:Rule 16 | val instantTaskExecutorRule = InstantTaskExecutorRule() 17 | 18 | 19 | @get:Rule 20 | val mainCoroutineRule = MainCoroutineRule() 21 | 22 | @Before 23 | fun baseSetup() { 24 | MockitoAnnotations.initMocks(this) 25 | MVIExampleDispatchers.IO = testCoroutineDispatcher 26 | } 27 | 28 | @After 29 | fun baseTearDown() { 30 | MVIExampleDispatchers.resetAll() 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/test/java/com/patrykkosieradzki/androidmviexample/utils/MainCoroutineRule.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.test.TestCoroutineScope 7 | import kotlinx.coroutines.test.resetMain 8 | import kotlinx.coroutines.test.setMain 9 | import org.junit.rules.TestWatcher 10 | import org.junit.runner.Description 11 | import kotlin.coroutines.ContinuationInterceptor 12 | 13 | /** 14 | * Sets the main coroutines dispatcher to a [TestCoroutineScope] for unit testing. A 15 | * [TestCoroutineScope] provides control over the execution of coroutines. 16 | * 17 | * Declare it as a JUnit Rule: 18 | * 19 | * ``` 20 | * @get:Rule 21 | * var mainCoroutineRule = MainCoroutineRule() 22 | * ``` 23 | * 24 | * Use it directly as a [TestCoroutineScope]: 25 | * 26 | * ``` 27 | * mainCoroutineRule.pauseDispatcher() 28 | * ... 29 | * mainCoroutineRule.resumeDispatcher() 30 | * ... 31 | * mainCoroutineRule.runBlockingTest { } 32 | * ... 33 | * 34 | * ``` 35 | */ 36 | @ExperimentalCoroutinesApi 37 | class MainCoroutineRule : TestWatcher(), TestCoroutineScope by TestCoroutineScope() { 38 | 39 | override fun starting(description: Description?) { 40 | super.starting(description) 41 | Dispatchers.setMain(this.coroutineContext[ContinuationInterceptor] as CoroutineDispatcher) 42 | } 43 | 44 | override fun finished(description: Description?) { 45 | super.finished(description) 46 | Dispatchers.resetMain() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | apply from: 'versions.gradle' 4 | ext { 5 | versions.kotlin = '1.5.10' 6 | } 7 | ext.kotlin_version = "1.5.10" 8 | repositories { 9 | google() 10 | mavenCentral() 11 | } 12 | dependencies { 13 | classpath "com.android.tools.build:gradle:${versions.android_gradle_plugin}" 14 | classpath deps.kotlin.plugin 15 | 16 | classpath 'com.google.gms:google-services:4.3.8' 17 | classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.0' 18 | classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5" 19 | } 20 | } 21 | 22 | allprojects { 23 | ext { 24 | isIdeBuild = project.properties['android.injected.invoked.from.ide'] == 'true' 25 | } 26 | repositories { 27 | google() 28 | mavenCentral() 29 | } 30 | } 31 | 32 | task clean(type: Delete) { 33 | delete rootProject.buildDir 34 | } -------------------------------------------------------------------------------- /domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /domain/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'kotlin' 2 | 3 | dependencies { 4 | implementation fileTree(dir: 'libs', include: ['*.jar']) 5 | implementation deps.kotlin.stdlib 6 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3' 7 | 8 | implementation group: 'org.slf4j', name: 'slf4j-android', version: '1.7.21' 9 | implementation 'io.github.microutils:kotlin-logging:1.12.0' 10 | } 11 | 12 | sourceCompatibility = "8" 13 | targetCompatibility = "8" 14 | 15 | buildscript { 16 | repositories { 17 | mavenCentral() 18 | } 19 | dependencies { 20 | classpath deps.kotlin.plugin 21 | } 22 | } 23 | repositories { 24 | mavenCentral() 25 | } 26 | compileKotlin { 27 | kotlinOptions { 28 | jvmTarget = "1.8" 29 | } 30 | } 31 | compileTestKotlin { 32 | kotlinOptions { 33 | jvmTarget = "1.8" 34 | } 35 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/AppConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain 2 | 3 | interface AppConfiguration { 4 | val debug: Boolean 5 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/DemoDataGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain 2 | 3 | interface DemoDataGenerator { 4 | suspend fun loadDemoDataIntoDB() 5 | } 6 | -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/model/Address.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.model 2 | 3 | data class Address( 4 | val name: String 5 | ) 6 | -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/model/Employee.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.model 2 | 3 | data class Employee( 4 | val employeeId: Long? = null, 5 | val firstName: String, 6 | val lastName: String, 7 | val age: Int, 8 | val gender: String, 9 | val addresses: List
10 | ) -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/model/Gender.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.model 2 | 3 | data class Gender( 4 | val id: Long, 5 | val name: String 6 | ) { 7 | override fun toString() = name 8 | } 9 | -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/repositories/EmployeeRepository.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.repositories 2 | 3 | import com.patrykkosieradzki.androidmviexample.domain.model.Employee 4 | import com.patrykkosieradzki.androidmviexample.domain.model.Gender 5 | 6 | interface EmployeeRepository { 7 | suspend fun getGenders(): List 8 | suspend fun saveEmployee(employee: Employee) 9 | } 10 | -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/usecases/DeleteEmployeeUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.usecases 2 | 3 | -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/usecases/GetEmployeesUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.usecases 2 | 3 | -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/usecases/GetGendersUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.usecases 2 | 3 | import com.patrykkosieradzki.androidmviexample.domain.model.Gender 4 | import com.patrykkosieradzki.androidmviexample.domain.repositories.EmployeeRepository 5 | 6 | interface GetGendersUseCase { 7 | suspend operator fun invoke(): List 8 | } 9 | 10 | class GetGendersUseCaseImpl( 11 | private val employeeRepository: EmployeeRepository 12 | ) : GetGendersUseCase { 13 | override suspend fun invoke(): List { 14 | return employeeRepository.getGenders() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/usecases/GetSingleEmployeeUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.usecases 2 | 3 | -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/usecases/SaveEmployeeUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.usecases 2 | 3 | import com.patrykkosieradzki.androidmviexample.domain.model.Employee 4 | import com.patrykkosieradzki.androidmviexample.domain.repositories.EmployeeRepository 5 | 6 | interface SaveEmployeeUseCase { 7 | suspend operator fun invoke(employee: Employee) 8 | } 9 | 10 | class SaveEmployeeUseCaseImpl( 11 | private val employeeRepository: EmployeeRepository 12 | ) : SaveEmployeeUseCase { 13 | override suspend fun invoke(employee: Employee) { 14 | employeeRepository.saveEmployee(employee) 15 | } 16 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/patrykkosieradzki/androidmviexample/domain/usecases/UpdateEmployeeUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.domain.usecases 2 | 3 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Jun 05 20:46:20 CEST 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':storage' 2 | include ':utils' 3 | include ':domain' 4 | include ':app' 5 | rootProject.name = "AndroidMVIExample" -------------------------------------------------------------------------------- /storage/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /storage/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | 5 | android { 6 | compileSdkVersion 30 7 | buildToolsVersion "30.0.3" 8 | 9 | defaultConfig { 10 | minSdkVersion 24 11 | targetSdkVersion 30 12 | 13 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 14 | consumerProguardFiles "consumer-rules.pro" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | compileOptions { 24 | sourceCompatibility JavaVersion.VERSION_1_8 25 | targetCompatibility JavaVersion.VERSION_1_8 26 | } 27 | kotlinOptions { 28 | jvmTarget = '1.8' 29 | } 30 | } 31 | 32 | dependencies { 33 | implementation(project(":domain")) 34 | 35 | implementation "org.jetbrains.kotlin:kotlin-stdlib:${versions.kotlin}" 36 | implementation 'androidx.core:core-ktx:1.3.2' 37 | implementation 'androidx.appcompat:appcompat:1.2.0' 38 | implementation 'com.google.android.material:material:1.3.0' 39 | testImplementation 'junit:junit:4.+' 40 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 41 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 42 | 43 | def koin_version= "3.1.0" 44 | implementation "io.insert-koin:koin-core:$koin_version" 45 | 46 | def room_version = "2.3.0" 47 | implementation "androidx.room:room-runtime:$room_version" 48 | implementation "androidx.room:room-ktx:$room_version" 49 | kapt "androidx.room:room-compiler:$room_version" 50 | 51 | def paging_version = "3.1.0-alpha01" 52 | implementation "androidx.paging:paging-runtime-ktx:$paging_version" 53 | } -------------------------------------------------------------------------------- /storage/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k0siara/AndroidMVIExample/5039dc84d560bd7ccdd43895108d3cde15df699f/storage/consumer-rules.pro -------------------------------------------------------------------------------- /storage/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 -------------------------------------------------------------------------------- /storage/src/androidTest/java/com/patrykkosieradzki/androidmviexample/storage/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.patrykkosieradzki.androidmviexample.storage.test", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /storage/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/DatabaseDemoDataGenerator.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage 2 | 3 | import com.patrykkosieradzki.androidmviexample.domain.DemoDataGenerator 4 | import com.patrykkosieradzki.androidmviexample.storage.dao.EmployeeDao 5 | import com.patrykkosieradzki.androidmviexample.storage.entity.AddressEntity 6 | import com.patrykkosieradzki.androidmviexample.storage.entity.EmployeeEntity 7 | import com.patrykkosieradzki.androidmviexample.storage.entity.GenderEntity 8 | 9 | class DatabaseDemoDataGenerator( 10 | private val employeeDao: EmployeeDao 11 | ) : DemoDataGenerator { 12 | 13 | override suspend fun loadDemoDataIntoDB() { 14 | employeeDao.insertAllGenders(GENDERS) 15 | employeeDao.insertAllEmployees(EMPLOYEES) 16 | employeeDao.insertAllAddresses(ADDRESSES) 17 | } 18 | 19 | companion object { 20 | val ADDRESSES = listOf( 21 | AddressEntity(1, "Some street 1", 1), 22 | AddressEntity(2, "Some street 2", 1), 23 | AddressEntity(3, "Some street 3", 2), 24 | AddressEntity(4, "Some street 4", 3), 25 | ) 26 | 27 | val GENDERS = listOf( 28 | GenderEntity(1, "Man"), 29 | GenderEntity(2, "Woman"), 30 | ) 31 | 32 | val EMPLOYEES = listOf( 33 | EmployeeEntity(1, "John", "Snow", 30, 1), 34 | EmployeeEntity(2, "Ann", "Brie", 25, 2), 35 | EmployeeEntity(3, "Andrew", "Toms", 19, 1), 36 | EmployeeEntity(4, "Kate", "Grand", 28, 2), 37 | EmployeeEntity(5, "Pat", "Thing", 24, 2), 38 | EmployeeEntity(6, "Mat", "Keyboard", 23, 1), 39 | EmployeeEntity(7, "Tom", "Hanks", 28, 1), 40 | EmployeeEntity(8, "Brad", "Pit", 29, 1), 41 | EmployeeEntity(9, "John", "Mallek", 35, 1), 42 | EmployeeEntity(10, "Ann", "Picker", 45, 2), 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/dao/EmployeeDao.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage.dao 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.room.* 5 | import com.patrykkosieradzki.androidmviexample.domain.model.Employee 6 | import com.patrykkosieradzki.androidmviexample.storage.entity.AddressEntity 7 | import com.patrykkosieradzki.androidmviexample.storage.entity.EmployeeEntity 8 | import com.patrykkosieradzki.androidmviexample.storage.entity.GenderEntity 9 | import com.patrykkosieradzki.androidmviexample.storage.model.EmployeeWithGenderAndAddresses 10 | 11 | @Dao 12 | abstract class EmployeeDao { 13 | @Transaction 14 | @Query("SELECT * FROM employees") 15 | abstract fun pagingSource(): PagingSource 16 | 17 | suspend fun insertDomainEmployee(emp: Employee) { 18 | val gender = getGenderByName(emp.gender) 19 | val employeeId = insertEmployee( 20 | EmployeeEntity( 21 | firstName = emp.firstName, 22 | lastName = emp.lastName, 23 | age = emp.age, 24 | genderId = gender?.uid 25 | ) 26 | ) 27 | insertAllAddresses(emp.addresses.map { 28 | AddressEntity( 29 | address = it.name, 30 | employeeId = employeeId 31 | ) 32 | }) 33 | } 34 | 35 | @Insert 36 | abstract suspend fun insertEmployee(employee: EmployeeEntity): Long 37 | 38 | @Insert 39 | abstract suspend fun insertAllEmployees(employees: List) 40 | 41 | @Insert 42 | abstract suspend fun insertAllAddresses(addresses: List) 43 | 44 | @Delete 45 | abstract suspend fun delete(employeeEntity: EmployeeEntity) 46 | 47 | @Insert 48 | abstract suspend fun insertAllGenders(genders: List) 49 | 50 | @Query("SELECT * FROM genders WHERE genders.name = :genderName") 51 | abstract suspend fun getGenderByName(genderName: String): GenderEntity? 52 | 53 | @Query("SELECT * FROM genders") 54 | abstract suspend fun getAllGenders(): List 55 | } 56 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/db/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.patrykkosieradzki.androidmviexample.storage.dao.EmployeeDao 6 | import com.patrykkosieradzki.androidmviexample.storage.entity.AddressEntity 7 | import com.patrykkosieradzki.androidmviexample.storage.entity.EmployeeEntity 8 | import com.patrykkosieradzki.androidmviexample.storage.entity.GenderEntity 9 | 10 | @Database( 11 | entities = [GenderEntity::class, AddressEntity::class, EmployeeEntity::class], 12 | exportSchema = false, 13 | version = 1 14 | ) 15 | abstract class AppDatabase : RoomDatabase() { 16 | abstract fun employeeDao(): EmployeeDao 17 | } 18 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/di/StorageModule.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage.di 2 | 3 | import androidx.room.Room 4 | import com.patrykkosieradzki.androidmviexample.domain.DemoDataGenerator 5 | import com.patrykkosieradzki.androidmviexample.domain.repositories.EmployeeRepository 6 | import com.patrykkosieradzki.androidmviexample.storage.DatabaseDemoDataGenerator 7 | import com.patrykkosieradzki.androidmviexample.storage.db.AppDatabase 8 | import com.patrykkosieradzki.androidmviexample.storage.repositories.LocalEmployeeRepository 9 | import org.koin.dsl.module 10 | 11 | val storageModule = module { 12 | single { 13 | Room.databaseBuilder( 14 | get(), 15 | AppDatabase::class.java, "employee_db" 16 | ).build() 17 | } 18 | 19 | single { get().employeeDao() } 20 | 21 | single { 22 | DatabaseDemoDataGenerator( 23 | employeeDao = get() 24 | ) 25 | } 26 | 27 | single { 28 | LocalEmployeeRepository( 29 | employeeDao = get(), 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/entity/AddressEntity.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity 7 | data class AddressEntity( 8 | @PrimaryKey val uid: Long? = null, 9 | val address: String, 10 | val employeeId: Long? 11 | ) 12 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/entity/EmployeeEntity.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "employees") 7 | data class EmployeeEntity( 8 | @PrimaryKey val employeeId: Long? = null, 9 | val firstName: String, 10 | val lastName: String, 11 | val age: Int, 12 | val genderId: Long?, 13 | ) 14 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/entity/GenderEntity.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "genders") 8 | data class GenderEntity( 9 | @PrimaryKey val uid: Long, 10 | @ColumnInfo(name = "name") val name: String 11 | ) 12 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/model/EmployeeWithGenderAndAddresses.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage.model 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Relation 5 | import com.patrykkosieradzki.androidmviexample.storage.entity.AddressEntity 6 | import com.patrykkosieradzki.androidmviexample.storage.entity.EmployeeEntity 7 | import com.patrykkosieradzki.androidmviexample.storage.entity.GenderEntity 8 | 9 | data class EmployeeWithGenderAndAddresses( 10 | @Embedded val employee: EmployeeEntity, 11 | 12 | @Relation( 13 | parentColumn = "genderId", 14 | entityColumn = "uid" 15 | ) 16 | val gender: GenderEntity, 17 | 18 | @Relation( 19 | parentColumn = "employeeId", 20 | entityColumn = "employeeId" 21 | ) 22 | val addresses: List 23 | ) 24 | -------------------------------------------------------------------------------- /storage/src/main/java/com/patrykkosieradzki/androidmviexample/storage/repositories/LocalEmployeeRepository.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage.repositories 2 | 3 | import com.patrykkosieradzki.androidmviexample.domain.model.Employee 4 | import com.patrykkosieradzki.androidmviexample.domain.model.Gender 5 | import com.patrykkosieradzki.androidmviexample.domain.repositories.EmployeeRepository 6 | import com.patrykkosieradzki.androidmviexample.storage.dao.EmployeeDao 7 | 8 | class LocalEmployeeRepository( 9 | private val employeeDao: EmployeeDao 10 | ) : EmployeeRepository { 11 | override suspend fun getGenders(): List { 12 | return employeeDao.getAllGenders().map { Gender(it.uid, it.name) } 13 | } 14 | 15 | override suspend fun saveEmployee(employee: Employee) { 16 | employeeDao.insertDomainEmployee(employee) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /storage/src/test/java/com/patrykkosieradzki/androidmviexample/storage/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.storage 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /utils/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /utils/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'kotlin' 2 | 3 | dependencies { 4 | implementation fileTree(dir: 'libs', include: ['*.jar']) 5 | implementation deps.kotlin.stdlib 6 | implementation "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin" 7 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2' 8 | } 9 | 10 | sourceCompatibility = "8" 11 | targetCompatibility = "8" 12 | 13 | buildscript { 14 | repositories { 15 | mavenCentral() 16 | } 17 | dependencies { 18 | classpath deps.kotlin.plugin 19 | } 20 | } 21 | repositories { 22 | mavenCentral() 23 | } 24 | compileKotlin { 25 | kotlinOptions { 26 | jvmTarget = "1.8" 27 | } 28 | } 29 | compileTestKotlin { 30 | kotlinOptions { 31 | jvmTarget = "1.8" 32 | } 33 | } -------------------------------------------------------------------------------- /utils/src/main/java/com/patrykkosieradzki/androidmviexample/utils/AllOpen.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | annotation class AllOpen 4 | -------------------------------------------------------------------------------- /utils/src/main/java/com/patrykkosieradzki/androidmviexample/utils/MVIExampleDispatchers.kt: -------------------------------------------------------------------------------- 1 | package com.patrykkosieradzki.androidmviexample.utils 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Dispatchers 5 | 6 | object MVIExampleDispatchers { 7 | var Main: CoroutineDispatcher = Dispatchers.Main 8 | var IO: CoroutineDispatcher = Dispatchers.IO 9 | var Default = Dispatchers.Default 10 | 11 | fun resetAll() { 12 | Main = Dispatchers.Main 13 | IO = Dispatchers.IO 14 | Default = Dispatchers.Default 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /versions.gradle: -------------------------------------------------------------------------------- 1 | def kotlin_version = '1.5.10' 2 | ext.versions = [ 3 | kotlin : "$kotlin_version", 4 | kotlin_jdk8 : "jdk8-$kotlin_version", 5 | android_gradle_plugin : "4.2.0", 6 | shot : "4.4.0", 7 | koin : "2.2.2", 8 | lifecycle : "2.4.0-alpha01", 9 | lifecycle_extensions : "2.2.0", 10 | espresso : '3.2.0', 11 | ] 12 | 13 | def deps = [:] 14 | deps.android_gradle_plugin = "com.android.tools.build:gradle:$versions.android_gradle_plugin" 15 | deps.shot = "com.karumi:shot:$versions.shot" 16 | 17 | def kotlin = [ 18 | plugin : "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin", 19 | stdlib : "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin", 20 | test : "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin" 21 | ] 22 | deps.kotlin = kotlin 23 | 24 | def lifecycle = [ 25 | runtime : "androidx.lifecycle:lifecycle-runtime-ktx:$versions.lifecycle", 26 | livedata_ktx : "androidx.lifecycle:lifecycle-livedata-ktx:$versions.lifecycle", 27 | extensions : "androidx.lifecycle:lifecycle-extensions:$versions.lifecycle_extensions", 28 | viewmodel_ktx : "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.lifecycle" 29 | ] 30 | deps.lifecycle = lifecycle 31 | 32 | def koin = [ 33 | koin_android : "org.koin:koin-android:$versions.koin", 34 | koin_viewmodel : "org.koin:koin-androidx-viewmodel:$versions.koin" 35 | ] 36 | deps.koin = koin 37 | deps.koin_test = "org.koin:koin-test:$versions.koin" 38 | 39 | def espresso = [ 40 | core : "androidx.test.espresso:espresso-core:$versions.espresso", 41 | intents : "androidx.test.espresso:espresso-intents:$versions.espresso", 42 | contrib : "androidx.test.espresso:espresso-contrib:$versions.espresso" 43 | ] 44 | deps.espresso = espresso 45 | 46 | 47 | ext.deps = [:] 48 | ext.deps = deps --------------------------------------------------------------------------------