├── .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 |
5 |
6 |
7 |
8 |
9 |
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 |
120 |
121 |
122 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/jarRepositories.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
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
--------------------------------------------------------------------------------