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
## 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
================================================