Repository: android/architecture-samples Branch: main Commit: ee66e1526b84 Files: 108 Total size: 294.7 KB Directory structure: gitextract_6d9xqtrj/ ├── .github/ │ ├── ci-gradle.properties │ └── workflows/ │ ├── build_test.yaml │ └── copy-branch.yml ├── .gitignore ├── .google/ │ └── packaging.yaml ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ ├── proguardTest-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── example/ │ │ └── android/ │ │ └── architecture/ │ │ └── blueprints/ │ │ └── todoapp/ │ │ ├── addedittask/ │ │ │ └── AddEditTaskScreenTest.kt │ │ ├── data/ │ │ │ └── source/ │ │ │ └── local/ │ │ │ └── TaskDaoTest.kt │ │ ├── statistics/ │ │ │ └── StatisticsScreenTest.kt │ │ ├── taskdetail/ │ │ │ └── TaskDetailScreenTest.kt │ │ └── tasks/ │ │ ├── AppNavigationTest.kt │ │ ├── TasksScreenTest.kt │ │ └── TasksTest.kt │ ├── debug/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── example/ │ │ └── android/ │ │ └── architecture/ │ │ └── blueprints/ │ │ └── todoapp/ │ │ └── HiltTestActivity.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── example/ │ │ │ └── android/ │ │ │ └── architecture/ │ │ │ └── blueprints/ │ │ │ └── todoapp/ │ │ │ ├── TodoActivity.kt │ │ │ ├── TodoApplication.kt │ │ │ ├── TodoNavGraph.kt │ │ │ ├── TodoNavigation.kt │ │ │ ├── TodoTheme.kt │ │ │ ├── addedittask/ │ │ │ │ ├── AddEditTaskScreen.kt │ │ │ │ └── AddEditTaskViewModel.kt │ │ │ ├── data/ │ │ │ │ ├── DefaultTaskRepository.kt │ │ │ │ ├── ModelMappingExt.kt │ │ │ │ ├── Task.kt │ │ │ │ ├── TaskRepository.kt │ │ │ │ └── source/ │ │ │ │ ├── local/ │ │ │ │ │ ├── LocalTask.kt │ │ │ │ │ ├── TaskDao.kt │ │ │ │ │ └── ToDoDatabase.kt │ │ │ │ └── network/ │ │ │ │ ├── NetworkDataSource.kt │ │ │ │ ├── NetworkTask.kt │ │ │ │ └── TaskNetworkDataSource.kt │ │ │ ├── di/ │ │ │ │ ├── CoroutinesModule.kt │ │ │ │ └── DataModules.kt │ │ │ ├── statistics/ │ │ │ │ ├── StatisticsScreen.kt │ │ │ │ ├── StatisticsUtils.kt │ │ │ │ └── StatisticsViewModel.kt │ │ │ ├── taskdetail/ │ │ │ │ ├── TaskDetailScreen.kt │ │ │ │ └── TaskDetailViewModel.kt │ │ │ ├── tasks/ │ │ │ │ ├── TasksFilterType.kt │ │ │ │ ├── TasksScreen.kt │ │ │ │ └── TasksViewModel.kt │ │ │ └── util/ │ │ │ ├── Async.kt │ │ │ ├── ComposeUtils.kt │ │ │ ├── CoroutinesUtils.kt │ │ │ ├── SimpleCountingIdlingResource.kt │ │ │ ├── TodoDrawer.kt │ │ │ └── TopAppBars.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── drawer_item_color.xml │ │ │ ├── ic_add.xml │ │ │ ├── ic_assignment_turned_in_24dp.xml │ │ │ ├── ic_check_circle_96dp.xml │ │ │ ├── ic_done.xml │ │ │ ├── ic_edit.xml │ │ │ ├── ic_filter_list.xml │ │ │ ├── ic_list.xml │ │ │ ├── ic_menu.xml │ │ │ ├── ic_statistics.xml │ │ │ ├── ic_statistics_100dp.xml │ │ │ ├── ic_statistics_24dp.xml │ │ │ ├── ic_verified_user_96dp.xml │ │ │ ├── list_completed_touch_feedback.xml │ │ │ └── touch_feedback.xml │ │ ├── font/ │ │ │ └── opensans_font.xml │ │ ├── values/ │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ └── values-w820dp/ │ │ └── dimens.xml │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── android/ │ │ └── architecture/ │ │ └── blueprints/ │ │ └── todoapp/ │ │ ├── addedittask/ │ │ │ └── AddEditTaskViewModelTest.kt │ │ ├── data/ │ │ │ └── DefaultTaskRepositoryTest.kt │ │ ├── statistics/ │ │ │ ├── StatisticsUtilsTest.kt │ │ │ └── StatisticsViewModelTest.kt │ │ ├── taskdetail/ │ │ │ └── TaskDetailViewModelTest.kt │ │ └── tasks/ │ │ └── TasksViewModelTest.kt │ └── resources/ │ └── mockito-extensions/ │ └── org.mockito.plugins.MockMaker ├── build.gradle.kts ├── gradle/ │ ├── init.gradle.kts │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── renovate.json ├── settings.gradle.kts ├── shared-test/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── example/ │ └── android/ │ └── architecture/ │ └── blueprints/ │ └── todoapp/ │ ├── CustomTestRunner.kt │ ├── MainCoroutineRule.kt │ ├── data/ │ │ ├── FakeTaskRepository.kt │ │ └── source/ │ │ ├── local/ │ │ │ └── FakeTaskDao.kt │ │ └── network/ │ │ └── FakeNetworkDataSource.kt │ └── di/ │ ├── DatabaseTestModule.kt │ └── RepositoryTestModule.kt └── spotless/ ├── copyright.kt ├── copyright.kts └── copyright.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ci-gradle.properties ================================================ # # Copyright 2020 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # org.gradle.daemon=false org.gradle.parallel=true org.gradle.workers.max=2 kotlin.incremental=false # Controls KotlinOptions.allWarningsAsErrors. # This value used in CI and is currently set to false. # If you want to treat warnings as errors locally, set this property to true # in your ~/.gradle/gradle.properties file. warningsAsErrors=false ================================================ FILE: .github/workflows/build_test.yaml ================================================ name: build_test on: workflow_dispatch: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: api-level: [29] steps: - uses: actions/checkout@v4 - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm ls /dev/kvm - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Set Up JDK uses: actions/setup-java@v4 with: distribution: 'zulu' # See 'Supported distributions' for available options java-version: '17' cache: 'gradle' - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Setup Android SDK uses: android-actions/setup-android@v3 - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} arch: x86 disable-animations: true script: ./gradlew :app:connectedCheck --stacktrace - name: Upload test reports if: always() uses: actions/upload-artifact@v4 with: name: test-reports-${{ matrix.api-level }} path: ./app/build/reports/androidTests ================================================ FILE: .github/workflows/copy-branch.yml ================================================ # Duplicates default main branch to the old master branch name: Duplicates main to old master branch # Controls when the action will run. Triggers the workflow on push or pull request # events but only for the main branch on: workflow_dispatch: push: branches: [ main ] # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "copy-branch" copy-branch: # The type of runner that the job will run on runs-on: ubuntu-latest # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it, # but specifies master branch (old default). - uses: actions/checkout@v4 with: fetch-depth: 0 ref: master - run: | git config user.name github-actions git config user.email github-actions@github.com git merge origin/main git push ================================================ FILE: .gitignore ================================================ *.iml .gradle local.properties .idea .DS_Store build captures .externalNativeBuild ================================================ FILE: .google/packaging.yaml ================================================ # Copyright (C) 2020 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # GOOGLE SAMPLE PACKAGING DATA # # This file is used by Google as part of our samples packaging process. # End users may safely ignore this file. It has no relevance to other systems. --- status: PUBLISHED technologies: [Android, JetpackCompose, Coroutines] categories: - AndroidTesting - AndroidArchitecture - AndroidArchitectureUILayer - AndroidArchitectureDataLayer - AndroidArchitectureStateProduction - AndroidArchitectureStateHolder - AndroidArchitectureUIEvents - JetpackComposeTesting - JetpackComposeArchitectureAndState - JetpackComposeNavigation languages: [Kotlin] solutions: - Mobile - Flow - JetpackHilt - JetpackRoom - JetpackNavigation - JetpackLifecycle github: android/architecture-samples level: INTERMEDIATE license: apache2 ================================================ FILE: CODEOWNERS ================================================ * @josealcerreca @dturner ================================================ FILE: CONTRIBUTING.md ================================================ # How to become a contributor and submit your own code ## Contributor License Agreements We'd love to accept your patches! Before we can take them, we have to jump a couple of legal hurdles. ### Before you contribute Before we can use your code, you must sign the [Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual) (CLA), which you can do online. The CLA is necessary mainly because you own the copyright to your changes, even after your contribution becomes part of our codebase, so we need your permission to use and distribute your code. We also need to be sure of various other things—for instance that you'll tell us if you know that your code infringes on other people's patents. You don't have to sign the CLA until after you've submitted your code for review and a member has approved it, but you must do it before we can put your code into our codebase. Before you start working on a larger contribution, you should get in touch with us first through the issue tracker with your idea so that we can help out and possibly guide you. Coordinating up front makes it much easier to avoid frustration later on. ### Code reviews All submissions, including submissions by project members, require review. We use Github pull requests for this purpose. ### The small print Contributions made by corporations are covered by a different agreement than the one above, the [Software Grant and Corporate Contributor License Agreement](https://cla.developers.google.com/about/google-corporate). ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2014 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Android Architecture Samples These samples showcase different architectural approaches to developing Android apps. In its different branches you'll find the same app (a TODO app) implemented with small differences. In this branch you'll find: * User Interface built with **[Jetpack Compose](https://developer.android.com/jetpack/compose)** * A single-activity architecture, using **[Navigation Compose](https://developer.android.com/jetpack/compose/navigation)**. * A presentation layer that contains a Compose screen (View) and a **ViewModel** per screen (or feature). * Reactive UIs using **[Flow](https://developer.android.com/kotlin/flow)** and **[coroutines](https://kotlinlang.org/docs/coroutines-overview.html)** for asynchronous operations. * A **data layer** with a repository and two data sources (local using Room and a fake remote). * Two **product flavors**, `mock` and `prod`, [to ease development and testing](https://android-developers.googleblog.com/2015/12/leveraging-product-flavors-in-android.html). * A collection of unit, integration and e2e **tests**, including "shared" tests that can be run on emulator/device. * Dependency injection using [Hilt](https://developer.android.com/training/dependency-injection/hilt-android). ## Screenshots Screenshot ## Why a to-do app? The app in this project aims to be simple enough that you can understand it quickly, but complex enough to showcase difficult design decisions and testing scenarios. For more information, see the [app's specification](https://github.com/googlesamples/android-architecture/wiki/To-do-app-specification). ## What is it not? * A template. Check out the [Architecture Templates](https://github.com/android/architecture-templates) instead. * A UI/Material Design sample. The interface of the app is deliberately kept simple to focus on architecture. Check out the [Compose Samples](https://github.com/android/compose-samples) instead. * A real production app with network access, user authentication, etc. Check out the [Now in Android app](https://github.com/android/nowinandroid) instead. ## Who is it for? * Intermediate developers and beginners looking for a way to structure their app in a testable and maintainable way. * Advanced developers looking for quick reference. ## Opening a sample in Android Studio To open one of the samples in Android Studio, begin by checking out one of the sample branches, and then open the root directory in Android Studio. The following series of steps illustrate how to open the sample. Clone the repository: ``` git clone git@github.com:android/architecture-samples.git ``` Finally open the `architecture-samples/` directory in Android Studio. ### License ``` Copyright 2024 Google, Inc. Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle.kts ================================================ /* * Copyright 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.hilt) alias(libs.plugins.compose.compiler) } android { namespace = "com.example.android.architecture.blueprints.todoapp" compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.example.android.architecture.blueprints.main" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" testInstrumentationRunner = "com.example.android.architecture.blueprints.todoapp.CustomTestRunner" javaCompileOptions { annotationProcessorOptions { arguments += "room.incremental" to "true" } } } buildTypes { getByName("debug") { isMinifyEnabled = false isTestCoverageEnabled = true proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") testProguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguardTest-rules.pro") } getByName("release") { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") testProguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguardTest-rules.pro") } } // Always show the result of every unit test, even if it passes. testOptions.unitTests { isIncludeAndroidResources = true all { test -> with(test) { testLogging { events = setOf( org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED, org.gradle.api.tasks.testing.logging.TestLogEvent.SKIPPED, org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED, org.gradle.api.tasks.testing.logging.TestLogEvent.STANDARD_OUT, org.gradle.api.tasks.testing.logging.TestLogEvent.STANDARD_ERROR, ) } } } } buildFeatures { compose = true buildConfig = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } packaging { excludes += "META-INF/AL2.0" excludes += "META-INF/LGPL2.1" } tasks.withType().configureEach { kotlinOptions { freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" freeCompilerArgs += "-opt-in=kotlin.Experimental" } } } /* Dependency versions are defined in the top level build.gradle file. This helps keeping track of all versions in a single place. This improves readability and helps managing project complexity. */ dependencies { // App dependencies implementation(libs.androidx.annotation) implementation(libs.kotlinx.coroutines.android) implementation(libs.timber) implementation(libs.androidx.test.espresso.idling.resources) // Architecture Components implementation(libs.room.runtime) implementation(libs.room.ktx) ksp(libs.room.compiler) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.viewModelCompose) // Hilt implementation(libs.hilt.android.core) implementation(libs.androidx.hilt.navigation.compose) ksp(libs.hilt.compiler) // Jetpack Compose val composeBom = platform(libs.androidx.compose.bom) implementation(libs.androidx.activity.compose) implementation(composeBom) implementation(libs.androidx.compose.foundation.core) implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.accompanist.appcompat.theme) implementation(libs.accompanist.swiperefresh) debugImplementation(composeBom) debugImplementation(libs.androidx.compose.ui.tooling.core) debugImplementation(libs.androidx.compose.ui.test.manifest) // Dependencies for local unit tests testImplementation(composeBom) testImplementation(libs.junit4) testImplementation(libs.androidx.archcore.testing) testImplementation(libs.kotlinx.coroutines.android) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.androidx.navigation.testing) testImplementation(libs.androidx.test.espresso.core) testImplementation(libs.androidx.test.espresso.contrib) testImplementation(libs.androidx.test.espresso.intents) testImplementation(libs.google.truth) testImplementation(libs.androidx.compose.ui.test.junit) // JVM tests - Hilt testImplementation(libs.hilt.android.testing) kspTest(libs.hilt.compiler) // Dependencies for Android unit tests androidTestImplementation(composeBom) androidTestImplementation(libs.junit4) androidTestImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.compose.ui.test.junit) // AndroidX Test - JVM testing testImplementation(libs.androidx.test.core.ktx) testImplementation(libs.androidx.test.ext) testImplementation(libs.androidx.test.rules) testImplementation(project(":shared-test")) // AndroidX Test - Instrumented testing androidTestImplementation(libs.androidx.test.core.ktx) androidTestImplementation(libs.androidx.test.ext) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.room.testing) androidTestImplementation(libs.androidx.archcore.testing) androidTestImplementation(libs.androidx.navigation.testing) androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.androidx.test.espresso.contrib) androidTestImplementation(libs.androidx.test.espresso.intents) androidTestImplementation(libs.androidx.test.espresso.idling.resources) androidTestImplementation(libs.androidx.test.espresso.idling.concurrent) androidTestImplementation(project(":shared-test")) // AndroidX Test - Hilt testing androidTestImplementation(libs.hilt.android.testing) kspAndroidTest(libs.hilt.compiler) } ================================================ FILE: app/proguard-rules.pro ================================================ -dontoptimize # Some methods are only called from tests, so make sure the shrinker keeps them. -keep class com.example.android.architecture.blueprints.** { *; } -keep class androidx.drawerlayout.widget.DrawerLayout { *; } -keep class androidx.test.espresso.** # keep the class and specified members from being removed or renamed -keep class androidx.test.espresso.IdlingRegistry { *; } -keep class androidx.test.espresso.IdlingResource { *; } -keep class com.google.common.base.Preconditions { *; } -keep class androidx.room.RoomDataBase { *; } -keep class androidx.room.Room { *; } -keep class android.arch.** { *; } # Proguard rules that are applied to your test apk/code. -ignorewarnings -keepattributes *Annotation* -dontnote junit.framework.** -dontnote junit.runner.** -dontwarn androidx.test.** -dontwarn org.junit.** -dontwarn org.hamcrest.** -dontwarn com.squareup.javawriter.JavaWriter # Uncomment this if you use Mockito -dontwarn org.mockito.** ================================================ FILE: app/proguardTest-rules.pro ================================================ # Proguard rules that are applied to your test apk/code. -ignorewarnings -dontoptimize -keepattributes *Annotation* -keep class androidx.test.espresso.** # keep the class and specified members from being removed or renamed -keep class androidx.test.espresso.IdlingRegistry { *; } -keep class androidx.test.espresso.IdlingResource { *; } -dontnote junit.framework.** -dontnote junit.runner.** -dontwarn androidx.test.** -dontwarn org.junit.** -dontwarn org.hamcrest.** -dontwarn com.squareup.javawriter.JavaWriter ================================================ FILE: app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreenTest.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.addedittask import androidx.compose.material3.Surface import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.example.android.architecture.blueprints.todoapp.HiltTestActivity import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoTheme import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import javax.inject.Inject /** * Integration test for the Add Task screen. */ @RunWith(AndroidJUnit4::class) @MediumTest @HiltAndroidTest @ExperimentalCoroutinesApi class AddEditTaskScreenTest { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() private val activity get() = composeTestRule.activity @Inject lateinit var repository: TaskRepository @Before fun setup() { hiltRule.inject() // GIVEN - On the "Add Task" screen. composeTestRule.setContent { TodoTheme { Surface { AddEditTaskScreen( viewModel = AddEditTaskViewModel(repository, SavedStateHandle()), topBarTitle = R.string.add_task, onTaskUpdate = { }, onBack = { }, ) } } } } @Test fun emptyTask_isNotSaved() { // WHEN - Enter invalid title and description combination and click save findTextField(R.string.title_hint).performTextClearance() findTextField(R.string.description_hint).performTextClearance() composeTestRule.onNodeWithContentDescription(activity.getString(R.string.cd_save_task)) .performClick() // THEN - Entered Task is still displayed (a correct task would close it). composeTestRule .onNodeWithText(activity.getString(R.string.empty_task_message)) .assertIsDisplayed() } @Test fun validTask_isSaved() = runTest { // WHEN - Valid title and description combination and click save findTextField(R.string.title_hint).performTextInput("title") findTextField(R.string.description_hint).performTextInput("description") composeTestRule.onNodeWithContentDescription(activity.getString(R.string.cd_save_task)) .performClick() // THEN - Verify that the repository saved the task val tasks = repository.getTasks(true) assertEquals(1, tasks.size) assertEquals("title", tasks[0].title) assertEquals("description", tasks[0].description) } private fun findTextField(text: Int): SemanticsNodeInteraction { return composeTestRule.onNode( hasSetTextAction() and hasText(activity.getString(text)) ) } } ================================================ FILE: app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDaoTest.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.local import androidx.room.Room import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @ExperimentalCoroutinesApi @RunWith(AndroidJUnit4::class) @SmallTest class TaskDaoTest { // using an in-memory database because the information stored here disappears when the // process is killed private lateinit var database: ToDoDatabase // Ensure that we use a new database for each test. @Before fun initDb() { database = Room.inMemoryDatabaseBuilder( getApplicationContext(), ToDoDatabase::class.java ).allowMainThreadQueries().build() } @Test fun insertTaskAndGetById() = runTest { // GIVEN - insert a task val task = LocalTask( title = "title", description = "description", id = "id", isCompleted = false, ) database.taskDao().upsert(task) // WHEN - Get the task by id from the database val loaded = database.taskDao().getById(task.id) // THEN - The loaded data contains the expected values assertNotNull(loaded as LocalTask) assertEquals(task.id, loaded.id) assertEquals(task.title, loaded.title) assertEquals(task.description, loaded.description) assertEquals(task.isCompleted, loaded.isCompleted) } @Test fun insertTaskReplacesOnConflict() = runTest { // Given that a task is inserted val task = LocalTask( title = "title", description = "description", id = "id", isCompleted = false, ) database.taskDao().upsert(task) // When a task with the same id is inserted val newTask = LocalTask( title = "title2", description = "description2", isCompleted = true, id = task.id ) database.taskDao().upsert(newTask) // THEN - The loaded data contains the expected values val loaded = database.taskDao().getById(task.id) assertEquals(task.id, loaded?.id) assertEquals("title2", loaded?.title) assertEquals("description2", loaded?.description) assertEquals(true, loaded?.isCompleted) } @Test fun insertTaskAndGetTasks() = runTest { // GIVEN - insert a task val task = LocalTask( title = "title", description = "description", id = "id", isCompleted = false, ) database.taskDao().upsert(task) // WHEN - Get tasks from the database val tasks = database.taskDao().getAll() // THEN - There is only 1 task in the database, and contains the expected values assertEquals(1, tasks.size) assertEquals(tasks[0].id, task.id) assertEquals(tasks[0].title, task.title) assertEquals(tasks[0].description, task.description) assertEquals(tasks[0].isCompleted, task.isCompleted) } @Test fun updateTaskAndGetById() = runTest { // When inserting a task val originalTask = LocalTask( title = "title", description = "description", id = "id", isCompleted = false, ) database.taskDao().upsert(originalTask) // When the task is updated val updatedTask = LocalTask( title = "new title", description = "new description", isCompleted = true, id = originalTask.id ) database.taskDao().upsert(updatedTask) // THEN - The loaded data contains the expected values val loaded = database.taskDao().getById(originalTask.id) assertEquals(originalTask.id, loaded?.id) assertEquals("new title", loaded?.title) assertEquals("new description", loaded?.description) assertEquals(true, loaded?.isCompleted) } @Test fun updateCompletedAndGetById() = runTest { // When inserting a task val task = LocalTask( title = "title", description = "description", id = "id", isCompleted = true ) database.taskDao().upsert(task) // When the task is updated database.taskDao().updateCompleted(task.id, false) // THEN - The loaded data contains the expected values val loaded = database.taskDao().getById(task.id) assertEquals(task.id, loaded?.id) assertEquals(task.title, loaded?.title) assertEquals(task.description, loaded?.description) assertEquals(false, loaded?.isCompleted) } @Test fun deleteTaskByIdAndGettingTasks() = runTest { // Given a task inserted val task = LocalTask( title = "title", description = "description", id = "id", isCompleted = false, ) database.taskDao().upsert(task) // When deleting a task by id database.taskDao().deleteById(task.id) // THEN - The list is empty val tasks = database.taskDao().getAll() assertEquals(true, tasks.isEmpty()) } @Test fun deleteTasksAndGettingTasks() = runTest { // Given a task inserted database.taskDao().upsert( LocalTask( title = "title", description = "description", id = "id", isCompleted = false, ) ) // When deleting all tasks database.taskDao().deleteAll() // THEN - The list is empty val tasks = database.taskDao().getAll() assertEquals(true, tasks.isEmpty()) } @Test fun deleteCompletedTasksAndGettingTasks() = runTest { // Given a completed task inserted database.taskDao().upsert( LocalTask(title = "completed", description = "task", id = "id", isCompleted = true) ) // When deleting completed tasks database.taskDao().deleteCompleted() // THEN - The list is empty val tasks = database.taskDao().getAll() assertEquals(true, tasks.isEmpty()) } } ================================================ FILE: app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreenTest.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.statistics import androidx.compose.material3.Surface import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.example.android.architecture.blueprints.todoapp.HiltTestActivity import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoTheme import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import javax.inject.Inject /** * Integration test for the statistics screen. */ @RunWith(AndroidJUnit4::class) @MediumTest @HiltAndroidTest @ExperimentalCoroutinesApi class StatisticsScreenTest { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() private val activity get() = composeTestRule.activity @Inject lateinit var repository: TaskRepository @Before fun setup() { hiltRule.inject() } @Test fun tasks_showsNonEmptyMessage() = runTest { // Given some tasks repository.apply { createTask("Title1", "Description1") createTask("Title2", "Description2").also { completeTask(it) } } composeTestRule.setContent { TodoTheme { Surface { StatisticsScreen( openDrawer = { }, viewModel = StatisticsViewModel(repository) ) } } } val expectedActiveTaskText = activity.getString(R.string.statistics_active_tasks, 50.0f) val expectedCompletedTaskText = activity .getString(R.string.statistics_completed_tasks, 50.0f) // check that both info boxes are displayed and contain the correct info composeTestRule.onNodeWithText(expectedActiveTaskText).assertIsDisplayed() composeTestRule.onNodeWithText(expectedCompletedTaskText).assertIsDisplayed() } } ================================================ FILE: app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreenTest.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.taskdetail import androidx.compose.material3.Surface import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.isToggleable import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.example.android.architecture.blueprints.todoapp.HiltTestActivity import com.example.android.architecture.blueprints.todoapp.TodoTheme import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import javax.inject.Inject /** * Integration test for the Task Details screen. */ @MediumTest @RunWith(AndroidJUnit4::class) @HiltAndroidTest @ExperimentalCoroutinesApi class TaskDetailScreenTest { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() @Inject lateinit var repository: TaskRepository @Before fun setup() { hiltRule.inject() } @Test fun activeTaskDetails_DisplayedInUi() = runTest { // GIVEN - Add active (incomplete) task to the DB val activeTaskId = repository.createTask( title = "Active Task", description = "AndroidX Rocks" ) // WHEN - Details screen is opened setContent(activeTaskId) // THEN - Task details are displayed on the screen // make sure that the title/description are both shown and correct composeTestRule.onNodeWithText("Active Task").assertIsDisplayed() composeTestRule.onNodeWithText("AndroidX Rocks").assertIsDisplayed() // and make sure the "active" checkbox is shown unchecked composeTestRule.onNode(isToggleable()).assertIsOff() } @Test fun completedTaskDetails_DisplayedInUi() = runTest { // GIVEN - Add completed task to the DB val completedTaskId = repository.createTask("Completed Task", "AndroidX Rocks") repository.completeTask(completedTaskId) // WHEN - Details screen is opened setContent(completedTaskId) // THEN - Task details are displayed on the screen // make sure that the title/description are both shown and correct composeTestRule.onNodeWithText("Completed Task").assertIsDisplayed() composeTestRule.onNodeWithText("AndroidX Rocks").assertIsDisplayed() // and make sure the "active" checkbox is shown unchecked composeTestRule.onNode(isToggleable()).assertIsOn() } private fun setContent(activeTaskId: String) { composeTestRule.setContent { TodoTheme { Surface { TaskDetailScreen( viewModel = TaskDetailViewModel( repository, SavedStateHandle(mapOf("taskId" to activeTaskId)) ), onEditTask = { /*TODO*/ }, onBack = { }, onDeleteTask = { }, ) } } } } } ================================================ FILE: app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/AppNavigationTest.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.tasks import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.espresso.Espresso.pressBack import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import com.example.android.architecture.blueprints.todoapp.HiltTestActivity import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoNavGraph import com.example.android.architecture.blueprints.todoapp.TodoTheme import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import javax.inject.Inject /** * Tests for scenarios that requires navigating within the app. */ @RunWith(AndroidJUnit4::class) @LargeTest @HiltAndroidTest class AppNavigationTest { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) // Executes tasks in the Architecture Components in the same thread @get:Rule(order = 1) var instantTaskExecutorRule = InstantTaskExecutorRule() @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule() private val activity get() = composeTestRule.activity @Inject lateinit var taskRepository: TaskRepository @Before fun init() { hiltRule.inject() } @Test fun drawerNavigationFromTasksToStatistics() { setContent() openDrawer() // Start statistics screen. composeTestRule.onNodeWithText(activity.getString(R.string.statistics_title)).performClick() // Check that statistics screen was opened. composeTestRule.onNodeWithText(activity.getString(R.string.statistics_no_tasks)) .assertIsDisplayed() openDrawer() // Start tasks screen. composeTestRule.onNodeWithText(activity.getString(R.string.list_title)).performClick() // Check that tasks screen was opened. composeTestRule.onNodeWithText(activity.getString(R.string.no_tasks_all)) .assertIsDisplayed() } @Test fun tasksScreen_clickOnAndroidHomeIcon_OpensNavigation() { setContent() // Check that left drawer is closed at startup composeTestRule.onNodeWithText(activity.getString(R.string.list_title)) .assertIsNotDisplayed() composeTestRule.onNodeWithText(activity.getString(R.string.statistics_title)) .assertIsNotDisplayed() openDrawer() // Check if drawer is open composeTestRule.onNodeWithText(activity.getString(R.string.list_title)).assertIsDisplayed() composeTestRule.onNodeWithText(activity.getString(R.string.statistics_title)) .assertIsDisplayed() } @Test fun statsScreen_clickOnAndroidHomeIcon_OpensNavigation() { setContent() // When the user navigates to the stats screen openDrawer() composeTestRule.onNodeWithText(activity.getString(R.string.statistics_title)).performClick() composeTestRule.onNodeWithText(activity.getString(R.string.list_title)) .assertIsNotDisplayed() openDrawer() // Check if drawer is open composeTestRule.onNodeWithText(activity.getString(R.string.list_title)).assertIsDisplayed() assertTrue( composeTestRule.onAllNodesWithText(activity.getString(R.string.statistics_title)) .fetchSemanticsNodes().isNotEmpty() ) } @Test fun taskDetailScreen_doubleUIBackButton() = runTest { val taskName = "UI <- button" taskRepository.createTask(taskName, "Description") setContent() // Click on the task on the list composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText(taskName).assertIsDisplayed() composeTestRule.onNodeWithText(taskName).performClick() // Click on the edit task button composeTestRule.onNodeWithContentDescription(activity.getString(R.string.edit_task)) .assertIsDisplayed() composeTestRule.onNodeWithContentDescription(activity.getString(R.string.edit_task)) .performClick() // Confirm that if we click "<-" once, we end up back at the task details page composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_back)) .performClick() composeTestRule.onNodeWithText(taskName).assertIsDisplayed() // Confirm that if we click "<-" a second time, we end up back at the home screen composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_back)) .performClick() composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() } @Test fun taskDetailScreen_doubleBackButton() = runTest { val taskName = "Back button" taskRepository.createTask(taskName, "Description") setContent() // Click on the task on the list composeTestRule.onNodeWithText(taskName).assertIsDisplayed() composeTestRule.onNodeWithText(taskName).performClick() // Click on the edit task button composeTestRule.onNodeWithContentDescription(activity.getString(R.string.edit_task)) .performClick() // Confirm that if we click back once, we end up back at the task details page pressBack() composeTestRule.onNodeWithText(taskName).assertIsDisplayed() // Confirm that if we click back a second time, we end up back at the home screen pressBack() composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() } private fun setContent() { composeTestRule.setContent { TodoTheme { TodoNavGraph() } } } private fun openDrawer() { composeTestRule.onNodeWithContentDescription(activity.getString(R.string.open_drawer)) .performClick() } } ================================================ FILE: app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreenTest.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.tasks import androidx.annotation.StringRes import androidx.compose.material3.Surface import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.isToggleable import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.example.android.architecture.blueprints.todoapp.HiltTestActivity import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoTheme import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import javax.inject.Inject /** * Integration test for the Task List screen. */ // TODO - Move to the sharedTest folder when https://issuetracker.google.com/224974381 is fixed @RunWith(AndroidJUnit4::class) @MediumTest // @LooperMode(LooperMode.Mode.PAUSED) // @TextLayoutMode(TextLayoutMode.Mode.REALISTIC) @HiltAndroidTest @OptIn(ExperimentalCoroutinesApi::class) class TasksScreenTest { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() private val activity get() = composeTestRule.activity @Inject lateinit var repository: TaskRepository @Before fun init() { hiltRule.inject() } @Test fun displayTask_whenRepositoryHasData() = runTest { // GIVEN - One task already in the repository repository.createTask("TITLE1", "DESCRIPTION1") // WHEN - On startup setContent() // THEN - Verify task is displayed on screen composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() } @Test fun displayActiveTask() = runTest { repository.createTask("TITLE1", "DESCRIPTION1") setContent() composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() openFilterAndSelectOption(R.string.nav_active) composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() openFilterAndSelectOption(R.string.nav_completed) composeTestRule.onNodeWithText("TITLE1").assertDoesNotExist() } @Test fun displayCompletedTask() = runTest { repository.apply { createTask("TITLE1", "DESCRIPTION1").also { completeTask(it) } } setContent() composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() openFilterAndSelectOption(R.string.nav_active) composeTestRule.onNodeWithText("TITLE1").assertDoesNotExist() openFilterAndSelectOption(R.string.nav_completed) composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() } @Test fun markTaskAsComplete() = runTest { repository.createTask("TITLE1", "DESCRIPTION1") setContent() // Mark the task as complete composeTestRule.onNode(isToggleable()).performClick() // Verify task is shown as complete openFilterAndSelectOption(R.string.nav_all) composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() openFilterAndSelectOption(R.string.nav_active) composeTestRule.onNodeWithText("TITLE1").assertDoesNotExist() openFilterAndSelectOption(R.string.nav_completed) composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() } @Test fun markTaskAsActive() = runTest { repository.apply { createTask("TITLE1", "DESCRIPTION1").also { completeTask(it) } } setContent() // Mark the task as active composeTestRule.onNode(isToggleable()).performClick() // Verify task is shown as active openFilterAndSelectOption(R.string.nav_all) composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() openFilterAndSelectOption(R.string.nav_active) composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() openFilterAndSelectOption(R.string.nav_completed) composeTestRule.onNodeWithText("TITLE1").assertDoesNotExist() } @Test fun showAllTasks() = runTest { // Add one active task and one completed task repository.apply { createTask("TITLE1", "DESCRIPTION1") createTask("TITLE2", "DESCRIPTION2").also { completeTask(it) } } setContent() // Verify that both of our tasks are shown openFilterAndSelectOption(R.string.nav_all) composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() composeTestRule.onNodeWithText("TITLE2").assertIsDisplayed() } @Test fun showActiveTasks() = runTest { // Add 2 active tasks and one completed task repository.apply { createTask("TITLE1", "DESCRIPTION1") createTask("TITLE2", "DESCRIPTION2") createTask("TITLE3", "DESCRIPTION3").also { completeTask(it) } } setContent() // Verify that the active tasks (but not the completed task) are shown openFilterAndSelectOption(R.string.nav_active) composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() composeTestRule.onNodeWithText("TITLE2").assertIsDisplayed() composeTestRule.onNodeWithText("TITLE3").assertDoesNotExist() } @Test fun showCompletedTasks() = runTest { // Add one active task and 2 completed tasks repository.apply { createTask("TITLE1", "DESCRIPTION1") createTask("TITLE2", "DESCRIPTION2").also { completeTask(it) } createTask("TITLE3", "DESCRIPTION3").also { completeTask(it) } } setContent() // Verify that the completed tasks (but not the active task) are shown openFilterAndSelectOption(R.string.nav_completed) composeTestRule.onNodeWithText("TITLE1").assertDoesNotExist() composeTestRule.onNodeWithText("TITLE2").assertIsDisplayed() composeTestRule.onNodeWithText("TITLE3").assertIsDisplayed() } @Test fun clearCompletedTasks() = runTest { // Add one active task and one completed task repository.apply { createTask("TITLE1", "DESCRIPTION1") createTask("TITLE2", "DESCRIPTION2").also { completeTask(it) } } setContent() // Click clear completed in menu composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_more)) .performClick() composeTestRule.onNodeWithText(activity.getString(R.string.menu_clear)).assertIsDisplayed() composeTestRule.onNodeWithText(activity.getString(R.string.menu_clear)).performClick() openFilterAndSelectOption(R.string.nav_all) // Verify that only the active task is shown composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() composeTestRule.onNodeWithText("TITLE2").assertDoesNotExist() } @Test fun noTasks_AllTasksFilter_AddTaskViewVisible() { setContent() openFilterAndSelectOption(R.string.nav_all) // Verify the "You have no tasks!" text is shown composeTestRule.onNodeWithText("You have no tasks!").assertIsDisplayed() } @Test fun noTasks_CompletedTasksFilter_AddTaskViewNotVisible() { setContent() openFilterAndSelectOption(R.string.nav_completed) // Verify the "You have no completed tasks!" text is shown composeTestRule.onNodeWithText("You have no completed tasks!").assertIsDisplayed() } @Test fun noTasks_ActiveTasksFilter_AddTaskViewNotVisible() { setContent() openFilterAndSelectOption(R.string.nav_active) // Verify the "You have no active tasks!" text is shown composeTestRule.onNodeWithText("You have no active tasks!").assertIsDisplayed() } private fun setContent() { composeTestRule.setContent { TodoTheme { Surface { TasksScreen( viewModel = TasksViewModel(repository, SavedStateHandle()), userMessage = R.string.successfully_added_task_message, onUserMessageDisplayed = { }, onAddTask = { }, onTaskClick = { }, openDrawer = { } ) } } } } private fun openFilterAndSelectOption(@StringRes option: Int) { composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_filter)) .performClick() composeTestRule.onNodeWithText(activity.getString(option)).assertIsDisplayed() composeTestRule.onNodeWithText(activity.getString(option)).performClick() } } ================================================ FILE: app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksTest.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.tasks import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isToggleable import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import com.example.android.architecture.blueprints.todoapp.HiltTestActivity import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoNavGraph import com.example.android.architecture.blueprints.todoapp.TodoTheme import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import javax.inject.Inject /** * Large End-to-End test for the tasks module. */ @RunWith(AndroidJUnit4::class) @LargeTest @HiltAndroidTest @OptIn(ExperimentalCoroutinesApi::class) class TasksTest { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) // Executes tasks in the Architecture Components in the same thread @get:Rule(order = 1) var instantTaskExecutorRule = InstantTaskExecutorRule() @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule() private val activity get() = composeTestRule.activity @Inject lateinit var repository: TaskRepository @Before fun init() { hiltRule.inject() } @Test fun editTask() = runTest { val originalTaskTitle = "TITLE1" repository.createTask(originalTaskTitle, "DESCRIPTION") setContent() // Click on the task on the list and verify that all the data is correct composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText(originalTaskTitle).assertIsDisplayed() composeTestRule.onNodeWithText(originalTaskTitle).performClick() // Task detail screen composeTestRule.onNodeWithText(activity.getString(R.string.task_details)) .assertIsDisplayed() composeTestRule.onNodeWithText(originalTaskTitle).assertIsDisplayed() composeTestRule.onNodeWithText("DESCRIPTION").assertIsDisplayed() composeTestRule.onNode(isToggleable()).assertIsOff() // Click on the edit button, edit, and save composeTestRule.onNodeWithContentDescription(activity.getString(R.string.edit_task)) .performClick() composeTestRule.onNodeWithText(activity.getString(R.string.edit_task)).assertIsDisplayed() findTextField(originalTaskTitle).performTextReplacement("NEW TITLE") findTextField("DESCRIPTION").performTextReplacement("NEW DESCRIPTION") composeTestRule.onNodeWithContentDescription(activity.getString(R.string.cd_save_task)) .performClick() // Verify task is displayed on screen in the task list. composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText("NEW TITLE").assertIsDisplayed() // Verify previous task is not displayed composeTestRule.onNodeWithText(originalTaskTitle).assertDoesNotExist() } @Test fun createOneTask_deleteTask() { setContent() val taskTitle = "TITLE1" // Add active task composeTestRule.onNodeWithContentDescription(activity.getString(R.string.add_task)) .performClick() findTextField(R.string.title_hint).performTextInput(taskTitle) findTextField(R.string.description_hint).performTextInput("DESCRIPTION") composeTestRule.onNodeWithContentDescription(activity.getString(R.string.cd_save_task)) .performClick() composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).assertIsDisplayed() // Open the task detail screen composeTestRule.onNodeWithText(taskTitle).performClick() composeTestRule.onNodeWithText(activity.getString(R.string.task_details)) .assertIsDisplayed() // Click delete task in menu composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_delete_task)) .performClick() // Verify it was deleted composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_filter)) .performClick() composeTestRule.onNodeWithText(activity.getString(R.string.nav_all)).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).assertDoesNotExist() } @Test fun createTwoTasks_deleteOneTask() = runTest { repository.apply { createTask("TITLE1", "DESCRIPTION") createTask("TITLE2", "DESCRIPTION") } setContent() // Open the second task in details view composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText("TITLE2").assertIsDisplayed() composeTestRule.onNodeWithText("TITLE2").performClick() // Click delete task in menu composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_delete_task)) .performClick() // Verify only one task was deleted composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_filter)) .performClick() composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).performClick() composeTestRule.onNodeWithText("TITLE1").assertIsDisplayed() composeTestRule.onNodeWithText("TITLE2").assertDoesNotExist() } @Test fun markTaskAsCompleteOnDetailScreen_taskIsCompleteInList() = runTest { // Add 1 active task val taskTitle = "COMPLETED" repository.createTask(taskTitle, "DESCRIPTION") setContent() // Click on the task on the list composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).performClick() // Click on the checkbox in task details screen composeTestRule.onNode(isToggleable()).performClick() // Click on the navigation up button to go back to the list composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_back)) .performClick() // Check that the task is marked as completed composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNode(isToggleable()).assertIsOn() } @Test fun markTaskAsActiveOnDetailScreen_taskIsActiveInList() = runTest { // Add 1 completed task val taskTitle = "ACTIVE" repository.apply { createTask(taskTitle, "DESCRIPTION").also { completeTask(it) } } setContent() // Click on the task on the list composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).performClick() // Click on the checkbox in task details screen composeTestRule.onNode(isToggleable()).performClick() // Click on the navigation up button to go back to the list composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_back)) .performClick() // Check that the task is marked as active composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNode(isToggleable()).assertIsOff() } @Test fun markTaskAsCompleteAndActiveOnDetailScreen_taskIsActiveInList() = runTest { // Add 1 active task val taskTitle = "ACT-COMP" repository.createTask(taskTitle, "DESCRIPTION") setContent() // Click on the task on the list composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).performClick() // Click on the checkbox in task details screen composeTestRule.onNode(isToggleable()).performClick() // Click again to restore it to original state composeTestRule.onNode(isToggleable()).performClick() // Click on the navigation up button to go back to the list composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_back)) .performClick() // Check that the task is marked as active composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNode(isToggleable()).assertIsOff() } @Test fun markTaskAsActiveAndCompleteOnDetailScreen_taskIsCompleteInList() = runTest { // Add 1 completed task val taskTitle = "COMP-ACT" repository.apply { createTask(taskTitle, "DESCRIPTION").also { completeTask(it) } } setContent() // Click on the task on the list composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).assertIsDisplayed() composeTestRule.onNodeWithText(taskTitle).performClick() // Click on the checkbox in task details screen composeTestRule.onNode(isToggleable()).performClick() // Click again to restore it to original state composeTestRule.onNode(isToggleable()).performClick() // Click on the navigation up button to go back to the list composeTestRule.onNodeWithContentDescription(activity.getString(R.string.menu_back)) .performClick() // Check that the task is marked as active composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNode(isToggleable()).assertIsOn() } @Test fun createTask() { setContent() // Click on the "+" button, add details, and save composeTestRule.onNodeWithContentDescription(activity.getString(R.string.add_task)) .performClick() findTextField(R.string.title_hint).performTextInput("title") findTextField(R.string.description_hint).performTextInput("description") composeTestRule.onNodeWithContentDescription(activity.getString(R.string.cd_save_task)) .performClick() // Then verify task is displayed on screen composeTestRule.onNodeWithText(activity.getString(R.string.label_all)).assertIsDisplayed() composeTestRule.onNodeWithText("title").assertIsDisplayed() } private fun setContent() { composeTestRule.setContent { TodoTheme { TodoNavGraph() } } } private fun findTextField(textId: Int): SemanticsNodeInteraction { return composeTestRule.onNode( hasSetTextAction() and hasText(activity.getString(textId)) ) } private fun findTextField(text: String): SemanticsNodeInteraction { return composeTestRule.onNode( hasSetTextAction() and hasText(text) ) } } ================================================ FILE: app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: app/src/debug/java/com/example/android/architecture/blueprints/todoapp/HiltTestActivity.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp import androidx.activity.ComponentActivity import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class HiltTestActivity : ComponentActivity() ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoActivity.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import dagger.hilt.android.AndroidEntryPoint /** * Main activity for the todoapp */ @AndroidEntryPoint class TodoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { TodoTheme { TodoNavGraph() } } } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoApplication.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp import android.app.Application import dagger.hilt.android.HiltAndroidApp import timber.log.Timber import timber.log.Timber.DebugTree /** * Application that sets up Timber in the DEBUG BuildConfig. * Read Timber's documentation for production setups. */ @HiltAndroidApp class TodoApplication : Application() { override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) Timber.plant(DebugTree()) } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavGraph.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp import android.app.Activity import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.TASK_ID_ARG import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.TITLE_ARG import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.USER_MESSAGE_ARG import com.example.android.architecture.blueprints.todoapp.addedittask.AddEditTaskScreen import com.example.android.architecture.blueprints.todoapp.statistics.StatisticsScreen import com.example.android.architecture.blueprints.todoapp.taskdetail.TaskDetailScreen import com.example.android.architecture.blueprints.todoapp.tasks.TasksScreen import com.example.android.architecture.blueprints.todoapp.util.AppModalDrawer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Composable fun TodoNavGraph( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), coroutineScope: CoroutineScope = rememberCoroutineScope(), drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), startDestination: String = TodoDestinations.TASKS_ROUTE, navActions: TodoNavigationActions = remember(navController) { TodoNavigationActions(navController) } ) { val currentNavBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = currentNavBackStackEntry?.destination?.route ?: startDestination NavHost( navController = navController, startDestination = startDestination, modifier = modifier ) { composable( TodoDestinations.TASKS_ROUTE, arguments = listOf( navArgument(USER_MESSAGE_ARG) { type = NavType.IntType; defaultValue = 0 } ) ) { entry -> AppModalDrawer(drawerState, currentRoute, navActions) { TasksScreen( userMessage = entry.arguments?.getInt(USER_MESSAGE_ARG)!!, onUserMessageDisplayed = { entry.arguments?.putInt(USER_MESSAGE_ARG, 0) }, onAddTask = { navActions.navigateToAddEditTask(R.string.add_task, null) }, onTaskClick = { task -> navActions.navigateToTaskDetail(task.id) }, openDrawer = { coroutineScope.launch { drawerState.open() } } ) } } composable(TodoDestinations.STATISTICS_ROUTE) { AppModalDrawer(drawerState, currentRoute, navActions) { StatisticsScreen(openDrawer = { coroutineScope.launch { drawerState.open() } }) } } composable( TodoDestinations.ADD_EDIT_TASK_ROUTE, arguments = listOf( navArgument(TITLE_ARG) { type = NavType.IntType }, navArgument(TASK_ID_ARG) { type = NavType.StringType; nullable = true }, ) ) { entry -> val taskId = entry.arguments?.getString(TASK_ID_ARG) AddEditTaskScreen( topBarTitle = entry.arguments?.getInt(TITLE_ARG)!!, onTaskUpdate = { navActions.navigateToTasks( if (taskId == null) ADD_EDIT_RESULT_OK else EDIT_RESULT_OK ) }, onBack = { navController.popBackStack() } ) } composable(TodoDestinations.TASK_DETAIL_ROUTE) { TaskDetailScreen( onEditTask = { taskId -> navActions.navigateToAddEditTask(R.string.edit_task, taskId) }, onBack = { navController.popBackStack() }, onDeleteTask = { navActions.navigateToTasks(DELETE_RESULT_OK) } ) } } } // Keys for navigation const val ADD_EDIT_RESULT_OK = Activity.RESULT_FIRST_USER + 1 const val DELETE_RESULT_OK = Activity.RESULT_FIRST_USER + 2 const val EDIT_RESULT_OK = Activity.RESULT_FIRST_USER + 3 ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavigation.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.TASK_ID_ARG import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.TITLE_ARG import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.USER_MESSAGE_ARG import com.example.android.architecture.blueprints.todoapp.TodoScreens.ADD_EDIT_TASK_SCREEN import com.example.android.architecture.blueprints.todoapp.TodoScreens.STATISTICS_SCREEN import com.example.android.architecture.blueprints.todoapp.TodoScreens.TASKS_SCREEN import com.example.android.architecture.blueprints.todoapp.TodoScreens.TASK_DETAIL_SCREEN /** * Screens used in [TodoDestinations] */ private object TodoScreens { const val TASKS_SCREEN = "tasks" const val STATISTICS_SCREEN = "statistics" const val TASK_DETAIL_SCREEN = "task" const val ADD_EDIT_TASK_SCREEN = "addEditTask" } /** * Arguments used in [TodoDestinations] routes */ object TodoDestinationsArgs { const val USER_MESSAGE_ARG = "userMessage" const val TASK_ID_ARG = "taskId" const val TITLE_ARG = "title" } /** * Destinations used in the [TodoActivity] */ object TodoDestinations { const val TASKS_ROUTE = "$TASKS_SCREEN?$USER_MESSAGE_ARG={$USER_MESSAGE_ARG}" const val STATISTICS_ROUTE = STATISTICS_SCREEN const val TASK_DETAIL_ROUTE = "$TASK_DETAIL_SCREEN/{$TASK_ID_ARG}" const val ADD_EDIT_TASK_ROUTE = "$ADD_EDIT_TASK_SCREEN/{$TITLE_ARG}?$TASK_ID_ARG={$TASK_ID_ARG}" } /** * Models the navigation actions in the app. */ class TodoNavigationActions(private val navController: NavHostController) { fun navigateToTasks(userMessage: Int = 0) { val navigatesFromDrawer = userMessage == 0 navController.navigate( TASKS_SCREEN.let { if (userMessage != 0) "$it?$USER_MESSAGE_ARG=$userMessage" else it } ) { popUpTo(navController.graph.findStartDestination().id) { inclusive = !navigatesFromDrawer saveState = navigatesFromDrawer } launchSingleTop = true restoreState = navigatesFromDrawer } } fun navigateToStatistics() { navController.navigate(TodoDestinations.STATISTICS_ROUTE) { // Pop up to the start destination of the graph to // avoid building up a large stack of destinations // on the back stack as users select items popUpTo(navController.graph.findStartDestination().id) { saveState = true } // Avoid multiple copies of the same destination when // reselecting the same item launchSingleTop = true // Restore state when reselecting a previously selected item restoreState = true } } fun navigateToTaskDetail(taskId: String) { navController.navigate("$TASK_DETAIL_SCREEN/$taskId") } fun navigateToAddEditTask(title: Int, taskId: String?) { navController.navigate( "$ADD_EDIT_TASK_SCREEN/$title".let { if (taskId != null) "$it?$TASK_ID_ARG=$taskId" else it } ) } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoTheme.kt ================================================ package com.example.android.architecture.blueprints.todoapp import androidx.compose.material3.MaterialTheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color @Composable fun TodoTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = lightColorScheme( primary = Color(0xFF263238), secondary = Color(0xFF2E7D32), tertiary = Color(0xFFCCCCCC), ) ) { content() } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreen.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ @file:OptIn(ExperimentalMaterial3Api::class) package com.example.android.architecture.blueprints.todoapp.addedittask import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.util.AddEditTaskTopAppBar @Composable fun AddEditTaskScreen( @StringRes topBarTitle: Int, onTaskUpdate: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: AddEditTaskViewModel = hiltViewModel(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } ) { Scaffold( modifier = modifier.fillMaxSize(), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { AddEditTaskTopAppBar(topBarTitle, onBack) }, floatingActionButton = { SmallFloatingActionButton(onClick = viewModel::saveTask) { Icon(Icons.Filled.Done, stringResource(id = R.string.cd_save_task)) } } ) { paddingValues -> val uiState by viewModel.uiState.collectAsStateWithLifecycle() AddEditTaskContent( loading = uiState.isLoading, title = uiState.title, description = uiState.description, onTitleChanged = viewModel::updateTitle, onDescriptionChanged = viewModel::updateDescription, modifier = Modifier.padding(paddingValues) ) // Check if the task is saved and call onTaskUpdate event LaunchedEffect(uiState.isTaskSaved) { if (uiState.isTaskSaved) { onTaskUpdate() } } // Check for user messages to display on the screen uiState.userMessage?.let { userMessage -> val snackbarText = stringResource(userMessage) LaunchedEffect(snackbarHostState, viewModel, userMessage, snackbarText) { snackbarHostState.showSnackbar(snackbarText) viewModel.snackbarMessageShown() } } } } @Composable private fun AddEditTaskContent( loading: Boolean, title: String, description: String, onTitleChanged: (String) -> Unit, onDescriptionChanged: (String) -> Unit, modifier: Modifier = Modifier ) { var isRefreshing by remember { mutableStateOf(false) } val refreshingState = rememberPullToRefreshState() if (loading) { PullToRefreshBox( isRefreshing = isRefreshing, state = refreshingState, onRefresh = { /* DO NOTHING */ }, content = { } ) } else { Column( modifier .fillMaxWidth() .padding(all = dimensionResource(id = R.dimen.horizontal_margin)) .verticalScroll(rememberScrollState()) ) { val textFieldColors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, cursorColor = MaterialTheme.colorScheme.onSecondary ) OutlinedTextField( value = title, modifier = Modifier.fillMaxWidth(), onValueChange = onTitleChanged, placeholder = { Text( text = stringResource(id = R.string.title_hint), style = MaterialTheme.typography.headlineSmall ) }, textStyle = MaterialTheme.typography.headlineSmall .copy(fontWeight = FontWeight.Bold), maxLines = 1, colors = textFieldColors ) OutlinedTextField( value = description, onValueChange = onDescriptionChanged, placeholder = { Text(stringResource(id = R.string.description_hint)) }, modifier = Modifier .height(350.dp) .fillMaxWidth(), colors = textFieldColors ) } } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModel.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.addedittask import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject /** * UiState for the Add/Edit screen */ data class AddEditTaskUiState( val title: String = "", val description: String = "", val isTaskCompleted: Boolean = false, val isLoading: Boolean = false, val userMessage: Int? = null, val isTaskSaved: Boolean = false ) /** * ViewModel for the Add/Edit screen. */ @HiltViewModel class AddEditTaskViewModel @Inject constructor( private val taskRepository: TaskRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { private val taskId: String? = savedStateHandle[TodoDestinationsArgs.TASK_ID_ARG] // A MutableStateFlow needs to be created in this ViewModel. The source of truth of the current // editable Task is the ViewModel, we need to mutate the UI state directly in methods such as // `updateTitle` or `updateDescription` private val _uiState = MutableStateFlow(AddEditTaskUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { if (taskId != null) { loadTask(taskId) } } // Called when clicking on fab. fun saveTask() { if (uiState.value.title.isEmpty() || uiState.value.description.isEmpty()) { _uiState.update { it.copy(userMessage = R.string.empty_task_message) } return } if (taskId == null) { createNewTask() } else { updateTask() } } fun snackbarMessageShown() { _uiState.update { it.copy(userMessage = null) } } fun updateTitle(newTitle: String) { _uiState.update { it.copy(title = newTitle) } } fun updateDescription(newDescription: String) { _uiState.update { it.copy(description = newDescription) } } private fun createNewTask() = viewModelScope.launch { taskRepository.createTask(uiState.value.title, uiState.value.description) _uiState.update { it.copy(isTaskSaved = true) } } private fun updateTask() { if (taskId == null) { throw RuntimeException("updateTask() was called but task is new.") } viewModelScope.launch { taskRepository.updateTask( taskId, title = uiState.value.title, description = uiState.value.description, ) _uiState.update { it.copy(isTaskSaved = true) } } } private fun loadTask(taskId: String) { _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { taskRepository.getTask(taskId).let { task -> if (task != null) { _uiState.update { it.copy( title = task.title, description = task.description, isTaskCompleted = task.isCompleted, isLoading = false ) } } else { _uiState.update { it.copy(isLoading = false) } } } } } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data import com.example.android.architecture.blueprints.todoapp.data.source.local.TaskDao import com.example.android.architecture.blueprints.todoapp.data.source.network.NetworkDataSource import com.example.android.architecture.blueprints.todoapp.di.ApplicationScope import com.example.android.architecture.blueprints.todoapp.di.DefaultDispatcher import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID import javax.inject.Inject import javax.inject.Singleton /** * Default implementation of [TaskRepository]. Single entry point for managing tasks' data. * * @param networkDataSource - The network data source * @param localDataSource - The local data source * @param dispatcher - The dispatcher to be used for long running or complex operations, such as ID * generation or mapping many models. * @param scope - The coroutine scope used for deferred jobs where the result isn't important, such * as sending data to the network. */ @Singleton class DefaultTaskRepository @Inject constructor( private val networkDataSource: NetworkDataSource, private val localDataSource: TaskDao, @DefaultDispatcher private val dispatcher: CoroutineDispatcher, @ApplicationScope private val scope: CoroutineScope, ) : TaskRepository { override suspend fun createTask(title: String, description: String): String { // ID creation might be a complex operation so it's executed using the supplied // coroutine dispatcher val taskId = withContext(dispatcher) { UUID.randomUUID().toString() } val task = Task( title = title, description = description, id = taskId, ) localDataSource.upsert(task.toLocal()) saveTasksToNetwork() return taskId } override suspend fun updateTask(taskId: String, title: String, description: String) { val task = getTask(taskId)?.copy( title = title, description = description ) ?: throw Exception("Task (id $taskId) not found") localDataSource.upsert(task.toLocal()) saveTasksToNetwork() } override suspend fun getTasks(forceUpdate: Boolean): List { if (forceUpdate) { refresh() } return withContext(dispatcher) { localDataSource.getAll().toExternal() } } override fun getTasksStream(): Flow> { return localDataSource.observeAll().map { tasks -> withContext(dispatcher) { tasks.toExternal() } } } override suspend fun refreshTask(taskId: String) { refresh() } override fun getTaskStream(taskId: String): Flow { return localDataSource.observeById(taskId).map { it.toExternal() } } /** * Get a Task with the given ID. Will return null if the task cannot be found. * * @param taskId - The ID of the task * @param forceUpdate - true if the task should be updated from the network data source first. */ override suspend fun getTask(taskId: String, forceUpdate: Boolean): Task? { if (forceUpdate) { refresh() } return localDataSource.getById(taskId)?.toExternal() } override suspend fun completeTask(taskId: String) { localDataSource.updateCompleted(taskId = taskId, completed = true) saveTasksToNetwork() } override suspend fun activateTask(taskId: String) { localDataSource.updateCompleted(taskId = taskId, completed = false) saveTasksToNetwork() } override suspend fun clearCompletedTasks() { localDataSource.deleteCompleted() saveTasksToNetwork() } override suspend fun deleteAllTasks() { localDataSource.deleteAll() saveTasksToNetwork() } override suspend fun deleteTask(taskId: String) { localDataSource.deleteById(taskId) saveTasksToNetwork() } /** * The following methods load tasks from (refresh), and save tasks to, the network. * * Real apps may want to do a proper sync, rather than the "one-way sync everything" approach * below. See https://developer.android.com/topic/architecture/data-layer/offline-first * for more efficient and robust synchronisation strategies. * * Note that the refresh operation is a suspend function (forces callers to wait) and the save * operation is not. It returns immediately so callers don't have to wait. */ /** * Delete everything in the local data source and replace it with everything from the network * data source. * * `withContext` is used here in case the bulk `toLocal` mapping operation is complex. */ override suspend fun refresh() { withContext(dispatcher) { val remoteTasks = networkDataSource.loadTasks() localDataSource.deleteAll() localDataSource.upsertAll(remoteTasks.toLocal()) } } /** * Send the tasks from the local data source to the network data source * * Returns immediately after launching the job. Real apps may want to suspend here until the * operation is complete or (better) use WorkManager to schedule this work. Both approaches * should provide a mechanism for failures to be communicated back to the user so that * they are aware that their data isn't being backed up. */ private fun saveTasksToNetwork() { scope.launch { try { val localTasks = localDataSource.getAll() val networkTasks = withContext(dispatcher) { localTasks.toNetwork() } networkDataSource.saveTasks(networkTasks) } catch (e: Exception) { // In a real app you'd handle the exception e.g. by exposing a `networkStatus` flow // to an app level UI state holder which could then display a Toast message. } } } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/ModelMappingExt.kt ================================================ /* * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data import com.example.android.architecture.blueprints.todoapp.data.source.local.LocalTask import com.example.android.architecture.blueprints.todoapp.data.source.network.NetworkTask import com.example.android.architecture.blueprints.todoapp.data.source.network.TaskStatus /** * Data model mapping extension functions. There are three model types: * * - Task: External model exposed to other layers in the architecture. * Obtained using `toExternal`. * * - NetworkTask: Internal model used to represent a task from the network. Obtained using * `toNetwork`. * * - LocalTask: Internal model used to represent a task stored locally in a database. Obtained * using `toLocal`. * */ // External to local fun Task.toLocal() = LocalTask( id = id, title = title, description = description, isCompleted = isCompleted, ) fun List.toLocal() = map(Task::toLocal) // Local to External fun LocalTask.toExternal() = Task( id = id, title = title, description = description, isCompleted = isCompleted, ) // Note: JvmName is used to provide a unique name for each extension function with the same name. // Without this, type erasure will cause compiler errors because these methods will have the same // signature on the JVM. @JvmName("localToExternal") fun List.toExternal() = map(LocalTask::toExternal) // Network to Local fun NetworkTask.toLocal() = LocalTask( id = id, title = title, description = shortDescription, isCompleted = (status == TaskStatus.COMPLETE), ) @JvmName("networkToLocal") fun List.toLocal() = map(NetworkTask::toLocal) // Local to Network fun LocalTask.toNetwork() = NetworkTask( id = id, title = title, shortDescription = description, status = if (isCompleted) { TaskStatus.COMPLETE } else { TaskStatus.ACTIVE } ) fun List.toNetwork() = map(LocalTask::toNetwork) // External to Network fun Task.toNetwork() = toLocal().toNetwork() @JvmName("externalToNetwork") fun List.toNetwork() = map(Task::toNetwork) // Network to External fun NetworkTask.toExternal() = toLocal().toExternal() @JvmName("networkToExternal") fun List.toExternal() = map(NetworkTask::toExternal) ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/Task.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data /** * Immutable model class for a Task. * * @param title title of the task * @param description description of the task * @param isCompleted whether or not this task is completed * @param id id of the task * * TODO: The constructor of this class should be `internal` but it is used in previews and tests * so that's not possible until those previews/tests are refactored. */ data class Task( val title: String = "", val description: String = "", val isCompleted: Boolean = false, val id: String, ) { val titleForList: String get() = if (title.isNotEmpty()) title else description val isActive get() = !isCompleted val isEmpty get() = title.isEmpty() || description.isEmpty() } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data import kotlinx.coroutines.flow.Flow /** * Interface to the data layer. */ interface TaskRepository { fun getTasksStream(): Flow> suspend fun getTasks(forceUpdate: Boolean = false): List suspend fun refresh() fun getTaskStream(taskId: String): Flow suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Task? suspend fun refreshTask(taskId: String) suspend fun createTask(title: String, description: String): String suspend fun updateTask(taskId: String, title: String, description: String) suspend fun completeTask(taskId: String) suspend fun activateTask(taskId: String) suspend fun clearCompletedTasks() suspend fun deleteAllTasks() suspend fun deleteTask(taskId: String) } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/LocalTask.kt ================================================ /* * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.local import androidx.room.Entity import androidx.room.PrimaryKey /** * Internal model used to represent a task stored locally in a Room database. This is used inside * the data layer only. * * See ModelMappingExt.kt for mapping functions used to convert this model to other * models. */ @Entity( tableName = "task" ) data class LocalTask( @PrimaryKey val id: String, var title: String, var description: String, var isCompleted: Boolean, ) ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDao.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.local import androidx.room.Dao import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow /** * Data Access Object for the task table. */ @Dao interface TaskDao { /** * Observes list of tasks. * * @return all tasks. */ @Query("SELECT * FROM task") fun observeAll(): Flow> /** * Observes a single task. * * @param taskId the task id. * @return the task with taskId. */ @Query("SELECT * FROM task WHERE id = :taskId") fun observeById(taskId: String): Flow /** * Select all tasks from the tasks table. * * @return all tasks. */ @Query("SELECT * FROM task") suspend fun getAll(): List /** * Select a task by id. * * @param taskId the task id. * @return the task with taskId. */ @Query("SELECT * FROM task WHERE id = :taskId") suspend fun getById(taskId: String): LocalTask? /** * Insert or update a task in the database. If a task already exists, replace it. * * @param task the task to be inserted or updated. */ @Upsert suspend fun upsert(task: LocalTask) /** * Insert or update tasks in the database. If a task already exists, replace it. * * @param tasks the tasks to be inserted or updated. */ @Upsert suspend fun upsertAll(tasks: List) /** * Update the complete status of a task * * @param taskId id of the task * @param completed status to be updated */ @Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId") suspend fun updateCompleted(taskId: String, completed: Boolean) /** * Delete a task by id. * * @return the number of tasks deleted. This should always be 1. */ @Query("DELETE FROM task WHERE id = :taskId") suspend fun deleteById(taskId: String): Int /** * Delete all tasks. */ @Query("DELETE FROM task") suspend fun deleteAll() /** * Delete all completed tasks from the table. * * @return the number of tasks deleted. */ @Query("DELETE FROM task WHERE isCompleted = 1") suspend fun deleteCompleted(): Int } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/ToDoDatabase.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.local import androidx.room.Database import androidx.room.RoomDatabase /** * The Room Database that contains the Task table. * * Note that exportSchema should be true in production databases. */ @Database(entities = [LocalTask::class], version = 1, exportSchema = false) abstract class ToDoDatabase : RoomDatabase() { abstract fun taskDao(): TaskDao } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkDataSource.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.network /** * Main entry point for accessing tasks data from the network. * */ interface NetworkDataSource { suspend fun loadTasks(): List suspend fun saveTasks(tasks: List) } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkTask.kt ================================================ /* * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.network /** * Internal model used to represent a task obtained from the network. This is used inside the data * layer only. * * See ModelMappingExt.kt for mapping functions used to convert this model to other * models. */ data class NetworkTask( val id: String, val title: String, val shortDescription: String, val priority: Int? = null, val status: TaskStatus = TaskStatus.ACTIVE ) enum class TaskStatus { ACTIVE, COMPLETE } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.network import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject class TaskNetworkDataSource @Inject constructor() : NetworkDataSource { // A mutex is used to ensure that reads and writes are thread-safe. private val accessMutex = Mutex() private var tasks = listOf( NetworkTask( id = "PISA", title = "Build tower in Pisa", shortDescription = "Ground looks good, no foundation work required." ), NetworkTask( id = "TACOMA", title = "Finish bridge in Tacoma", shortDescription = "Found awesome girders at half the cost!" ) ) override suspend fun loadTasks(): List = accessMutex.withLock { delay(SERVICE_LATENCY_IN_MILLIS) return tasks } override suspend fun saveTasks(newTasks: List) = accessMutex.withLock { delay(SERVICE_LATENCY_IN_MILLIS) tasks = newTasks } } private const val SERVICE_LATENCY_IN_MILLIS = 2000L ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.di import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import javax.inject.Qualifier import javax.inject.Singleton @Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class IoDispatcher @Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class DefaultDispatcher @Retention(AnnotationRetention.RUNTIME) @Qualifier annotation class ApplicationScope @Module @InstallIn(SingletonComponent::class) object CoroutinesModule { @Provides @IoDispatcher fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO @Provides @DefaultDispatcher fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default @Provides @Singleton @ApplicationScope fun providesCoroutineScope( @DefaultDispatcher dispatcher: CoroutineDispatcher ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.di import android.content.Context import androidx.room.Room import com.example.android.architecture.blueprints.todoapp.data.DefaultTaskRepository import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import com.example.android.architecture.blueprints.todoapp.data.source.local.TaskDao import com.example.android.architecture.blueprints.todoapp.data.source.local.ToDoDatabase import com.example.android.architecture.blueprints.todoapp.data.source.network.NetworkDataSource import com.example.android.architecture.blueprints.todoapp.data.source.network.TaskNetworkDataSource import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Singleton @Binds abstract fun bindTaskRepository(repository: DefaultTaskRepository): TaskRepository } @Module @InstallIn(SingletonComponent::class) abstract class DataSourceModule { @Singleton @Binds abstract fun bindNetworkDataSource(dataSource: TaskNetworkDataSource): NetworkDataSource } @Module @InstallIn(SingletonComponent::class) object DatabaseModule { @Singleton @Provides fun provideDataBase(@ApplicationContext context: Context): ToDoDatabase { return Room.databaseBuilder( context.applicationContext, ToDoDatabase::class.java, "Tasks.db" ).build() } @Provides fun provideTaskDao(database: ToDoDatabase): TaskDao = database.taskDao() } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreen.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.statistics import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.util.LoadingContent import com.example.android.architecture.blueprints.todoapp.util.StatisticsTopAppBar @Composable fun StatisticsScreen( openDrawer: () -> Unit, modifier: Modifier = Modifier, viewModel: StatisticsViewModel = hiltViewModel(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } ) { Scaffold( modifier = modifier.fillMaxSize(), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { StatisticsTopAppBar(openDrawer) }, ) { paddingValues -> val uiState by viewModel.uiState.collectAsStateWithLifecycle() StatisticsContent( loading = uiState.isLoading, empty = uiState.isEmpty, activeTasksPercent = uiState.activeTasksPercent, completedTasksPercent = uiState.completedTasksPercent, onRefresh = { viewModel.refresh() }, modifier = modifier.padding(paddingValues) ) } } @Composable private fun StatisticsContent( loading: Boolean, empty: Boolean, activeTasksPercent: Float, completedTasksPercent: Float, onRefresh: () -> Unit, modifier: Modifier = Modifier ) { val commonModifier = modifier .fillMaxSize() .padding(all = dimensionResource(id = R.dimen.horizontal_margin)) LoadingContent( loading = loading, empty = empty, onRefresh = onRefresh, modifier = modifier, emptyContent = { Text( text = stringResource(id = R.string.statistics_no_tasks), modifier = commonModifier ) } ) { Column( commonModifier .fillMaxSize() .verticalScroll(rememberScrollState()) ) { if (!loading) { Text(stringResource(id = R.string.statistics_active_tasks, activeTasksPercent)) Text( stringResource( id = R.string.statistics_completed_tasks, completedTasksPercent ) ) } } } } @Preview @Composable fun StatisticsContentPreview() { Surface { StatisticsContent( loading = false, empty = false, activeTasksPercent = 80f, completedTasksPercent = 20f, onRefresh = { } ) } } @Preview @Composable fun StatisticsContentEmptyPreview() { Surface { StatisticsScreen({}) } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtils.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.statistics import com.example.android.architecture.blueprints.todoapp.data.Task /** * Function that does some trivial computation. Used to showcase unit tests. */ internal fun getActiveAndCompletedStats(tasks: List): StatsResult { return if (tasks.isEmpty()) { StatsResult(0f, 0f) } else { val totalTasks = tasks.size val numberOfActiveTasks = tasks.count { it.isActive } StatsResult( activeTasksPercent = 100f * numberOfActiveTasks / tasks.size, completedTasksPercent = 100f * (totalTasks - numberOfActiveTasks) / tasks.size ) } } data class StatsResult(val activeTasksPercent: Float, val completedTasksPercent: Float) ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.statistics import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.data.Task import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import com.example.android.architecture.blueprints.todoapp.util.Async import com.example.android.architecture.blueprints.todoapp.util.WhileUiSubscribed import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject /** * UiState for the statistics screen. */ data class StatisticsUiState( val isEmpty: Boolean = false, val isLoading: Boolean = false, val activeTasksPercent: Float = 0f, val completedTasksPercent: Float = 0f ) /** * ViewModel for the statistics screen. */ @HiltViewModel class StatisticsViewModel @Inject constructor( private val taskRepository: TaskRepository ) : ViewModel() { val uiState: StateFlow = taskRepository.getTasksStream() .map { Async.Success(it) } .catch>> { emit(Async.Error(R.string.loading_tasks_error)) } .map { taskAsync -> produceStatisticsUiState(taskAsync) } .stateIn( scope = viewModelScope, started = WhileUiSubscribed, initialValue = StatisticsUiState(isLoading = true) ) fun refresh() { viewModelScope.launch { taskRepository.refresh() } } private fun produceStatisticsUiState(taskLoad: Async>) = when (taskLoad) { Async.Loading -> { StatisticsUiState(isLoading = true, isEmpty = true) } is Async.Error -> { // TODO: Show error message? StatisticsUiState(isEmpty = true, isLoading = false) } is Async.Success -> { val stats = getActiveAndCompletedStats(taskLoad.data) StatisticsUiState( isEmpty = taskLoad.data.isEmpty(), activeTasksPercent = stats.activeTasksPercent, completedTasksPercent = stats.completedTasksPercent, isLoading = false ) } } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreen.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.taskdetail import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.data.Task import com.example.android.architecture.blueprints.todoapp.util.LoadingContent import com.example.android.architecture.blueprints.todoapp.util.TaskDetailTopAppBar @Composable fun TaskDetailScreen( onEditTask: (String) -> Unit, onBack: () -> Unit, onDeleteTask: () -> Unit, modifier: Modifier = Modifier, viewModel: TaskDetailViewModel = hiltViewModel(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } ) { Scaffold( modifier = modifier.fillMaxSize(), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TaskDetailTopAppBar(onBack = onBack, onDelete = viewModel::deleteTask) }, floatingActionButton = { SmallFloatingActionButton(onClick = { onEditTask(viewModel.taskId) }) { Icon(Icons.Filled.Edit, stringResource(id = R.string.edit_task)) } } ) { paddingValues -> val uiState by viewModel.uiState.collectAsStateWithLifecycle() EditTaskContent( loading = uiState.isLoading, empty = uiState.task == null && !uiState.isLoading, task = uiState.task, onRefresh = viewModel::refresh, onTaskCheck = viewModel::setCompleted, modifier = Modifier.padding(paddingValues) ) // Check for user messages to display on the screen uiState.userMessage?.let { userMessage -> val snackbarText = stringResource(userMessage) LaunchedEffect(snackbarHostState, viewModel, userMessage, snackbarText) { snackbarHostState.showSnackbar(snackbarText) viewModel.snackbarMessageShown() } } // Check if the task is deleted and call onDeleteTask LaunchedEffect(uiState.isTaskDeleted) { if (uiState.isTaskDeleted) { onDeleteTask() } } } } @Composable private fun EditTaskContent( loading: Boolean, empty: Boolean, task: Task?, onTaskCheck: (Boolean) -> Unit, onRefresh: () -> Unit, modifier: Modifier = Modifier ) { val screenPadding = Modifier.padding( horizontal = dimensionResource(id = R.dimen.horizontal_margin), vertical = dimensionResource(id = R.dimen.vertical_margin), ) val commonModifier = modifier .fillMaxWidth() .then(screenPadding) LoadingContent( loading = loading, empty = empty, emptyContent = { Text( text = stringResource(id = R.string.no_data), modifier = commonModifier ) }, onRefresh = onRefresh ) { Column(commonModifier.verticalScroll(rememberScrollState())) { Row( Modifier .fillMaxWidth() .then(screenPadding), ) { if (task != null) { Checkbox(task.isCompleted, onTaskCheck) Column { Text(text = task.title, style = MaterialTheme.typography.headlineSmall) Text(text = task.description, style = MaterialTheme.typography.bodySmall) } } } } } } @Preview @Composable private fun EditTaskContentPreview() { Surface { EditTaskContent( loading = false, empty = false, Task( title = "Title", description = "Description", isCompleted = false, id = "ID" ), onTaskCheck = { }, onRefresh = { } ) } } @Preview @Composable private fun EditTaskContentTaskCompletedPreview() { Surface { EditTaskContent( loading = false, empty = false, Task( title = "Title", description = "Description", isCompleted = false, id = "ID" ), onTaskCheck = { }, onRefresh = { } ) } } @Preview @Composable private fun EditTaskContentEmptyPreview() { Surface { EditTaskContent( loading = false, empty = true, Task( title = "Title", description = "Description", isCompleted = false, id = "ID" ), onTaskCheck = { }, onRefresh = { } ) } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModel.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.taskdetail import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs import com.example.android.architecture.blueprints.todoapp.data.Task import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import com.example.android.architecture.blueprints.todoapp.util.Async import com.example.android.architecture.blueprints.todoapp.util.WhileUiSubscribed import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject /** * UiState for the Details screen. */ data class TaskDetailUiState( val task: Task? = null, val isLoading: Boolean = false, val userMessage: Int? = null, val isTaskDeleted: Boolean = false ) /** * ViewModel for the Details screen. */ @HiltViewModel class TaskDetailViewModel @Inject constructor( private val taskRepository: TaskRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { val taskId: String = savedStateHandle[TodoDestinationsArgs.TASK_ID_ARG]!! private val _userMessage: MutableStateFlow = MutableStateFlow(null) private val _isLoading = MutableStateFlow(false) private val _isTaskDeleted = MutableStateFlow(false) private val _taskAsync = taskRepository.getTaskStream(taskId) .map { handleTask(it) } .catch { emit(Async.Error(R.string.loading_task_error)) } val uiState: StateFlow = combine( _userMessage, _isLoading, _isTaskDeleted, _taskAsync ) { userMessage, isLoading, isTaskDeleted, taskAsync -> when (taskAsync) { Async.Loading -> { TaskDetailUiState(isLoading = true) } is Async.Error -> { TaskDetailUiState( userMessage = taskAsync.errorMessage, isTaskDeleted = isTaskDeleted ) } is Async.Success -> { TaskDetailUiState( task = taskAsync.data, isLoading = isLoading, userMessage = userMessage, isTaskDeleted = isTaskDeleted ) } } } .stateIn( scope = viewModelScope, started = WhileUiSubscribed, initialValue = TaskDetailUiState(isLoading = true) ) fun deleteTask() = viewModelScope.launch { taskRepository.deleteTask(taskId) _isTaskDeleted.value = true } fun setCompleted(completed: Boolean) = viewModelScope.launch { val task = uiState.value.task ?: return@launch if (completed) { taskRepository.completeTask(task.id) showSnackbarMessage(R.string.task_marked_complete) } else { taskRepository.activateTask(task.id) showSnackbarMessage(R.string.task_marked_active) } } fun refresh() { _isLoading.value = true viewModelScope.launch { taskRepository.refreshTask(taskId) _isLoading.value = false } } fun snackbarMessageShown() { _userMessage.value = null } private fun showSnackbarMessage(message: Int) { _userMessage.value = message } private fun handleTask(task: Task?): Async { if (task == null) { return Async.Error(R.string.task_not_found) } return Async.Success(task) } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksFilterType.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.tasks /** * Used with the filter spinner in the tasks list. */ enum class TasksFilterType { /** * Do not filter tasks. */ ALL_TASKS, /** * Filters only the active (not completed yet) tasks. */ ACTIVE_TASKS, /** * Filters only the completed tasks. */ COMPLETED_TASKS } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreen.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.tasks import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoTheme import com.example.android.architecture.blueprints.todoapp.data.Task import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.ACTIVE_TASKS import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.ALL_TASKS import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.COMPLETED_TASKS import com.example.android.architecture.blueprints.todoapp.util.LoadingContent import com.example.android.architecture.blueprints.todoapp.util.TasksTopAppBar @Composable fun TasksScreen( @StringRes userMessage: Int, onAddTask: () -> Unit, onTaskClick: (Task) -> Unit, onUserMessageDisplayed: () -> Unit, openDrawer: () -> Unit, modifier: Modifier = Modifier, viewModel: TasksViewModel = hiltViewModel(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } ) { Scaffold( modifier = modifier.fillMaxSize(), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TasksTopAppBar( openDrawer = openDrawer, onFilterAllTasks = { viewModel.setFiltering(ALL_TASKS) }, onFilterActiveTasks = { viewModel.setFiltering(ACTIVE_TASKS) }, onFilterCompletedTasks = { viewModel.setFiltering(COMPLETED_TASKS) }, onClearCompletedTasks = { viewModel.clearCompletedTasks() }, onRefresh = { viewModel.refresh() } ) }, floatingActionButton = { SmallFloatingActionButton(onClick = onAddTask) { Icon(Icons.Filled.Add, stringResource(id = R.string.add_task)) } } ) { paddingValues -> val uiState by viewModel.uiState.collectAsStateWithLifecycle() TasksContent( loading = uiState.isLoading, tasks = uiState.items, currentFilteringLabel = uiState.filteringUiInfo.currentFilteringLabel, noTasksLabel = uiState.filteringUiInfo.noTasksLabel, noTasksIconRes = uiState.filteringUiInfo.noTaskIconRes, onRefresh = viewModel::refresh, onTaskClick = onTaskClick, onTaskCheckedChange = viewModel::completeTask, modifier = Modifier.padding(paddingValues) ) // Check for user messages to display on the screen uiState.userMessage?.let { message -> val snackbarText = stringResource(message) LaunchedEffect(snackbarHostState, viewModel, message, snackbarText) { snackbarHostState.showSnackbar(snackbarText) viewModel.snackbarMessageShown() } } // Check if there's a userMessage to show to the user val currentOnUserMessageDisplayed by rememberUpdatedState(onUserMessageDisplayed) LaunchedEffect(userMessage) { if (userMessage != 0) { viewModel.showEditResultMessage(userMessage) currentOnUserMessageDisplayed() } } } } @Composable private fun TasksContent( loading: Boolean, tasks: List, @StringRes currentFilteringLabel: Int, @StringRes noTasksLabel: Int, @DrawableRes noTasksIconRes: Int, onRefresh: () -> Unit, onTaskClick: (Task) -> Unit, onTaskCheckedChange: (Task, Boolean) -> Unit, modifier: Modifier = Modifier ) { LoadingContent( loading = loading, empty = tasks.isEmpty() && !loading, emptyContent = { TasksEmptyContent(noTasksLabel, noTasksIconRes, modifier) }, onRefresh = onRefresh ) { Column( modifier = modifier .fillMaxSize() .padding(horizontal = dimensionResource(id = R.dimen.horizontal_margin)) ) { Text( text = stringResource(currentFilteringLabel), modifier = Modifier.padding( horizontal = dimensionResource(id = R.dimen.list_item_padding), vertical = dimensionResource(id = R.dimen.vertical_margin) ), style = MaterialTheme.typography.headlineSmall ) LazyColumn { items(tasks) { task -> TaskItem( task = task, onTaskClick = onTaskClick, onCheckedChange = { onTaskCheckedChange(task, it) } ) } } } } } @Composable private fun TaskItem( task: Task, onCheckedChange: (Boolean) -> Unit, onTaskClick: (Task) -> Unit ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding( horizontal = dimensionResource(id = R.dimen.horizontal_margin), vertical = dimensionResource(id = R.dimen.list_item_padding), ) .clickable { onTaskClick(task) } ) { Checkbox( checked = task.isCompleted, onCheckedChange = onCheckedChange ) Text( text = task.titleForList, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding( start = dimensionResource(id = R.dimen.horizontal_margin) ), textDecoration = if (task.isCompleted) { TextDecoration.LineThrough } else { null } ) } } @Composable private fun TasksEmptyContent( @StringRes noTasksLabel: Int, @DrawableRes noTasksIconRes: Int, modifier: Modifier = Modifier ) { Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(id = noTasksIconRes), contentDescription = stringResource(R.string.no_tasks_image_content_description), modifier = Modifier.size(96.dp) ) Text(stringResource(id = noTasksLabel)) } } @Preview @Composable private fun TasksContentPreview() { MaterialTheme { Surface { TasksContent( loading = false, tasks = listOf( Task( title = "Title 1", description = "Description 1", isCompleted = false, id = "ID 1" ), Task( title = "Title 2", description = "Description 2", isCompleted = true, id = "ID 2" ), Task( title = "Title 3", description = "Description 3", isCompleted = true, id = "ID 3" ), Task( title = "Title 4", description = "Description 4", isCompleted = false, id = "ID 4" ), Task( title = "Title 5", description = "Description 5", isCompleted = true, id = "ID 5" ), ), currentFilteringLabel = R.string.label_all, noTasksLabel = R.string.no_tasks_all, noTasksIconRes = R.drawable.logo_no_fill, onRefresh = { }, onTaskClick = { }, onTaskCheckedChange = { _, _ -> }, ) } } } @Preview @Composable private fun TasksContentEmptyPreview() { MaterialTheme { Surface { TasksContent( loading = false, tasks = emptyList(), currentFilteringLabel = R.string.label_all, noTasksLabel = R.string.no_tasks_all, noTasksIconRes = R.drawable.logo_no_fill, onRefresh = { }, onTaskClick = { }, onTaskCheckedChange = { _, _ -> }, ) } } } @Preview @Composable private fun TasksEmptyContentPreview() { TodoTheme { Surface { TasksEmptyContent( noTasksLabel = R.string.no_tasks_all, noTasksIconRes = R.drawable.logo_no_fill ) } } } @Preview @Composable private fun TaskItemPreview() { MaterialTheme { Surface { TaskItem( task = Task( title = "Title", description = "Description", id = "ID" ), onTaskClick = { }, onCheckedChange = { } ) } } } @Preview @Composable private fun TaskItemCompletedPreview() { MaterialTheme { Surface { TaskItem( task = Task( title = "Title", description = "Description", isCompleted = true, id = "ID" ), onTaskClick = { }, onCheckedChange = { } ) } } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.tasks import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.android.architecture.blueprints.todoapp.ADD_EDIT_RESULT_OK import com.example.android.architecture.blueprints.todoapp.DELETE_RESULT_OK import com.example.android.architecture.blueprints.todoapp.EDIT_RESULT_OK import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.data.Task import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.ACTIVE_TASKS import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.ALL_TASKS import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.COMPLETED_TASKS import com.example.android.architecture.blueprints.todoapp.util.Async import com.example.android.architecture.blueprints.todoapp.util.WhileUiSubscribed import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject /** * UiState for the task list screen. */ data class TasksUiState( val items: List = emptyList(), val isLoading: Boolean = false, val filteringUiInfo: FilteringUiInfo = FilteringUiInfo(), val userMessage: Int? = null ) /** * ViewModel for the task list screen. */ @HiltViewModel class TasksViewModel @Inject constructor( private val taskRepository: TaskRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel() { private val _savedFilterType = savedStateHandle.getStateFlow(TASKS_FILTER_SAVED_STATE_KEY, ALL_TASKS) private val _filterUiInfo = _savedFilterType.map { getFilterUiInfo(it) }.distinctUntilChanged() private val _userMessage: MutableStateFlow = MutableStateFlow(null) private val _isLoading = MutableStateFlow(false) private val _filteredTasksAsync = combine(taskRepository.getTasksStream(), _savedFilterType) { tasks, type -> filterTasks(tasks, type) } .map { Async.Success(it) } .catch>> { emit(Async.Error(R.string.loading_tasks_error)) } val uiState: StateFlow = combine( _filterUiInfo, _isLoading, _userMessage, _filteredTasksAsync ) { filterUiInfo, isLoading, userMessage, tasksAsync -> when (tasksAsync) { Async.Loading -> { TasksUiState(isLoading = true) } is Async.Error -> { TasksUiState(userMessage = tasksAsync.errorMessage) } is Async.Success -> { TasksUiState( items = tasksAsync.data, filteringUiInfo = filterUiInfo, isLoading = isLoading, userMessage = userMessage ) } } } .stateIn( scope = viewModelScope, started = WhileUiSubscribed, initialValue = TasksUiState(isLoading = true) ) fun setFiltering(requestType: TasksFilterType) { savedStateHandle[TASKS_FILTER_SAVED_STATE_KEY] = requestType } fun clearCompletedTasks() { viewModelScope.launch { taskRepository.clearCompletedTasks() showSnackbarMessage(R.string.completed_tasks_cleared) refresh() } } fun completeTask(task: Task, completed: Boolean) = viewModelScope.launch { if (completed) { taskRepository.completeTask(task.id) showSnackbarMessage(R.string.task_marked_complete) } else { taskRepository.activateTask(task.id) showSnackbarMessage(R.string.task_marked_active) } } fun showEditResultMessage(result: Int) { when (result) { EDIT_RESULT_OK -> showSnackbarMessage(R.string.successfully_saved_task_message) ADD_EDIT_RESULT_OK -> showSnackbarMessage(R.string.successfully_added_task_message) DELETE_RESULT_OK -> showSnackbarMessage(R.string.successfully_deleted_task_message) } } fun snackbarMessageShown() { _userMessage.value = null } private fun showSnackbarMessage(message: Int) { _userMessage.value = message } fun refresh() { _isLoading.value = true viewModelScope.launch { taskRepository.refresh() _isLoading.value = false } } private fun filterTasks(tasks: List, filteringType: TasksFilterType): List { val tasksToShow = ArrayList() // We filter the tasks based on the requestType for (task in tasks) { when (filteringType) { ALL_TASKS -> tasksToShow.add(task) ACTIVE_TASKS -> if (task.isActive) { tasksToShow.add(task) } COMPLETED_TASKS -> if (task.isCompleted) { tasksToShow.add(task) } } } return tasksToShow } private fun getFilterUiInfo(requestType: TasksFilterType): FilteringUiInfo = when (requestType) { ALL_TASKS -> { FilteringUiInfo( R.string.label_all, R.string.no_tasks_all, R.drawable.logo_no_fill ) } ACTIVE_TASKS -> { FilteringUiInfo( R.string.label_active, R.string.no_tasks_active, R.drawable.ic_check_circle_96dp ) } COMPLETED_TASKS -> { FilteringUiInfo( R.string.label_completed, R.string.no_tasks_completed, R.drawable.ic_verified_user_96dp ) } } } // Used to save the current filtering in SavedStateHandle. const val TASKS_FILTER_SAVED_STATE_KEY = "TASKS_FILTER_SAVED_STATE_KEY" data class FilteringUiInfo( val currentFilteringLabel: Int = R.string.label_all, val noTasksLabel: Int = R.string.no_tasks_all, val noTaskIconRes: Int = R.drawable.logo_no_fill, ) ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/Async.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.util /** * A generic class that holds a loading signal or the result of an async operation. */ sealed class Async { object Loading : Async() data class Error(val errorMessage: Int) : Async() data class Success(val data: T) : Async() } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/ComposeUtils.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.util import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState val primaryDarkColor: Color = Color(0xFF263238) /** * Display an initial empty state or swipe to refresh content. * * @param loading (state) when true, display a loading spinner over [content] * @param empty (state) when true, display [emptyContent] * @param emptyContent (slot) the content to display for the empty state * @param onRefresh (event) event to request refresh * @param modifier the modifier to apply to this layout. * @param content (slot) the main content to show */ @Composable fun LoadingContent( loading: Boolean, empty: Boolean, emptyContent: @Composable () -> Unit, onRefresh: () -> Unit, modifier: Modifier = Modifier, content: @Composable () -> Unit ) { if (empty) { emptyContent() } else { SwipeRefresh( state = rememberSwipeRefreshState(loading), onRefresh = onRefresh, modifier = modifier, content = content, ) } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/CoroutinesUtils.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.util import kotlinx.coroutines.flow.SharingStarted private const val StopTimeoutMillis: Long = 5000 /** * A [SharingStarted] meant to be used with a [StateFlow] to expose data to the UI. * * When the UI stops observing, upstream flows stay active for some time to allow the system to * come back from a short-lived configuration change (such as rotations). If the UI stops * observing for longer, the cache is kept but the upstream flows are stopped. When the UI comes * back, the latest value is replayed and the upstream flows are executed again. This is done to * save resources when the app is in the background but let users switch between apps quickly. */ val WhileUiSubscribed: SharingStarted = SharingStarted.WhileSubscribed(StopTimeoutMillis) ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/SimpleCountingIdlingResource.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.util import androidx.test.espresso.IdlingResource import java.util.concurrent.atomic.AtomicInteger /** * An simple counter implementation of [IdlingResource] that determines idleness by * maintaining an internal counter. When the counter is 0 - it is considered to be idle, when it is * non-zero it is not idle. This is very similar to the way a [java.util.concurrent.Semaphore] * behaves. * * * This class can then be used to wrap up operations that while in progress should block tests from * accessing the UI. */ class SimpleCountingIdlingResource(private val resourceName: String) : IdlingResource { private val counter = AtomicInteger(0) // written from main thread, read from any thread. @Volatile private var resourceCallback: IdlingResource.ResourceCallback? = null override fun getName() = resourceName override fun isIdleNow() = counter.get() == 0 override fun registerIdleTransitionCallback(resourceCallback: IdlingResource.ResourceCallback) { this.resourceCallback = resourceCallback } /** * Increments the count of in-flight transactions to the resource being monitored. */ fun increment() { counter.getAndIncrement() } /** * Decrements the count of in-flight transactions to the resource being monitored. * If this operation results in the counter falling below 0 - an exception is raised. * * @throws IllegalStateException if the counter is below 0. */ fun decrement() { val counterVal = counter.decrementAndGet() if (counterVal == 0) { // we've gone from non-zero to zero. That means we're idle now! Tell espresso. resourceCallback?.onTransitionToIdle() } else if (counterVal < 0) { throw IllegalStateException("Counter has been corrupted!") } } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TodoDrawer.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.util import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.DrawerState import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoDestinations import com.example.android.architecture.blueprints.todoapp.TodoNavigationActions import com.example.android.architecture.blueprints.todoapp.TodoTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Composable fun AppModalDrawer( drawerState: DrawerState, currentRoute: String, navigationActions: TodoNavigationActions, coroutineScope: CoroutineScope = rememberCoroutineScope(), content: @Composable () -> Unit ) { ModalNavigationDrawer( drawerState = drawerState, drawerContent = { AppDrawer( currentRoute = currentRoute, navigateToTasks = { navigationActions.navigateToTasks() }, navigateToStatistics = { navigationActions.navigateToStatistics() }, closeDrawer = { coroutineScope.launch { drawerState.close() } } ) } ) { content() } } @Composable private fun AppDrawer( currentRoute: String, navigateToTasks: () -> Unit, navigateToStatistics: () -> Unit, closeDrawer: () -> Unit, modifier: Modifier = Modifier ) { Surface(color = MaterialTheme.colorScheme.background) { Column(modifier = modifier.fillMaxSize()) { DrawerHeader() DrawerButton( painter = painterResource(id = R.drawable.ic_list), label = stringResource(id = R.string.list_title), isSelected = currentRoute == TodoDestinations.TASKS_ROUTE, action = { navigateToTasks() closeDrawer() } ) DrawerButton( painter = painterResource(id = R.drawable.ic_statistics), label = stringResource(id = R.string.statistics_title), isSelected = currentRoute == TodoDestinations.STATISTICS_ROUTE, action = { navigateToStatistics() closeDrawer() } ) } } } @Composable private fun DrawerHeader( modifier: Modifier = Modifier ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = modifier .fillMaxWidth() .background(primaryDarkColor) .height(dimensionResource(id = R.dimen.header_height)) .padding(dimensionResource(id = R.dimen.header_padding)) ) { Image( painter = painterResource(id = R.drawable.logo_no_fill), contentDescription = stringResource(id = R.string.tasks_header_image_content_description), modifier = Modifier.width(dimensionResource(id = R.dimen.header_image_width)) ) Text( text = stringResource(id = R.string.navigation_view_header_title), color = MaterialTheme.colorScheme.surface ) } } @Composable private fun DrawerButton( painter: Painter, label: String, isSelected: Boolean, action: () -> Unit, modifier: Modifier = Modifier ) { val tintColor = if (isSelected) { MaterialTheme.colorScheme.secondary } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) } TextButton( onClick = action, modifier = modifier .fillMaxWidth() .padding(horizontal = dimensionResource(id = R.dimen.horizontal_margin)) ) { Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { Icon( painter = painter, contentDescription = null, // decorative tint = tintColor ) Spacer(Modifier.width(16.dp)) Text( text = label, style = MaterialTheme.typography.bodySmall, color = tintColor ) } } } @Preview("Drawer contents") @Composable fun PreviewAppDrawer() { TodoTheme { Surface { AppDrawer( currentRoute = TodoDestinations.TASKS_ROUTE, navigateToTasks = {}, navigateToStatistics = {}, closeDrawer = {} ) } } } ================================================ FILE: app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TopAppBars.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ @file:OptIn(ExperimentalMaterial3Api::class) package com.example.android.architecture.blueprints.todoapp.util import androidx.annotation.StringRes import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoTheme @Composable fun TasksTopAppBar( openDrawer: () -> Unit, onFilterAllTasks: () -> Unit, onFilterActiveTasks: () -> Unit, onFilterCompletedTasks: () -> Unit, onClearCompletedTasks: () -> Unit, onRefresh: () -> Unit ) { TopAppBar( title = { Text(text = stringResource(id = R.string.app_name)) }, navigationIcon = { IconButton(onClick = openDrawer) { Icon(Icons.Filled.Menu, stringResource(id = R.string.open_drawer)) } }, actions = { FilterTasksMenu(onFilterAllTasks, onFilterActiveTasks, onFilterCompletedTasks) MoreTasksMenu(onClearCompletedTasks, onRefresh) }, modifier = Modifier.fillMaxWidth() ) } @Composable private fun FilterTasksMenu( onFilterAllTasks: () -> Unit, onFilterActiveTasks: () -> Unit, onFilterCompletedTasks: () -> Unit ) { TopAppBarDropdownMenu( iconContent = { Icon( painterResource(id = R.drawable.ic_filter_list), stringResource(id = R.string.menu_filter) ) } ) { closeMenu -> DropdownMenuItem(onClick = { onFilterAllTasks(); closeMenu() }, text = { Text(text = stringResource(id = R.string.nav_all)) } ) DropdownMenuItem(onClick = { onFilterActiveTasks(); closeMenu() }, text = { Text(text = stringResource(id = R.string.nav_active)) } ) DropdownMenuItem(onClick = { onFilterCompletedTasks(); closeMenu() }, text = { Text(text = stringResource(id = R.string.nav_completed)) } ) } } @Composable private fun MoreTasksMenu( onClearCompletedTasks: () -> Unit, onRefresh: () -> Unit ) { TopAppBarDropdownMenu( iconContent = { Icon(Icons.Filled.MoreVert, stringResource(id = R.string.menu_more)) } ) { closeMenu -> DropdownMenuItem( text = { Text(text = stringResource(id = R.string.menu_clear)) }, onClick = { onClearCompletedTasks(); closeMenu() } ) DropdownMenuItem( text = { Text(text = stringResource(id = R.string.refresh)) }, onClick = { onRefresh(); closeMenu() } ) } } @Composable private fun TopAppBarDropdownMenu( iconContent: @Composable () -> Unit, content: @Composable ColumnScope.(() -> Unit) -> Unit ) { var expanded by remember { mutableStateOf(false) } Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) { IconButton(onClick = { expanded = !expanded }) { iconContent() } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, modifier = Modifier.wrapContentSize(Alignment.TopEnd) ) { content { expanded = !expanded } } } } @Composable fun StatisticsTopAppBar(openDrawer: () -> Unit) { TopAppBar( title = { Text(text = stringResource(id = R.string.statistics_title)) }, navigationIcon = { IconButton(onClick = openDrawer) { Icon(Icons.Filled.Menu, stringResource(id = R.string.open_drawer)) } }, modifier = Modifier.fillMaxWidth() ) } @Composable fun TaskDetailTopAppBar(onBack: () -> Unit, onDelete: () -> Unit) { TopAppBar( title = { Text(text = stringResource(id = R.string.task_details)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(id = R.string.menu_back)) } }, actions = { IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, stringResource(id = R.string.menu_delete_task)) } }, modifier = Modifier.fillMaxWidth() ) } @Composable fun AddEditTaskTopAppBar(@StringRes title: Int, onBack: () -> Unit) { TopAppBar( title = { Text(text = stringResource(title)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(id = R.string.menu_back)) } }, modifier = Modifier.fillMaxWidth() ) } @Preview @Composable private fun TasksTopAppBarPreview() { TodoTheme { Surface { TasksTopAppBar({}, {}, {}, {}, {}, {}) } } } @Preview @Composable private fun StatisticsTopAppBarPreview() { TodoTheme { Surface { StatisticsTopAppBar { } } } } @Preview @Composable private fun TaskDetailTopAppBarPreview() { TodoTheme { Surface { TaskDetailTopAppBar({ }, { }) } } } @Preview @Composable private fun AddEditTaskTopAppBarPreview() { TodoTheme { Surface { AddEditTaskTopAppBar(R.string.add_task) { } } } } ================================================ FILE: app/src/main/res/drawable/drawer_item_color.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_assignment_turned_in_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check_circle_96dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_done.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_filter_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_menu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_statistics.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_statistics_100dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_statistics_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_verified_user_96dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/list_completed_touch_feedback.xml ================================================ ================================================ FILE: app/src/main/res/drawable/touch_feedback.xml ================================================ ================================================ FILE: app/src/main/res/font/opensans_font.xml ================================================ ================================================ FILE: app/src/main/res/values/attrs.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #FFFFF0 #263238 #2E7D32 #000000 #757575 #CCCCCC #CFD8DC ================================================ FILE: app/src/main/res/values/dimens.xml ================================================ 16dp 16dp 16dp 8dp 192dp 16dp 100dp ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Todo New Task Edit Task Task Details Task marked complete Task marked active Error while loading tasks Error while loading task Task not found Completed tasks cleared Filter Open Drawer Back Clear completed Delete task More Todo Title Enter your task here. Save task Tasks cannot be empty Task saved Task List Statistics You have no tasks. Active tasks: %.1f%% Completed tasks: %.1f%% Error loading statistics. No data LOADING @string/nav_all @string/nav_active @string/nav_completed All Active Completed All Tasks Active Tasks Completed Tasks You have no tasks! You have no active tasks! You have no completed tasks! Refresh Task was deleted Task added Tasks header image No tasks image ================================================ FILE: app/src/main/res/values/styles.xml ================================================ ================================================ FILE: app/src/main/res/values-w820dp/dimens.xml ================================================ 64dp 24dp ================================================ FILE: app/src/test/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModelTest.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.addedittask import androidx.lifecycle.SavedStateHandle import com.example.android.architecture.blueprints.todoapp.MainCoroutineRule import com.example.android.architecture.blueprints.todoapp.R.string import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository import com.example.android.architecture.blueprints.todoapp.data.Task import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.Before import org.junit.Rule import org.junit.Test /** * Unit tests for the implementation of [AddEditTaskViewModel]. */ @ExperimentalCoroutinesApi class AddEditTaskViewModelTest { // Subject under test private lateinit var addEditTaskViewModel: AddEditTaskViewModel // Use a fake repository to be injected into the viewmodel private lateinit var tasksRepository: FakeTaskRepository private val task = Task(title = "Title1", description = "Description1", id = "0") // Set the main coroutines dispatcher for unit testing. @ExperimentalCoroutinesApi @get:Rule val mainCoroutineRule = MainCoroutineRule() @Before fun setupViewModel() { // We initialise the repository with no tasks tasksRepository = FakeTaskRepository().apply { addTasks(task) } } @Test fun saveNewTaskToRepository_showsSuccessMessageUi() { addEditTaskViewModel = AddEditTaskViewModel( tasksRepository, SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) ) val newTitle = "New Task Title" val newDescription = "Some Task Description" addEditTaskViewModel.apply { updateTitle(newTitle) updateDescription(newDescription) } addEditTaskViewModel.saveTask() val newTask = tasksRepository.savedTasks.value.values.first() // Then a task is saved in the repository and the view updated assertThat(newTask.title).isEqualTo(newTitle) assertThat(newTask.description).isEqualTo(newDescription) } @Test fun loadTasks_loading() = runTest { // Set Main dispatcher to not run coroutines eagerly, for just this one test Dispatchers.setMain(StandardTestDispatcher()) addEditTaskViewModel = AddEditTaskViewModel( tasksRepository, SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) ) // Then progress indicator is shown assertThat(addEditTaskViewModel.uiState.value.isLoading).isTrue() // Execute pending coroutines actions advanceUntilIdle() // Then progress indicator is hidden assertThat(addEditTaskViewModel.uiState.value.isLoading).isFalse() } @Test fun loadTasks_taskShown() { addEditTaskViewModel = AddEditTaskViewModel( tasksRepository, SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) ) // Add task to repository tasksRepository.addTasks(task) // Verify a task is loaded val uiState = addEditTaskViewModel.uiState.value assertThat(uiState.title).isEqualTo(task.title) assertThat(uiState.description).isEqualTo(task.description) assertThat(uiState.isLoading).isFalse() } @Test fun saveNewTaskToRepository_emptyTitle_error() { addEditTaskViewModel = AddEditTaskViewModel( tasksRepository, SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) ) saveTaskAndAssertUserMessage("", "Some Task Description") } @Test fun saveNewTaskToRepository_emptyDescription_error() { addEditTaskViewModel = AddEditTaskViewModel( tasksRepository, SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) ) saveTaskAndAssertUserMessage("Title", "") } @Test fun saveNewTaskToRepository_emptyDescriptionEmptyTitle_error() { addEditTaskViewModel = AddEditTaskViewModel( tasksRepository, SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) ) saveTaskAndAssertUserMessage("", "") } private fun saveTaskAndAssertUserMessage(title: String, description: String) { addEditTaskViewModel.apply { updateTitle(title) updateDescription(description) } // When saving an incomplete task addEditTaskViewModel.saveTask() assertThat( addEditTaskViewModel.uiState.value.userMessage ).isEqualTo(string.empty_task_message) } } ================================================ FILE: app/src/test/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepositoryTest.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data import com.example.android.architecture.blueprints.todoapp.data.source.local.FakeTaskDao import com.example.android.architecture.blueprints.todoapp.data.source.network.FakeNetworkDataSource import com.google.common.truth.Truth.assertThat import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test /** * Unit tests for the implementation of the in-memory repository with cache. */ @ExperimentalCoroutinesApi class DefaultTaskRepositoryTest { private val task1 = Task(id = "1", title = "Title1", description = "Description1") private val task2 = Task(id = "2", title = "Title2", description = "Description2") private val task3 = Task(id = "3", title = "Title3", description = "Description3") private val newTaskTitle = "Title new" private val newTaskDescription = "Description new" private val newTask = Task(id = "new", title = newTaskTitle, description = newTaskDescription) private val newTasks = listOf(newTask) private val networkTasks = listOf(task1, task2).toNetwork() private val localTasks = listOf(task3.toLocal()) // Test dependencies private lateinit var networkDataSource: FakeNetworkDataSource private lateinit var localDataSource: FakeTaskDao private var testDispatcher = UnconfinedTestDispatcher() private var testScope = TestScope(testDispatcher) // Class under test private lateinit var taskRepository: DefaultTaskRepository @ExperimentalCoroutinesApi @Before fun createRepository() { networkDataSource = FakeNetworkDataSource(networkTasks.toMutableList()) localDataSource = FakeTaskDao(localTasks) // Get a reference to the class under test taskRepository = DefaultTaskRepository( networkDataSource = networkDataSource, localDataSource = localDataSource, dispatcher = testDispatcher, scope = testScope ) } @ExperimentalCoroutinesApi @Test fun getTasks_emptyRepositoryAndUninitializedCache() = testScope.runTest { networkDataSource.tasks?.clear() localDataSource.deleteAll() assertThat(taskRepository.getTasks().size).isEqualTo(0) } @Test fun getTasks_repositoryCachesAfterFirstApiCall() = testScope.runTest { // Trigger the repository to load tasks from the remote data source val initial = taskRepository.getTasks(forceUpdate = true) // Change the remote data source networkDataSource.tasks = newTasks.toNetwork().toMutableList() // Load the tasks again without forcing a refresh val second = taskRepository.getTasks() // Initial and second should match because we didn't force a refresh (no tasks were loaded // from the remote data source) assertThat(second).isEqualTo(initial) } @Test fun getTasks_requestsAllTasksFromRemoteDataSource() = testScope.runTest { // When tasks are requested from the tasks repository val tasks = taskRepository.getTasks(true) // Then tasks are loaded from the remote data source assertThat(tasks).isEqualTo(networkTasks.toExternal()) } @Test fun saveTask_savesToLocalAndRemote() = testScope.runTest { // When a task is saved to the tasks repository val newTaskId = taskRepository.createTask(newTask.title, newTask.description) // Then the remote and local sources contain the new task assertThat(networkDataSource.tasks?.map { it.id }?.contains(newTaskId)) assertThat(localDataSource.tasks?.map { it.id }?.contains(newTaskId)) } @Test fun getTasks_WithDirtyCache_tasksAreRetrievedFromRemote() = testScope.runTest { // First call returns from REMOTE val tasks = taskRepository.getTasks() // Set a different list of tasks in REMOTE networkDataSource.tasks = newTasks.toNetwork().toMutableList() // But if tasks are cached, subsequent calls load from cache val cachedTasks = taskRepository.getTasks() assertThat(cachedTasks).isEqualTo(tasks) // Now force remote loading val refreshedTasks = taskRepository.getTasks(true) // Tasks must be the recently updated in REMOTE assertThat(refreshedTasks).isEqualTo(newTasks) } @Test(expected = Exception::class) fun getTasks_WithDirtyCache_remoteUnavailable_throwsException() = testScope.runTest { // Make remote data source unavailable networkDataSource.tasks = null // Load tasks forcing remote load taskRepository.getTasks(true) // Exception should be thrown } @Test fun getTasks_WithRemoteDataSourceUnavailable_tasksAreRetrievedFromLocal() = testScope.runTest { // When the remote data source is unavailable networkDataSource.tasks = null // The repository fetches from the local source assertThat(taskRepository.getTasks()).isEqualTo(localTasks.toExternal()) } @Test(expected = Exception::class) fun getTasks_WithBothDataSourcesUnavailable_throwsError() = testScope.runTest { // When both sources are unavailable networkDataSource.tasks = null localDataSource.tasks = null // The repository throws an error taskRepository.getTasks() } @Test fun getTasks_refreshesLocalDataSource() = testScope.runTest { // Forcing an update will fetch tasks from remote val expectedTasks = networkTasks.toExternal() val newTasks = taskRepository.getTasks(true) assertEquals(expectedTasks, newTasks) assertEquals(expectedTasks, localDataSource.tasks?.toExternal()) } @Test fun completeTask_completesTaskToServiceAPIUpdatesCache() = testScope.runTest { // Save a task val newTaskId = taskRepository.createTask(newTask.title, newTask.description) // Make sure it's active assertThat(taskRepository.getTask(newTaskId)?.isCompleted).isFalse() // Mark is as complete taskRepository.completeTask(newTaskId) // Verify it's now completed assertThat(taskRepository.getTask(newTaskId)?.isCompleted).isTrue() } @Test fun completeTask_activeTaskToServiceAPIUpdatesCache() = testScope.runTest { // Save a task val newTaskId = taskRepository.createTask(newTask.title, newTask.description) taskRepository.completeTask(newTaskId) // Make sure it's completed assertThat(taskRepository.getTask(newTaskId)?.isActive).isFalse() // Mark is as active taskRepository.activateTask(newTaskId) // Verify it's now activated assertThat(taskRepository.getTask(newTaskId)?.isActive).isTrue() } @Test fun getTask_repositoryCachesAfterFirstApiCall() = testScope.runTest { // Obtain a task from the local data source localDataSource = FakeTaskDao(mutableListOf(task1.toLocal())) val initial = taskRepository.getTask(task1.id) // Change the tasks on the remote networkDataSource.tasks = newTasks.toNetwork().toMutableList() // Obtain the same task again val second = taskRepository.getTask(task1.id) // Initial and second tasks should match because we didn't force a refresh assertThat(second).isEqualTo(initial) } @Test fun getTask_forceRefresh() = testScope.runTest { // Trigger the repository to load data, which loads from remote and caches networkDataSource.tasks = mutableListOf(task1.toNetwork()) val task1FirstTime = taskRepository.getTask(task1.id, forceUpdate = true) assertThat(task1FirstTime?.id).isEqualTo(task1.id) // Configure the remote data source to return a different task networkDataSource.tasks = mutableListOf(task2.toNetwork()) // Force refresh val task1SecondTime = taskRepository.getTask(task1.id, true) val task2SecondTime = taskRepository.getTask(task2.id, true) // Only task2 works because task1 does not exist on the remote assertThat(task1SecondTime).isNull() assertThat(task2SecondTime?.id).isEqualTo(task2.id) } @Test fun clearCompletedTasks() = testScope.runTest { val completedTask = task1.copy(isCompleted = true) localDataSource.tasks = listOf(completedTask.toLocal(), task2.toLocal()) taskRepository.clearCompletedTasks() val tasks = taskRepository.getTasks(true) assertThat(tasks).hasSize(1) assertThat(tasks).contains(task2) assertThat(tasks).doesNotContain(completedTask) } @Test fun deleteAllTasks() = testScope.runTest { val initialTasks = taskRepository.getTasks() // Verify tasks are returned assertThat(initialTasks.size).isEqualTo(1) // Delete all tasks taskRepository.deleteAllTasks() // Verify tasks are empty now val afterDeleteTasks = taskRepository.getTasks() assertThat(afterDeleteTasks).isEmpty() } @Test fun deleteSingleTask() = testScope.runTest { val initialTasksSize = taskRepository.getTasks(true).size // Delete first task taskRepository.deleteTask(task1.id) // Fetch data again val afterDeleteTasks = taskRepository.getTasks(true) // Verify only one task was deleted assertThat(afterDeleteTasks.size).isEqualTo(initialTasksSize - 1) assertThat(afterDeleteTasks).doesNotContain(task1) } } ================================================ FILE: app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtilsTest.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.statistics import com.example.android.architecture.blueprints.todoapp.data.Task import org.hamcrest.core.Is.`is` import org.junit.Assert.assertThat import org.junit.Test /** * Unit tests for [getActiveAndCompletedStats]. */ class StatisticsUtilsTest { @Test fun getActiveAndCompletedStats_noCompleted() { val tasks = listOf( Task( id = "id", title = "title", description = "desc", isCompleted = false, ) ) // When the list of tasks is computed with an active task val result = getActiveAndCompletedStats(tasks) // Then the percentages are 100 and 0 assertThat(result.activeTasksPercent, `is`(100f)) assertThat(result.completedTasksPercent, `is`(0f)) } @Test fun getActiveAndCompletedStats_noActive() { val tasks = listOf( Task( id = "id", title = "title", description = "desc", isCompleted = true, ) ) // When the list of tasks is computed with a completed task val result = getActiveAndCompletedStats(tasks) // Then the percentages are 0 and 100 assertThat(result.activeTasksPercent, `is`(0f)) assertThat(result.completedTasksPercent, `is`(100f)) } @Test fun getActiveAndCompletedStats_both() { // Given 3 completed tasks and 2 active tasks val tasks = listOf( Task(id = "1", title = "title", description = "desc", isCompleted = true), Task(id = "2", title = "title", description = "desc", isCompleted = true), Task(id = "3", title = "title", description = "desc", isCompleted = true), Task(id = "4", title = "title", description = "desc", isCompleted = false), Task(id = "5", title = "title", description = "desc", isCompleted = false), ) // When the list of tasks is computed val result = getActiveAndCompletedStats(tasks) // Then the result is 40-60 assertThat(result.activeTasksPercent, `is`(40f)) assertThat(result.completedTasksPercent, `is`(60f)) } @Test fun getActiveAndCompletedStats_empty() { // When there are no tasks val result = getActiveAndCompletedStats(emptyList()) // Both active and completed tasks are 0 assertThat(result.activeTasksPercent, `is`(0f)) assertThat(result.completedTasksPercent, `is`(0f)) } } ================================================ FILE: app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModelTest.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.statistics import com.example.android.architecture.blueprints.todoapp.MainCoroutineRule import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository import com.example.android.architecture.blueprints.todoapp.data.Task import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.Before import org.junit.Rule import org.junit.Test /** * Unit tests for the implementation of [StatisticsViewModel] */ @ExperimentalCoroutinesApi class StatisticsViewModelTest { // Subject under test private lateinit var statisticsViewModel: StatisticsViewModel // Use a fake repository to be injected into the viewmodel private lateinit var tasksRepository: FakeTaskRepository // Set the main coroutines dispatcher for unit testing. @ExperimentalCoroutinesApi @get:Rule val mainCoroutineRule = MainCoroutineRule() @Before fun setupStatisticsViewModel() { tasksRepository = FakeTaskRepository() statisticsViewModel = StatisticsViewModel(tasksRepository) } @Test fun loadEmptyTasksFromRepository_EmptyResults() = runTest { // Given an initialized StatisticsViewModel with no tasks // Then the results are empty val uiState = statisticsViewModel.uiState.first() assertThat(uiState.isEmpty).isTrue() } @Test fun loadNonEmptyTasksFromRepository_NonEmptyResults() = runTest { // We initialise the tasks to 3, with one active and two completed val task1 = Task(id = "1", title = "Title1", description = "Desc1") val task2 = Task(id = "2", title = "Title2", description = "Desc2", isCompleted = true) val task3 = Task(id = "3", title = "Title3", description = "Desc3", isCompleted = true) val task4 = Task(id = "4", title = "Title4", description = "Desc4", isCompleted = true) tasksRepository.addTasks(task1, task2, task3, task4) // Then the results are not empty val uiState = statisticsViewModel.uiState.first() assertThat(uiState.isEmpty).isFalse() assertThat(uiState.activeTasksPercent).isEqualTo(25f) assertThat(uiState.completedTasksPercent).isEqualTo(75f) assertThat(uiState.isLoading).isEqualTo(false) } @Test fun loadTasks_loading() = runTest { // Set Main dispatcher to not run coroutines eagerly, for just this one test Dispatchers.setMain(StandardTestDispatcher()) var isLoading: Boolean? = true val job = launch { statisticsViewModel.uiState.collect { isLoading = it.isLoading } } // Then progress indicator is shown assertThat(isLoading).isTrue() // Execute pending coroutines actions advanceUntilIdle() // Then progress indicator is hidden assertThat(isLoading).isFalse() job.cancel() } } ================================================ FILE: app/src/test/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModelTest.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.taskdetail import androidx.lifecycle.SavedStateHandle import com.example.android.architecture.blueprints.todoapp.MainCoroutineRule import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository import com.example.android.architecture.blueprints.todoapp.data.Task import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test /** * Unit tests for the implementation of [TaskDetailViewModel] */ @ExperimentalCoroutinesApi class TaskDetailViewModelTest { // Set the main coroutines dispatcher for unit testing. @ExperimentalCoroutinesApi @get:Rule val mainCoroutineRule = MainCoroutineRule() // Subject under test private lateinit var taskDetailViewModel: TaskDetailViewModel // Use a fake repository to be injected into the viewmodel private lateinit var tasksRepository: FakeTaskRepository private val task = Task(title = "Title1", description = "Description1", id = "0") @Before fun setupViewModel() { tasksRepository = FakeTaskRepository() tasksRepository.addTasks(task) taskDetailViewModel = TaskDetailViewModel( tasksRepository, SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) ) } @Test fun getActiveTaskFromRepositoryAndLoadIntoView() = runTest { val uiState = taskDetailViewModel.uiState.first() // Then verify that the view was notified assertThat(uiState.task?.title).isEqualTo(task.title) assertThat(uiState.task?.description).isEqualTo(task.description) } @Test fun completeTask() = runTest { // Verify that the task was active initially assertThat(tasksRepository.savedTasks.value[task.id]?.isCompleted).isFalse() // When the ViewModel is asked to complete the task assertThat(taskDetailViewModel.uiState.first().task?.id).isEqualTo("0") taskDetailViewModel.setCompleted(true) // Then the task is completed and the snackbar shows the correct message assertThat(tasksRepository.savedTasks.value[task.id]?.isCompleted).isTrue() assertThat(taskDetailViewModel.uiState.first().userMessage) .isEqualTo(R.string.task_marked_complete) } @Test fun activateTask() = runTest { tasksRepository.deleteAllTasks() tasksRepository.addTasks(task.copy(isCompleted = true)) // Verify that the task was completed initially assertThat(tasksRepository.savedTasks.value[task.id]?.isCompleted).isTrue() // When the ViewModel is asked to complete the task assertThat(taskDetailViewModel.uiState.first().task?.id).isEqualTo("0") taskDetailViewModel.setCompleted(false) // Then the task is not completed and the snackbar shows the correct message val newTask = tasksRepository.getTask(task.id) assertTrue((newTask?.isActive) ?: false) assertThat(taskDetailViewModel.uiState.first().userMessage) .isEqualTo(R.string.task_marked_active) } @Test fun taskDetailViewModel_repositoryError() = runTest { // Given a repository that throws errors tasksRepository.setShouldThrowError(true) // Then the task is null and the snackbar shows a loading error message assertThat(taskDetailViewModel.uiState.value.task).isNull() assertThat(taskDetailViewModel.uiState.first().userMessage) .isEqualTo(R.string.loading_task_error) } @Test fun taskDetailViewModel_taskNotFound() = runTest { // Given an ID for a non existent task taskDetailViewModel = TaskDetailViewModel( tasksRepository, SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "nonexistent_id")) ) // The task is null and the snackbar shows a "not found" error message assertThat(taskDetailViewModel.uiState.value.task).isNull() assertThat(taskDetailViewModel.uiState.first().userMessage) .isEqualTo(R.string.task_not_found) } @Test fun deleteTask() = runTest { assertThat(tasksRepository.savedTasks.value.containsValue(task)).isTrue() // When the deletion of a task is requested taskDetailViewModel.deleteTask() assertThat(tasksRepository.savedTasks.value.containsValue(task)).isFalse() } @Test fun loadTask_loading() = runTest { // Set Main dispatcher to not run coroutines eagerly, for just this one test Dispatchers.setMain(StandardTestDispatcher()) var isLoading: Boolean? = true val job = launch { taskDetailViewModel.uiState.collect { isLoading = it.isLoading } } // Then progress indicator is shown assertThat(isLoading).isTrue() // Execute pending coroutines actions advanceUntilIdle() // Then progress indicator is hidden assertThat(isLoading).isFalse() job.cancel() } } ================================================ FILE: app/src/test/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModelTest.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.tasks import androidx.lifecycle.SavedStateHandle import com.example.android.architecture.blueprints.todoapp.ADD_EDIT_RESULT_OK import com.example.android.architecture.blueprints.todoapp.DELETE_RESULT_OK import com.example.android.architecture.blueprints.todoapp.EDIT_RESULT_OK import com.example.android.architecture.blueprints.todoapp.MainCoroutineRule import com.example.android.architecture.blueprints.todoapp.R import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository import com.example.android.architecture.blueprints.todoapp.data.Task import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.Before import org.junit.Rule import org.junit.Test /** * Unit tests for the implementation of [TasksViewModel] */ @ExperimentalCoroutinesApi class TasksViewModelTest { // Subject under test private lateinit var tasksViewModel: TasksViewModel // Use a fake repository to be injected into the viewmodel private lateinit var tasksRepository: FakeTaskRepository // Set the main coroutines dispatcher for unit testing. @ExperimentalCoroutinesApi @get:Rule val mainCoroutineRule = MainCoroutineRule() @Before fun setupViewModel() { // We initialise the tasks to 3, with one active and two completed tasksRepository = FakeTaskRepository() val task1 = Task(id = "1", title = "Title1", description = "Desc1") val task2 = Task(id = "2", title = "Title2", description = "Desc2", isCompleted = true) val task3 = Task(id = "3", title = "Title3", description = "Desc3", isCompleted = true) tasksRepository.addTasks(task1, task2, task3) tasksViewModel = TasksViewModel(tasksRepository, SavedStateHandle()) } @Test fun loadAllTasksFromRepository_loadingTogglesAndDataLoaded() = runTest { // Set Main dispatcher to not run coroutines eagerly, for just this one test Dispatchers.setMain(StandardTestDispatcher()) // Given an initialized TasksViewModel with initialized tasks // When loading of Tasks is requested tasksViewModel.setFiltering(TasksFilterType.ALL_TASKS) // Trigger loading of tasks tasksViewModel.refresh() // Then progress indicator is shown assertThat(tasksViewModel.uiState.first().isLoading).isTrue() // Execute pending coroutines actions advanceUntilIdle() // Then progress indicator is hidden assertThat(tasksViewModel.uiState.first().isLoading).isFalse() // And data correctly loaded assertThat(tasksViewModel.uiState.first().items).hasSize(3) } @Test fun loadActiveTasksFromRepositoryAndLoadIntoView() = runTest { // Given an initialized TasksViewModel with initialized tasks // When loading of Tasks is requested tasksViewModel.setFiltering(TasksFilterType.ACTIVE_TASKS) // Load tasks tasksViewModel.refresh() // Then progress indicator is hidden assertThat(tasksViewModel.uiState.first().isLoading).isFalse() // And data correctly loaded assertThat(tasksViewModel.uiState.first().items).hasSize(1) } @Test fun loadCompletedTasksFromRepositoryAndLoadIntoView() = runTest { // Given an initialized TasksViewModel with initialized tasks // When loading of Tasks is requested tasksViewModel.setFiltering(TasksFilterType.COMPLETED_TASKS) // Load tasks tasksViewModel.refresh() // Then progress indicator is hidden assertThat(tasksViewModel.uiState.first().isLoading).isFalse() // And data correctly loaded assertThat(tasksViewModel.uiState.first().items).hasSize(2) } @Test fun loadTasks_error() = runTest { // Make the repository throw errors tasksRepository.setShouldThrowError(true) // Load tasks tasksViewModel.refresh() // Then progress indicator is hidden assertThat(tasksViewModel.uiState.first().isLoading).isFalse() // And the list of items is empty assertThat(tasksViewModel.uiState.first().items).isEmpty() assertThat(tasksViewModel.uiState.first().userMessage) .isEqualTo(R.string.loading_tasks_error) } @Test fun clearCompletedTasks_clearsTasks() = runTest { // When completed tasks are cleared tasksViewModel.clearCompletedTasks() // Fetch tasks tasksViewModel.refresh() // Fetch tasks val allTasks = tasksViewModel.uiState.first().items val completedTasks = allTasks?.filter { it.isCompleted } // Verify there are no completed tasks left assertThat(completedTasks).isEmpty() // Verify active task is not cleared assertThat(allTasks).hasSize(1) // Verify snackbar is updated assertThat(tasksViewModel.uiState.first().userMessage) .isEqualTo(R.string.completed_tasks_cleared) } @Test fun showEditResultMessages_editOk_snackbarUpdated() = runTest { // When the viewmodel receives a result from another destination tasksViewModel.showEditResultMessage(EDIT_RESULT_OK) // The snackbar is updated assertThat(tasksViewModel.uiState.first().userMessage) .isEqualTo(R.string.successfully_saved_task_message) } @Test fun showEditResultMessages_addOk_snackbarUpdated() = runTest { // When the viewmodel receives a result from another destination tasksViewModel.showEditResultMessage(ADD_EDIT_RESULT_OK) // The snackbar is updated assertThat(tasksViewModel.uiState.first().userMessage) .isEqualTo(R.string.successfully_added_task_message) } @Test fun showEditResultMessages_deleteOk_snackbarUpdated() = runTest { // When the viewmodel receives a result from another destination tasksViewModel.showEditResultMessage(DELETE_RESULT_OK) // The snackbar is updated assertThat(tasksViewModel.uiState.first().userMessage) .isEqualTo(R.string.successfully_deleted_task_message) } @Test fun completeTask_dataAndSnackbarUpdated() = runTest { // With a repository that has an active task val task = Task(id = "id", title = "Title", description = "Description") tasksRepository.addTasks(task) // Complete task tasksViewModel.completeTask(task, true) // Verify the task is completed assertThat(tasksRepository.savedTasks.value[task.id]?.isCompleted).isTrue() // The snackbar is updated assertThat(tasksViewModel.uiState.first().userMessage) .isEqualTo(R.string.task_marked_complete) } @Test fun activateTask_dataAndSnackbarUpdated() = runTest { // With a repository that has a completed task val task = Task(id = "id", title = "Title", description = "Description", isCompleted = true) tasksRepository.addTasks(task) // Activate task tasksViewModel.completeTask(task, false) // Verify the task is active assertThat(tasksRepository.savedTasks.value[task.id]?.isActive).isTrue() // The snackbar is updated assertThat(tasksViewModel.uiState.first().userMessage) .isEqualTo(R.string.task_marked_active) } } ================================================ FILE: app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker ================================================ mock-maker-inline ================================================ FILE: build.gradle.kts ================================================ /* * Copyright 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.compose.compiler) apply false } ================================================ FILE: gradle/init.gradle.kts ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // The init script is used to run Spotless in a gradle configuration cache compliant manner as // Spotless itself is not gradle configuration cache compliant. // Note that the init script needs to be run with the configuration cache turned off. val ktlintVersion = "0.44.0" initscript { val spotlessVersion = "6.25.0" repositories { mavenCentral() } dependencies { classpath("com.diffplug.spotless:spotless-plugin-gradle:$spotlessVersion") } } rootProject { subprojects { apply() extensions.configure { kotlin { target("**/*.kt") targetExclude("**/build/**/*.kt") ktlint(ktlintVersion).userData(mapOf("android" to "true")) licenseHeaderFile(rootProject.file("spotless/copyright.kt")) } format("kts") { target("**/*.kts") targetExclude("**/build/**/*.kts") // Look for the first line that doesn't have a block comment (assumed to be the license) licenseHeaderFile(rootProject.file("spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)") } format("xml") { target("**/*.xml") targetExclude("**/build/**/*.xml") // Look for the first XML tag that isn't a comment ( ================================================ FILE: shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/CustomTestRunner.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp import android.app.Application import android.content.Context import androidx.test.runner.AndroidJUnitRunner import dagger.hilt.android.testing.HiltTestApplication class CustomTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } } ================================================ FILE: shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/MainCoroutineRule.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.rules.TestWatcher import org.junit.runner.Description /** * Sets the main coroutines dispatcher to a [TestDispatcher] for unit testing. * * Declare it as a JUnit Rule: * * ``` * @get:Rule * val mainCoroutineRule = MainCoroutineRule() * ``` * * Then, use `runTest` to execute your tests. */ @ExperimentalCoroutinesApi class MainCoroutineRule( val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() ) : TestWatcher() { override fun starting(description: Description?) { super.starting(description) Dispatchers.setMain(testDispatcher) } override fun finished(description: Description?) { super.finished(description) Dispatchers.resetMain() } } ================================================ FILE: shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data import androidx.annotation.VisibleForTesting import java.util.UUID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update /** * Implementation of a tasks repository with static access to the data for easy testing. */ class FakeTaskRepository : TaskRepository { private var shouldThrowError = false private val _savedTasks = MutableStateFlow(LinkedHashMap()) val savedTasks: StateFlow> = _savedTasks.asStateFlow() private val observableTasks: Flow> = savedTasks.map { if (shouldThrowError) { throw Exception("Test exception") } else { it.values.toList() } } fun setShouldThrowError(value: Boolean) { shouldThrowError = value } override suspend fun refresh() { // Tasks already refreshed } override suspend fun refreshTask(taskId: String) { refresh() } override suspend fun createTask(title: String, description: String): String { val taskId = generateTaskId() Task(title = title, description = description, id = taskId).also { saveTask(it) } return taskId } override fun getTasksStream(): Flow> = observableTasks override fun getTaskStream(taskId: String): Flow { return observableTasks.map { tasks -> return@map tasks.firstOrNull { it.id == taskId } } } override suspend fun getTask(taskId: String, forceUpdate: Boolean): Task? { if (shouldThrowError) { throw Exception("Test exception") } return savedTasks.value[taskId] } override suspend fun getTasks(forceUpdate: Boolean): List { if (shouldThrowError) { throw Exception("Test exception") } return observableTasks.first() } override suspend fun updateTask(taskId: String, title: String, description: String) { val updatedTask = _savedTasks.value[taskId]?.copy( title = title, description = description ) ?: throw Exception("Task (id $taskId) not found") saveTask(updatedTask) } private fun saveTask(task: Task) { _savedTasks.update { tasks -> val newTasks = LinkedHashMap(tasks) newTasks[task.id] = task newTasks } } override suspend fun completeTask(taskId: String) { _savedTasks.value[taskId]?.let { saveTask(it.copy(isCompleted = true)) } } override suspend fun activateTask(taskId: String) { _savedTasks.value[taskId]?.let { saveTask(it.copy(isCompleted = false)) } } override suspend fun clearCompletedTasks() { _savedTasks.update { tasks -> tasks.filterValues { !it.isCompleted } as LinkedHashMap } } override suspend fun deleteTask(taskId: String) { _savedTasks.update { tasks -> val newTasks = LinkedHashMap(tasks) newTasks.remove(taskId) newTasks } } override suspend fun deleteAllTasks() { _savedTasks.update { LinkedHashMap() } } private fun generateTaskId() = UUID.randomUUID().toString() @VisibleForTesting fun addTasks(vararg tasks: Task) { _savedTasks.update { oldTasks -> val newTasks = LinkedHashMap(oldTasks) for (task in tasks) { newTasks[task.id] = task } newTasks } } } ================================================ FILE: shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/FakeTaskDao.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.local import kotlinx.coroutines.flow.Flow class FakeTaskDao(initialTasks: List? = emptyList()) : TaskDao { private var _tasks: MutableMap? = null var tasks: List? get() = _tasks?.values?.toList() set(newTasks) { _tasks = newTasks?.associateBy { it.id }?.toMutableMap() } init { tasks = initialTasks } override suspend fun getAll() = tasks ?: throw Exception("Task list is null") override suspend fun getById(taskId: String): LocalTask? = _tasks?.get(taskId) override suspend fun upsertAll(tasks: List) { _tasks?.putAll(tasks.associateBy { it.id }) } override suspend fun upsert(task: LocalTask) { _tasks?.put(task.id, task) } override suspend fun updateCompleted(taskId: String, completed: Boolean) { _tasks?.get(taskId)?.let { it.isCompleted = completed } } override suspend fun deleteAll() { _tasks?.clear() } override suspend fun deleteById(taskId: String): Int { return if (_tasks?.remove(taskId) == null) { 0 } else { 1 } } override suspend fun deleteCompleted(): Int { _tasks?.apply { val originalSize = size entries.removeIf { it.value.isCompleted } return originalSize - size } return 0 } override fun observeAll(): Flow> { TODO("Not implemented") } override fun observeById(taskId: String): Flow { TODO("Not implemented") } } ================================================ FILE: shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/FakeNetworkDataSource.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.data.source.network class FakeNetworkDataSource( var tasks: MutableList? = mutableListOf() ) : NetworkDataSource { override suspend fun loadTasks() = tasks ?: throw Exception("Task list is null") override suspend fun saveTasks(tasks: List) { this.tasks = tasks.toMutableList() } } ================================================ FILE: shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DatabaseTestModule.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.di import android.content.Context import androidx.room.Room import com.example.android.architecture.blueprints.todoapp.data.source.local.ToDoDatabase import dagger.Module import dagger.Provides import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [DatabaseModule::class] ) object DatabaseTestModule { @Singleton @Provides fun provideDataBase(@ApplicationContext context: Context): ToDoDatabase { return Room .inMemoryDatabaseBuilder(context.applicationContext, ToDoDatabase::class.java) .allowMainThreadQueries() .build() } } ================================================ FILE: shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/RepositoryTestModule.kt ================================================ /* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.android.architecture.blueprints.todoapp.di import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository import com.example.android.architecture.blueprints.todoapp.data.TaskRepository import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RepositoryModule::class] ) object RepositoryTestModule { @Singleton @Provides fun provideTasksRepository(): TaskRepository { return FakeTaskRepository() } } ================================================ FILE: spotless/copyright.kt ================================================ /* * Copyright $YEAR The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ ================================================ FILE: spotless/copyright.kts ================================================ /* * Copyright $YEAR The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ ================================================ FILE: spotless/copyright.xml ================================================