diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties
new file mode 100644
index 000000000..2962a6399
--- /dev/null
+++ b/.github/ci-gradle.properties
@@ -0,0 +1,23 @@
+#
+# 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=false
+org.gradle.jvmargs=-Xmx5120m
+org.gradle.workers.max=2
+
+kotlin.incremental=false
+kotlin.compiler.execution.strategy=in-process
\ No newline at end of file
diff --git a/.github/workflows/build_test.yaml b/.github/workflows/build_test.yaml
new file mode 100644
index 000000000..8e52b225a
--- /dev/null
+++ b/.github/workflows/build_test.yaml
@@ -0,0 +1,60 @@
+name: build_test
+
+on:
+ 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 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
+
+
diff --git a/.github/workflows/copy-branch.yml b/.github/workflows/copy-branch.yml
new file mode 100644
index 000000000..f8f8572d9
--- /dev/null
+++ b/.github/workflows/copy-branch.yml
@@ -0,0 +1,31 @@
+# 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:
+ 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@v2
+ 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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..4d789636a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+*.iml
+.gradle
+local.properties
+.idea
+.DS_Store
+build
+captures
+.externalNativeBuild
diff --git a/.google/packaging.yaml b/.google/packaging.yaml
new file mode 100644
index 000000000..4ce97a7d8
--- /dev/null
+++ b/.google/packaging.yaml
@@ -0,0 +1,43 @@
+# 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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 339b53652..262d7276c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,43 +1,30 @@
# How to become a contributor and submit your own code
-To contribute with a small fix, simply create a pull request against the appropriate branch. This branch is usually [todo-mvp](https://github.com/googlesamples/android-architecture/tree/todo-mvp) unless the change is related to a variant's implementation. See [Development branches](https://github.com/googlesamples/android-architecture/wiki/Development-branches) for more information or create an [issue](https://github.com/googlesamples/android-architecture/issues) if you're not sure what branch to target.
-
-Before starting work on new sample intended for submission, please open an issue to discuss it with the team. This will allow us to review the architecture and frameworks used to determine if a spec-compatible app is likely to be accepted.
-
-Please note that this project is released with a [Contributor Code of Conduct](https://github.com/googlesamples/android-architecture/blob/master/code-of-conduct.md). By participating in this project you agree to abide by its terms.
-
-
-## Code style and structure
-
-Please check out the [Code Style for Contributors](https://source.android.com/source/code-style.html) section in AOSP. Also, check out the rest of the samples and maintain as much consistency with them as possible.
-
## Contributor License Agreements
-We'd love to accept your sample apps and patches! Before we can take them, we
+We'd love to accept your patches! Before we can take them, we
have to jump a couple of legal hurdles.
-Please fill out either the individual or corporate Contributor License Agreement (CLA).
-
- * If you are an individual writing original source code and you're sure you
- own the intellectual property, then you'll need to sign an [individual CLA](https://cla.developers.google.com).
- * If you work for a company that wants to allow you to contribute your work,
- then you'll need to sign a [corporate CLA](https://cla.developers.google.com).
- * Please make sure you sign both, Android and Google CLA
-
-Follow either of the two links above to access the appropriate CLA and
-instructions for how to sign and return it. Once we receive it, we'll be able to
-accept your pull requests.
-
-## Contributing A Patch
-
-1. Submit an issue describing your proposed change to the repo in question.
-1. The repo owner will respond to your issue promptly.
-1. If your proposed change is accepted, and you haven't already done so, sign a
- Contributor License Agreement (see details above).
-1. Fork the desired repo, develop and test your code changes.
-1. Ensure that your code adheres to the existing style in the sample to which
- you are contributing. Refer to the
- [Android Code Style Guide](https://source.android.com/source/code-style.html) for the
- recommended coding standards for this organization.
-1. Ensure that your code has an appropriate set of tests which all pass.
-1. Submit a pull request.
+### 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).
diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md
deleted file mode 100644
index 89ac5d9ec..000000000
--- a/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -1,4 +0,0 @@
-/* Remove this comment
-Please add the branch/sample ("all", "todo-mvp", "todo-mvp-dagger", etc.)
-and include it in the title if it applies.
-*/
diff --git a/README.md b/README.md
index bc865c0c0..fbc2603f5 100644
--- a/README.md
+++ b/README.md
@@ -1,137 +1,88 @@
-# Android Architecture Blueprints
+# 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.
-The Android framework provides a lot of flexibility in deciding how to organize and architect an Android app. While this freedom is very valuable, it can also lead to apps with large classes, inconsistent naming schemes, as well as mismatching or missing architectures. These types of issues can make testing, maintaining and extending your apps difficult.
+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).
-The Android Architecture Blueprints project demonstrates strategies to help solve or avoid these common problems. This project implements the same app using different architectural concepts and tools.
-
-You can use the samples in this project as a learning reference, or as a starting point for creating your own apps. The focus of this project is on demonstrating how to structure your code, design your architecture, and the eventual impact of adopting these patterns on testing and maintaining your app. You can use the techniques demonstrated here in many different ways to build apps. Your own particular priorities will impact how you implement the concepts in these projects, so you should not consider these samples to be canonical examples. To ensure the focus is kept on the aims described above, the app uses a simple UI.
-
-## Explore the samples
+## Variations
This project hosts each sample app in separate repository branches. For more information, see the `README.md` file in each branch.
-### Stable samples
-| Sample | Description |
+### Stable samples - Kotlin
+| Sample | Description |
| ------------- | ------------- |
-| [todo‑mvp](https://github.com/googlesamples/android-architecture/tree/todo-mvp/) | Demonstrates a basic [Model‑View‑Presenter](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93presenter) (MVP) architecture and provides a foundation on which the other samples are built. This sample also acts as a reference point for comparing and contrasting the other samples in this project. |
-| [todo‑mvp‑clean](https://github.com/googlesamples/android-architecture/tree/todo-mvp-clean/) | Uses concepts from [Clean Architecture](https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html). |
-| [todo‑mvp‑dagger](https://github.com/googlesamples/android-architecture/tree/todo-mvp-dagger/) | Uses [Dagger 2](https://google.github.io/dagger/) to add support for [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection). |
-| [todo‑mvp‑rxjava](https://github.com/googlesamples/android-architecture/tree/todo-mvp-rxjava/) | Uses [RxJava 2](https://github.com/ReactiveX/RxJava) to implement concurrency, and abstract the data layer. |
-| [todo‑mvvm‑databinding](https://github.com/googlesamples/android-architecture/tree/todo-mvvm-databinding/) | Based on the todo-databinding sample, this version incorporates the [Model‑View‑ViewModel](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) pattern.|
-| [todo‑mvvm‑live](https://github.com/googlesamples/android-architecture/tree/todo-mvvm-live/) | Uses ViewModels and LiveData from [Architecture Components](http://developer.android.com/arch) and the Data Binding library with an MVVM architecture. |
-
-
-### Deprecated samples
-
-These samples are no longer being maintained, but their implementation is still valid.
+| [main](https://github.com/googlesamples/android-architecture/tree/main) | This branch |
+| [service-locator](https://github.com/googlesamples/android-architecture/tree/service-locator) | A simple setup that removes Hilt in favor of a service locator |
+| [livedata](https://github.com/googlesamples/android-architecture/tree/livedata) | Uses LiveData instead of StateFlow as the data stream solution |
+| [usecases](https://github.com/googlesamples/android-architecture/tree/usecases) | Adds a new domain layer that uses UseCases for business logic (not using Compose yet) |
+| [views](https://github.com/googlesamples/android-architecture/tree/views) | Uses Views instead of Jetpack Compose to render UI elements on the screen |
+| [views-hilt](https://github.com/googlesamples/android-architecture/tree/views-hilt) | Uses Views and Hilt instead together |
-| Sample | Description |
-| ------------- | ------------- |
-| [todo‑mvp‑loaders](https://github.com/googlesamples/android-architecture/tree/deprecated-todo-mvp-loaders/) | Fetches data using the [Loaders API](https://developer.android.com/guide/components/loaders.html). |
-| [todo‑databinding](https://github.com/googlesamples/android-architecture/tree/deprecated-todo-databinding/) | Replaced by [todo‑mvvm‑databinding](https://github.com/googlesamples/android-architecture/tree/todo-mvvm-databinding/) |
-[todo‑mvp‑contentproviders](https://github.com/googlesamples/android-architecture/tree/deprecated-todo-mvp-contentproviders/) | Based on the todo-mvp-loaders sample, this version fetches data using the Loaders API, and also makes use of [content providers](https://developer.android.com/guide/topics/providers/content-providers.html). |
-
-### Samples in progress
-
-| Sample | Description |
-| ------------- | ------------- |
-| [dev‑todo‑mvp‑tablet](https://github.com/googlesamples/android-architecture/tree/dev-todo-mvp-tablet/) | Adds a master and detail view for tablets. |
-| [dev‑todo‑mvvm‑rxjava](https://github.com/googlesamples/android-architecture/tree/dev-todo-mvvm-rxjava/) | Based on the todo-rxjava sample, this version incorporates the [Model‑View‑ViewModel](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) pattern.|
-| [dev-todo-mvp-kotlin](https://github.com/googlesamples/android-architecture/tree/dev-todo-mvp-kotlin/) | Conversion of todo-mvp to Kotlin. |
-| [dev-todo-mvvm-live-kotlin](https://github.com/googlesamples/android-architecture/tree/dev-todo-mvvm-live-kotlin/) | Conversion of todo-mvvm-live to Kotlin. |
-For information about planned samples, see ["New sample" issues](https://github.com/googlesamples/android-architecture/issues?q=is%3Aissue+is%3Aopen+label%3A%22New+sample%22).
-
-### External samples
-[External samples](https://github.com/googlesamples/android-architecture/wiki/External-samples) are variants that may not be in sync with the rest of the branches in this repository.
-
-| Sample | Description |
-| ------------- | ------------- |
-| [todo‑mvp‑fragmentless](https://github.com/Syhids/android-architecture/tree/todo-mvp-fragmentless) | Uses [View](https://developer.android.com/reference/android/view/View.html) objects instead of [Fragment](https://developer.android.com/reference/android/app/Fragment.html) objects.|
-| [todo‑mvp‑conductor](https://github.com/grepx/android-architecture/tree/todo-mvp-conductor) | Uses the [Conductor](https://github.com/bluelinelabs/Conductor) framework to refactor the app to use a single Activity architecture. |
-| [todo‑mvi-rxjava](https://github.com/oldergod/android-architecture/tree/todo-mvi-rxjava) | Adapts the [Model-View-Intent](https://cycle.js.org/model-view-intent.html) pattern to Android to create a fully reactive architecture. |
+## 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).
-The following screenshot illustrates the UI of the app:
-
-
+## 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 complete Jetpack sample covering all libraries. Check out [Now in Android](https://github.com/android/nowinandroid) or the advanced [GitHub Browser Sample](https://github.com/googlesamples/android-architecture-components/tree/master/GithubBrowserSample) instead.
+* A real production app with network access, user authentication, etc. Check out the [Now in Android app](https://github.com/android/nowinandroid) instead.
-## Choose a sample for your app
+## Who is it for?
-Each sample includes a dedicated `README.md` file where you can find related metrics, as well as subjective assessments and observations by contributors. The following factors are worth considering when selecting a particular sample for your app:
+* Intermediate developers and beginners looking for a way to structure their app in a testable and maintainable way.
+* Advanced developers looking for quick reference.
-* The size of the app you are developing
-* The size and experience of your team
-* The amount of maintenance that you are expecting to have to do
-* Whether you need a tablet layout
-* Whether you need to support multiple platforms
-* Your preference for the compactness of your codebase
+## Opening a sample in Android Studio
-For more information on choosing and comparing samples, see the following pages:
-* [Samples at a glance](https://github.com/googlesamples/android-architecture/wiki/Samples-at-a-glance)
-* [How to compare samples](https://github.com/googlesamples/android-architecture/wiki/How-to-compare-samples)
-
-## Open 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 `todoapp/` directory in Android Studio. The following series of steps illustrate how to open the [todo‑mvp](https://github.com/googlesamples/android-architecture/tree/todo-mvp) sample.
-
-**Note:** The master branch does not compile.
+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 [usecases](tree/usecases/) sample.
Clone the repository:
```
-git clone git@github.com:googlesamples/android-architecture.git
+git clone git@github.com:android/architecture-samples.git
```
+This step checks out the master branch. If you want to change to a different sample:
-Checkout the todo-mvp sample:
```
-git checkout todo-mvp
+git checkout usecases
```
-**Note:** To review a different sample, replace `todo-mvp` with the name of sample you want to check out.
-
-Finally open the `todoapp/` directory in Android Studio.
+**Note:** To review a different sample, replace `usecases` with the name of sample you want to check out.
-## Contributors
+Finally open the `architecture-samples/` directory in Android Studio.
-This project is **built by the community**, and curated by Google as well as other core maintainers.
+### License
-### External contributors
-[David González](http://github.com/malmstein) - Core developer (MVP Content Providers sample)
-
-[Karumi](http://github.com/Karumi) - Developers (MVP Clean Architecture sample)
-
-[Natalie Masse](http://github.com/freewheelnat) - Core developer
-
-[Erik Hellman](https://github.com/ErikHellman) - Developer (MVP RxJava sample)
-
-[Saúl Molinero](https://github.com/saulmm) - Developer (MVP Dagger sample)
-
-[Mike Nakhimovich](https://github.com/digitalbuddha) - Developer (MVP Dagger sample)
-
-[Voicu Klein](https://github.com/kleinsenberg) - Developer (MVP RxJava sample)
-
-### Googlers
-
-[Jose Alcérreca](http://github.com/JoseAlcerreca) - Lead/Core developer
-
-[Mustafa Kurtuldu](https://github.com/mustafa-x) - UX/design
-
-[Stephan Linzner](http://github.com/slinzner) - Core developer
-
-[Florina Muntenescu](https://github.com/florina-muntenescu) - Core developer
-
-[Sharif Salah](https://github.com/sharifsalah) - Technical Writer
-
-[Doug Sigelbaum](https://github.com/DougSig) - Kotlin conversion
-
-[Ben Weiss](https://github.com/keyboardsurfer) - Kotlin conversion
-
-For more information on joining the project, see [how to become a contributor](https://github.com/googlesamples/android-architecture/blob/master/CONTRIBUTING.md) and the [contributor's guide](https://github.com/googlesamples/android-architecture/wiki/Contributions)
+```
+Copyright 2022 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.
+```
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 000000000..796b96d1c
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 000000000..457602507
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,200 @@
+/*
+ * 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.kapt)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+}
+
+android {
+ 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
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+
+ packagingOptions {
+ excludes += "META-INF/AL2.0"
+ excludes += "META-INF/LGPL2.1"
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
+ }
+
+ 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)
+ kapt(libs.hilt.compiler)
+
+ // Jetpack Compose
+ val composeBom = platform(libs.androidx.compose.bom)
+
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.compose.compiler)
+ implementation(composeBom)
+ implementation(libs.androidx.compose.foundation.core)
+ implementation(libs.androidx.compose.foundation.layout)
+ implementation(libs.androidx.compose.animation)
+ implementation(libs.androidx.compose.material.core)
+ 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)
+ kaptTest(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)
+ kaptAndroidTest(libs.hilt.compiler)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 000000000..5b42c227e
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,31 @@
+-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.**
diff --git a/app/proguardTest-rules.pro b/app/proguardTest-rules.pro
new file mode 100644
index 000000000..b0558e9bf
--- /dev/null
+++ b/app/proguardTest-rules.pro
@@ -0,0 +1,18 @@
+# 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
diff --git a/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreenTest.kt b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreenTest.kt
new file mode 100644
index 000000000..f7b68a521
--- /dev/null
+++ b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreenTest.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.material.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.data.TaskRepository
+import com.google.accompanist.appcompattheme.AppCompatTheme
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import javax.inject.Inject
+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
+
+/**
+ * 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 {
+ AppCompatTheme {
+ 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))
+ )
+ }
+}
diff --git a/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDaoTest.kt b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDaoTest.kt
new file mode 100644
index 000000000..4575af69e
--- /dev/null
+++ b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDaoTest.kt
@@ -0,0 +1,224 @@
+/*
+ * 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())
+ }
+}
diff --git a/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreenTest.kt b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreenTest.kt
new file mode 100644
index 000000000..ac28599c4
--- /dev/null
+++ b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreenTest.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.material.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.data.TaskRepository
+import com.google.accompanist.appcompattheme.AppCompatTheme
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import javax.inject.Inject
+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
+
+/**
+ * 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 {
+ AppCompatTheme {
+ 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()
+ }
+}
diff --git a/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreenTest.kt b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreenTest.kt
new file mode 100644
index 000000000..666f76747
--- /dev/null
+++ b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreenTest.kt
@@ -0,0 +1,118 @@
+/*
+ * 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.material.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.data.TaskRepository
+import com.google.accompanist.appcompattheme.AppCompatTheme
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import javax.inject.Inject
+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
+
+/**
+ * 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 {
+ AppCompatTheme {
+ Surface {
+ TaskDetailScreen(
+ viewModel = TaskDetailViewModel(
+ repository,
+ SavedStateHandle(mapOf("taskId" to activeTaskId))
+ ),
+ onEditTask = { /*TODO*/ },
+ onBack = { },
+ onDeleteTask = { },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/AppNavigationTest.kt b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/AppNavigationTest.kt
new file mode 100644
index 000000000..2d9014eb9
--- /dev/null
+++ b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/AppNavigationTest.kt
@@ -0,0 +1,194 @@
+/*
+ * 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.data.TaskRepository
+import com.google.accompanist.appcompattheme.AppCompatTheme
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import javax.inject.Inject
+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
+
+/**
+ * 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 {
+ AppCompatTheme {
+ TodoNavGraph()
+ }
+ }
+ }
+
+ private fun openDrawer() {
+ composeTestRule.onNodeWithContentDescription(activity.getString(R.string.open_drawer))
+ .performClick()
+ }
+}
diff --git a/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreenTest.kt b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreenTest.kt
new file mode 100644
index 000000000..a91ad574c
--- /dev/null
+++ b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreenTest.kt
@@ -0,0 +1,279 @@
+/*
+ * 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.material.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.data.TaskRepository
+import com.google.accompanist.appcompattheme.AppCompatTheme
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import javax.inject.Inject
+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
+
+/**
+ * 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 {
+ AppCompatTheme {
+ 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()
+ }
+}
diff --git a/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksTest.kt b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksTest.kt
new file mode 100644
index 000000000..8d3097e87
--- /dev/null
+++ b/app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksTest.kt
@@ -0,0 +1,311 @@
+/*
+ * 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.data.TaskRepository
+import com.google.accompanist.appcompattheme.AppCompatTheme
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import javax.inject.Inject
+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
+
+/**
+ * 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 {
+ AppCompatTheme {
+ 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)
+ )
+ }
+}
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..dbf09ddfc
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/debug/java/com/example/android/architecture/blueprints/todoapp/HiltTestActivity.kt b/app/src/debug/java/com/example/android/architecture/blueprints/todoapp/HiltTestActivity.kt
new file mode 100644
index 000000000..27f031018
--- /dev/null
+++ b/app/src/debug/java/com/example/android/architecture/blueprints/todoapp/HiltTestActivity.kt
@@ -0,0 +1,23 @@
+/*
+ * 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()
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..79bb88f91
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoActivity.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoActivity.kt
new file mode 100644
index 000000000..90ad33d03
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoActivity.kt
@@ -0,0 +1,39 @@
+/*
+ * 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 com.google.accompanist.appcompattheme.AppCompatTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+/**
+ * Main activity for the todoapp
+ */
+@AndroidEntryPoint
+class TodoActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ AppCompatTheme {
+ TodoNavGraph()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoApplication.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoApplication.kt
new file mode 100644
index 000000000..c8381d922
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoApplication.kt
@@ -0,0 +1,35 @@
+/*
+ * 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())
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavGraph.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavGraph.kt
new file mode 100644
index 000000000..c488d87d5
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavGraph.kt
@@ -0,0 +1,119 @@
+/*
+ * 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.material.DrawerState
+import androidx.compose.material.DrawerValue
+import androidx.compose.material.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
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavigation.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavigation.kt
new file mode 100644
index 000000000..ea0bc4a66
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavigation.kt
@@ -0,0 +1,106 @@
+/*
+ * 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
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreen.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreen.kt
new file mode 100644
index 000000000..26e381546
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreen.kt
@@ -0,0 +1,158 @@
+/*
+ * 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.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.ContentAlpha
+import androidx.compose.material.FloatingActionButton
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Scaffold
+import androidx.compose.material.ScaffoldState
+import androidx.compose.material.Text
+import androidx.compose.material.TextFieldDefaults
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material.rememberScaffoldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+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.ExperimentalLifecycleComposeApi
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.android.architecture.blueprints.todoapp.R
+import com.example.android.architecture.blueprints.todoapp.util.AddEditTaskTopAppBar
+import com.google.accompanist.swiperefresh.SwipeRefresh
+import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
+
+@OptIn(ExperimentalLifecycleComposeApi::class)
+@Composable
+fun AddEditTaskScreen(
+ @StringRes topBarTitle: Int,
+ onTaskUpdate: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ scaffoldState: ScaffoldState = rememberScaffoldState(),
+ viewModel: AddEditTaskViewModel = hiltViewModel()
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ scaffoldState = scaffoldState,
+ topBar = { AddEditTaskTopAppBar(topBarTitle, onBack) },
+ floatingActionButton = {
+ FloatingActionButton(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(scaffoldState, viewModel, userMessage, snackbarText) {
+ scaffoldState.snackbarHostState.showSnackbar(snackbarText)
+ viewModel.snackbarMessageShown()
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddEditTaskContent(
+ loading: Boolean,
+ title: String,
+ description: String,
+ onTitleChanged: (String) -> Unit,
+ onDescriptionChanged: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ if (loading) {
+ SwipeRefresh(
+ // Show the loading spinner—`loading` is `true` in this code path
+ state = rememberSwipeRefreshState(true),
+ onRefresh = { /* DO NOTHING */ },
+ content = { },
+ )
+ } else {
+ Column(
+ modifier
+ .fillMaxWidth()
+ .padding(all = dimensionResource(id = R.dimen.horizontal_margin))
+ .verticalScroll(rememberScrollState())
+ ) {
+ val textFieldColors = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = Color.Transparent,
+ unfocusedBorderColor = Color.Transparent,
+ cursorColor = MaterialTheme.colors.secondary.copy(alpha = ContentAlpha.high)
+ )
+ OutlinedTextField(
+ value = title,
+ modifier = Modifier.fillMaxWidth(),
+ onValueChange = onTitleChanged,
+ placeholder = {
+ Text(
+ text = stringResource(id = R.string.title_hint),
+ style = MaterialTheme.typography.h6
+ )
+ },
+ textStyle = MaterialTheme.typography.h6.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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModel.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModel.kt
new file mode 100644
index 000000000..247573042
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModel.kt
@@ -0,0 +1,148 @@
+/*
+ * 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 javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * 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)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt
new file mode 100644
index 000000000..9fa32de87
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt
@@ -0,0 +1,187 @@
+/*
+ * 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 java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+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
+
+/**
+ * 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.
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/ModelMappingExt.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/ModelMappingExt.kt
new file mode 100644
index 000000000..38b14282f
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/ModelMappingExt.kt
@@ -0,0 +1,92 @@
+/*
+ * 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)
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/Task.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/Task.kt
new file mode 100644
index 000000000..91377071c
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/Task.kt
@@ -0,0 +1,45 @@
+/*
+ * 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()
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt
new file mode 100644
index 000000000..6f0ee0bb5
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt
@@ -0,0 +1,51 @@
+/*
+ * 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)
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/LocalTask.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/LocalTask.kt
new file mode 100644
index 000000000..9d9b084e4
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/LocalTask.kt
@@ -0,0 +1,37 @@
+/*
+ * 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,
+)
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDao.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDao.kt
new file mode 100644
index 000000000..23251efe8
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDao.kt
@@ -0,0 +1,110 @@
+/*
+ * 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
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/ToDoDatabase.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/ToDoDatabase.kt
new file mode 100644
index 000000000..4f4386af2
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/ToDoDatabase.kt
@@ -0,0 +1,31 @@
+/*
+ * 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
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkDataSource.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkDataSource.kt
new file mode 100644
index 000000000..f6e298771
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkDataSource.kt
@@ -0,0 +1,28 @@
+/*
+ * 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)
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkTask.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkTask.kt
new file mode 100644
index 000000000..e7168a7ec
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkTask.kt
@@ -0,0 +1,37 @@
+/*
+ * 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
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt
new file mode 100644
index 000000000..3f248fc3a
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt
@@ -0,0 +1,52 @@
+/*
+ * 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 javax.inject.Inject
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+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
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt
new file mode 100644
index 000000000..262d52e39
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt
@@ -0,0 +1,60 @@
+/*
+ * 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 javax.inject.Qualifier
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+
+@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)
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt
new file mode 100644
index 000000000..aab699ccd
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt
@@ -0,0 +1,69 @@
+/*
+ * 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()
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreen.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreen.kt
new file mode 100644
index 000000000..cd5b24d71
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreen.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.material.Scaffold
+import androidx.compose.material.ScaffoldState
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.rememberScaffoldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.ExperimentalLifecycleComposeApi
+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
+import com.google.accompanist.appcompattheme.AppCompatTheme
+
+@OptIn(ExperimentalLifecycleComposeApi::class)
+@Composable
+fun StatisticsScreen(
+ openDrawer: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: StatisticsViewModel = hiltViewModel(),
+ scaffoldState: ScaffoldState = rememberScaffoldState()
+) {
+ Scaffold(
+ scaffoldState = scaffoldState,
+ 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
+ .fillMaxWidth()
+ .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() {
+ AppCompatTheme {
+ Surface {
+ StatisticsContent(
+ loading = false,
+ empty = false,
+ activeTasksPercent = 80f,
+ completedTasksPercent = 20f,
+ onRefresh = { }
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun StatisticsContentEmptyPreview() {
+ AppCompatTheme {
+ Surface {
+ StatisticsContent(
+ loading = false,
+ empty = true,
+ activeTasksPercent = 0f,
+ completedTasksPercent = 0f,
+ onRefresh = { }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtils.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtils.kt
new file mode 100644
index 000000000..99942a94c
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtils.kt
@@ -0,0 +1,38 @@
+/*
+ * 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)
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt
new file mode 100644
index 000000000..4db5c1f22
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt
@@ -0,0 +1,88 @@
+/*
+ * 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 javax.inject.Inject
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/**
+ * 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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreen.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreen.kt
new file mode 100644
index 000000000..814377da3
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreen.kt
@@ -0,0 +1,212 @@
+/*
+ * 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.Checkbox
+import androidx.compose.material.FloatingActionButton
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Scaffold
+import androidx.compose.material.ScaffoldState
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.rememberScaffoldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+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.ExperimentalLifecycleComposeApi
+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
+import com.google.accompanist.appcompattheme.AppCompatTheme
+
+@OptIn(ExperimentalLifecycleComposeApi::class)
+@Composable
+fun TaskDetailScreen(
+ onEditTask: (String) -> Unit,
+ onBack: () -> Unit,
+ onDeleteTask: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: TaskDetailViewModel = hiltViewModel(),
+ scaffoldState: ScaffoldState = rememberScaffoldState()
+) {
+ Scaffold(
+ scaffoldState = scaffoldState,
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TaskDetailTopAppBar(onBack = onBack, onDelete = viewModel::deleteTask)
+ },
+ floatingActionButton = {
+ FloatingActionButton(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(scaffoldState, viewModel, userMessage, snackbarText) {
+ scaffoldState.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.h6)
+ Text(text = task.description, style = MaterialTheme.typography.body1)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun EditTaskContentPreview() {
+ AppCompatTheme {
+ Surface {
+ EditTaskContent(
+ loading = false,
+ empty = false,
+ Task(
+ title = "Title",
+ description = "Description",
+ isCompleted = false,
+ id = "ID"
+ ),
+ onTaskCheck = { },
+ onRefresh = { }
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun EditTaskContentTaskCompletedPreview() {
+ AppCompatTheme {
+ Surface {
+ EditTaskContent(
+ loading = false,
+ empty = false,
+ Task(
+ title = "Title",
+ description = "Description",
+ isCompleted = false,
+ id = "ID"
+ ),
+ onTaskCheck = { },
+ onRefresh = { }
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun EditTaskContentEmptyPreview() {
+ AppCompatTheme {
+ Surface {
+ EditTaskContent(
+ loading = false,
+ empty = true,
+ Task(
+ title = "Title",
+ description = "Description",
+ isCompleted = false,
+ id = "ID"
+ ),
+ onTaskCheck = { },
+ onRefresh = { }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModel.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModel.kt
new file mode 100644
index 000000000..080b22c04
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModel.kt
@@ -0,0 +1,133 @@
+/*
+ * 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 javax.inject.Inject
+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
+
+/**
+ * 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)
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksFilterType.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksFilterType.kt
new file mode 100644
index 000000000..4de088319
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksFilterType.kt
@@ -0,0 +1,37 @@
+/*
+ * 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
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreen.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreen.kt
new file mode 100644
index 000000000..16cf81e86
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksScreen.kt
@@ -0,0 +1,348 @@
+/*
+ * 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.Checkbox
+import androidx.compose.material.FloatingActionButton
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Scaffold
+import androidx.compose.material.ScaffoldState
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.rememberScaffoldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+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.ExperimentalLifecycleComposeApi
+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.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
+import com.google.accompanist.appcompattheme.AppCompatTheme
+
+@OptIn(ExperimentalLifecycleComposeApi::class)
+@Composable
+fun TasksScreen(
+ @StringRes userMessage: Int,
+ onAddTask: () -> Unit,
+ onTaskClick: (Task) -> Unit,
+ onUserMessageDisplayed: () -> Unit,
+ openDrawer: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: TasksViewModel = hiltViewModel(),
+ scaffoldState: ScaffoldState = rememberScaffoldState()
+) {
+ Scaffold(
+ scaffoldState = scaffoldState,
+ 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() }
+ )
+ },
+ modifier = modifier.fillMaxSize(),
+ floatingActionButton = {
+ FloatingActionButton(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(scaffoldState, viewModel, message, snackbarText) {
+ scaffoldState.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.h6
+ )
+ 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.h6,
+ 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() {
+ AppCompatTheme {
+ 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() {
+ AppCompatTheme {
+ 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() {
+ AppCompatTheme {
+ Surface {
+ TasksEmptyContent(
+ noTasksLabel = R.string.no_tasks_all,
+ noTasksIconRes = R.drawable.logo_no_fill
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun TaskItemPreview() {
+ AppCompatTheme {
+ Surface {
+ TaskItem(
+ task = Task(
+ title = "Title",
+ description = "Description",
+ id = "ID"
+ ),
+ onTaskClick = { },
+ onCheckedChange = { }
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun TaskItemCompletedPreview() {
+ AppCompatTheme {
+ Surface {
+ TaskItem(
+ task = Task(
+ title = "Title",
+ description = "Description",
+ isCompleted = true,
+ id = "ID"
+ ),
+ onTaskClick = { },
+ onCheckedChange = { }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt
new file mode 100644
index 000000000..9868f80a7
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt
@@ -0,0 +1,195 @@
+/*
+ * 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 javax.inject.Inject
+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
+
+/**
+ * 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,
+)
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/Async.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/Async.kt
new file mode 100644
index 000000000..004da7e4a
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/Async.kt
@@ -0,0 +1,28 @@
+/*
+ * 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()
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/ComposeUtils.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/ComposeUtils.kt
new file mode 100644
index 000000000..84d641712
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/ComposeUtils.kt
@@ -0,0 +1,56 @@
+/*
+ * 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,
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/CoroutinesUtils.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/CoroutinesUtils.kt
new file mode 100644
index 000000000..318b2c90e
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/CoroutinesUtils.kt
@@ -0,0 +1,32 @@
+/*
+ * 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)
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/SimpleCountingIdlingResource.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/SimpleCountingIdlingResource.kt
new file mode 100644
index 000000000..706756359
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/SimpleCountingIdlingResource.kt
@@ -0,0 +1,70 @@
+/*
+ * 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!")
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TodoDrawer.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TodoDrawer.kt
new file mode 100644
index 000000000..8e5ed06af
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TodoDrawer.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.material.DrawerState
+import androidx.compose.material.Icon
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.ModalDrawer
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.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.google.accompanist.appcompattheme.AppCompatTheme
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+@Composable
+fun AppModalDrawer(
+ drawerState: DrawerState,
+ currentRoute: String,
+ navigationActions: TodoNavigationActions,
+ coroutineScope: CoroutineScope = rememberCoroutineScope(),
+ content: @Composable () -> Unit
+) {
+ ModalDrawer(
+ 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
+) {
+ 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.colors.surface
+ )
+ }
+}
+
+@Composable
+private fun DrawerButton(
+ painter: Painter,
+ label: String,
+ isSelected: Boolean,
+ action: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val tintColor = if (isSelected) {
+ MaterialTheme.colors.secondary
+ } else {
+ MaterialTheme.colors.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.body2,
+ color = tintColor
+ )
+ }
+ }
+}
+
+@Preview("Drawer contents")
+@Composable
+fun PreviewAppDrawer() {
+ AppCompatTheme {
+ Surface {
+ AppDrawer(
+ currentRoute = TodoDestinations.TASKS_ROUTE,
+ navigateToTasks = {},
+ navigateToStatistics = {},
+ closeDrawer = {}
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TopAppBars.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TopAppBars.kt
new file mode 100644
index 000000000..abf26cc2e
--- /dev/null
+++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TopAppBars.kt
@@ -0,0 +1,223 @@
+/*
+ * 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.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.DropdownMenu
+import androidx.compose.material.DropdownMenuItem
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.icons.Icons
+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.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.google.accompanist.appcompattheme.AppCompatTheme
+
+@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 = stringResource(id = R.string.nav_all))
+ }
+ DropdownMenuItem(onClick = { onFilterActiveTasks(); closeMenu() }) {
+ Text(text = stringResource(id = R.string.nav_active))
+ }
+ DropdownMenuItem(onClick = { onFilterCompletedTasks(); closeMenu() }) {
+ 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(onClick = { onClearCompletedTasks(); closeMenu() }) {
+ Text(text = stringResource(id = R.string.menu_clear))
+ }
+ DropdownMenuItem(onClick = { onRefresh(); closeMenu() }) {
+ Text(text = stringResource(id = R.string.refresh))
+ }
+ }
+}
+
+@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.Filled.ArrowBack, stringResource(id = R.string.menu_back))
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+}
+
+@Preview
+@Composable
+private fun TasksTopAppBarPreview() {
+ AppCompatTheme {
+ Surface {
+ TasksTopAppBar({}, {}, {}, {}, {}, {})
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun StatisticsTopAppBarPreview() {
+ AppCompatTheme {
+ Surface {
+ StatisticsTopAppBar { }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun TaskDetailTopAppBarPreview() {
+ AppCompatTheme {
+ Surface {
+ TaskDetailTopAppBar({ }, { })
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun AddEditTaskTopAppBarPreview() {
+ AppCompatTheme {
+ Surface {
+ AddEditTaskTopAppBar(R.string.add_task) { }
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/drawer_item_color.xml b/app/src/main/res/drawable/drawer_item_color.xml
new file mode 100644
index 000000000..5ad440684
--- /dev/null
+++ b/app/src/main/res/drawable/drawer_item_color.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml
new file mode 100644
index 000000000..4d3e17f4d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_assignment_turned_in_24dp.xml b/app/src/main/res/drawable/ic_assignment_turned_in_24dp.xml
new file mode 100644
index 000000000..efe9b1e51
--- /dev/null
+++ b/app/src/main/res/drawable/ic_assignment_turned_in_24dp.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_check_circle_96dp.xml b/app/src/main/res/drawable/ic_check_circle_96dp.xml
new file mode 100644
index 000000000..5b193c864
--- /dev/null
+++ b/app/src/main/res/drawable/ic_check_circle_96dp.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml
new file mode 100644
index 000000000..caf1ec700
--- /dev/null
+++ b/app/src/main/res/drawable/ic_done.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml
new file mode 100644
index 000000000..536f23785
--- /dev/null
+++ b/app/src/main/res/drawable/ic_edit.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_filter_list.xml b/app/src/main/res/drawable/ic_filter_list.xml
new file mode 100644
index 000000000..e582f3efc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_filter_list.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml
new file mode 100644
index 000000000..f96b69203
--- /dev/null
+++ b/app/src/main/res/drawable/ic_list.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml
new file mode 100644
index 000000000..b3badec7f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_statistics.xml b/app/src/main/res/drawable/ic_statistics.xml
new file mode 100644
index 000000000..4b8843749
--- /dev/null
+++ b/app/src/main/res/drawable/ic_statistics.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_statistics_100dp.xml b/app/src/main/res/drawable/ic_statistics_100dp.xml
new file mode 100644
index 000000000..58ee450c8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_statistics_100dp.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_statistics_24dp.xml b/app/src/main/res/drawable/ic_statistics_24dp.xml
new file mode 100644
index 000000000..4b8843749
--- /dev/null
+++ b/app/src/main/res/drawable/ic_statistics_24dp.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_verified_user_96dp.xml b/app/src/main/res/drawable/ic_verified_user_96dp.xml
new file mode 100644
index 000000000..02e9dae9e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_verified_user_96dp.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/list_completed_touch_feedback.xml b/app/src/main/res/drawable/list_completed_touch_feedback.xml
new file mode 100644
index 000000000..9a7304cd6
--- /dev/null
+++ b/app/src/main/res/drawable/list_completed_touch_feedback.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/logo_no_fill.png b/app/src/main/res/drawable/logo_no_fill.png
new file mode 100644
index 000000000..b1bac3114
Binary files /dev/null and b/app/src/main/res/drawable/logo_no_fill.png differ
diff --git a/app/src/main/res/drawable/touch_feedback.xml b/app/src/main/res/drawable/touch_feedback.xml
new file mode 100644
index 000000000..68c9b1cc1
--- /dev/null
+++ b/app/src/main/res/drawable/touch_feedback.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/trash_icon.png b/app/src/main/res/drawable/trash_icon.png
new file mode 100644
index 000000000..4e466e560
Binary files /dev/null and b/app/src/main/res/drawable/trash_icon.png differ
diff --git a/app/src/main/res/font/opensans_font.xml b/app/src/main/res/font/opensans_font.xml
new file mode 100644
index 000000000..1cdc8aeb4
--- /dev/null
+++ b/app/src/main/res/font/opensans_font.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/font/opensans_regular.ttf b/app/src/main/res/font/opensans_regular.ttf
new file mode 100644
index 000000000..2d4da3a6e
Binary files /dev/null and b/app/src/main/res/font/opensans_regular.ttf differ
diff --git a/app/src/main/res/font/opensans_semibold.ttf b/app/src/main/res/font/opensans_semibold.ttf
new file mode 100644
index 000000000..fd71fe9da
Binary files /dev/null and b/app/src/main/res/font/opensans_semibold.ttf differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..ec70e05ca
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..7b8f7fd34
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..fe342ca68
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..9e8e5da08
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..a176762df
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml
new file mode 100644
index 000000000..e42c06cbf
--- /dev/null
+++ b/app/src/main/res/values-v21/styles.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-w820dp/dimens.xml b/app/src/main/res/values-w820dp/dimens.xml
new file mode 100644
index 000000000..bfde1cfa8
--- /dev/null
+++ b/app/src/main/res/values-w820dp/dimens.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ 64dp
+
+ 24dp
+
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 000000000..824764515
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..288283dd8
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,28 @@
+
+
+
+ #FFFFF0
+ #263238
+ #2E7D32
+ #000000
+ #757575
+
+ #CCCCCC
+
+ #CFD8DC
+
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 000000000..59cf881d5
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ 16dp
+ 16dp
+
+ 16dp
+
+ 8dp
+ 192dp
+ 16dp
+ 100dp
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..644ec9d09
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,72 @@
+
+
+
+ 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
+
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 000000000..cfa6d2bfb
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModelTest.kt b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModelTest.kt
new file mode 100644
index 000000000..1a65c10af
--- /dev/null
+++ b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModelTest.kt
@@ -0,0 +1,164 @@
+/*
+ * 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)
+ }
+}
diff --git a/app/src/test/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepositoryTest.kt b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepositoryTest.kt
new file mode 100644
index 000000000..1c41c8654
--- /dev/null
+++ b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepositoryTest.kt
@@ -0,0 +1,285 @@
+/*
+ * 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)
+ }
+}
diff --git a/app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtilsTest.kt b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtilsTest.kt
new file mode 100644
index 000000000..d35aab169
--- /dev/null
+++ b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtilsTest.kt
@@ -0,0 +1,92 @@
+/*
+ * 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))
+ }
+}
diff --git a/app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModelTest.kt b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModelTest.kt
new file mode 100644
index 000000000..532c70301
--- /dev/null
+++ b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModelTest.kt
@@ -0,0 +1,106 @@
+/*
+ * 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()
+ }
+}
diff --git a/app/src/test/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModelTest.kt b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModelTest.kt
new file mode 100644
index 000000000..c9a2c7618
--- /dev/null
+++ b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModelTest.kt
@@ -0,0 +1,167 @@
+/*
+ * 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()
+ }
+}
diff --git a/app/src/test/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModelTest.kt b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModelTest.kt
new file mode 100644
index 000000000..97cc75c08
--- /dev/null
+++ b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModelTest.kt
@@ -0,0 +1,228 @@
+/*
+ * 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)
+ }
+}
diff --git a/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 000000000..1f0955d45
--- /dev/null
+++ b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 000000000..79946bd70
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,23 @@
+/*
+ * 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.kapt) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.hilt) apply false
+}
diff --git a/code-of-conduct.md b/code-of-conduct.md
deleted file mode 100644
index a55ece4cc..000000000
--- a/code-of-conduct.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# Contributor Code of Conduct
-
-## Our Pledge
-
-In the interest of fostering an open and welcoming environment, we as
-contributors and maintainers pledge to making participation in our project and
-our community a harassment-free experience for everyone, regardless of age, body
-size, disability, ethnicity, gender identity and expression, level of experience,
-nationality, personal appearance, race, religion, or sexual identity and
-orientation.
-
-## Our Standards
-
-Examples of behavior that contributes to creating a positive environment
-include:
-
-* Using welcoming and inclusive language
-* Being respectful of differing viewpoints and experiences
-* Gracefully accepting constructive criticism
-* Focusing on what is best for the community
-* Showing empathy towards other community members
-
-Examples of unacceptable behavior by participants include:
-
-* The use of sexualized language or imagery and unwelcome sexual attention or
-advances
-* Trolling, insulting/derogatory comments, and personal or political attacks
-* Public or private harassment
-* Publishing others' private information, such as a physical or electronic
- address, without explicit permission
-* Other conduct which could reasonably be considered inappropriate in a
- professional setting
-
-## Our Responsibilities
-
-Project maintainers are responsible for clarifying the standards of acceptable
-behavior and are expected to take appropriate and fair corrective action in
-response to any instances of unacceptable behavior.
-
-Project maintainers have the right and responsibility to remove, edit, or
-reject comments, commits, code, wiki edits, issues, and other contributions
-that are not aligned to this Code of Conduct, or to ban temporarily or
-permanently any contributor for other behaviors that they deem inappropriate,
-threatening, offensive, or harmful.
-
-## Scope
-
-This Code of Conduct applies both within project spaces and in public spaces
-when an individual is representing the project or its community. Examples of
-representing a project or community include using an official project e-mail
-address, posting via an official social media account, or acting as an appointed
-representative at an online or offline event. Representation of a project may be
-further defined and clarified by project maintainers.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported by contacting the project team. All
-complaints will be reviewed and investigated and will result in a response that
-is deemed necessary and appropriate to the circumstances. The project team is
-obligated to maintain confidentiality with regard to the reporter of an incident.
-Further details of specific enforcement policies may be posted separately.
-
-Project maintainers who do not follow or enforce the Code of Conduct in good
-faith may face temporary or permanent repercussions as determined by other
-members of the project's leadership.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
-available at [http://contributor-covenant.org/version/1/4][version]
-
-[homepage]: http://contributor-covenant.org
-[version]: http://contributor-covenant.org/version/1/4/
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 000000000..504538451
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx10248m -XX:MaxPermSize=256m
+org.gradle.jvmargs=-Xmx4096m -XX:MaxPermSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+android.enableJetifier=true
+android.useAndroidX=true
+kapt.incremental.apt=true
+ksp.incremental.apt=true
+org.gradle.unsafe.configuration-cache=true
\ No newline at end of file
diff --git a/gradle/init.gradle.kts b/gradle/init.gradle.kts
new file mode 100644
index 000000000..82b61b7e4
--- /dev/null
+++ b/gradle/init.gradle.kts
@@ -0,0 +1,59 @@
+/*
+ * 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.13.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 (
+
diff --git a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/CustomTestRunner.kt b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/CustomTestRunner.kt
new file mode 100644
index 000000000..26222729b
--- /dev/null
+++ b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/CustomTestRunner.kt
@@ -0,0 +1,28 @@
+/*
+ * 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)
+ }
+}
diff --git a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/MainCoroutineRule.kt b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/MainCoroutineRule.kt
new file mode 100644
index 000000000..d76b3c4e4
--- /dev/null
+++ b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/MainCoroutineRule.kt
@@ -0,0 +1,54 @@
+/*
+ * 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()
+ }
+}
diff --git a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt
new file mode 100644
index 000000000..20056ff7d
--- /dev/null
+++ b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt
@@ -0,0 +1,152 @@
+/*
+ * 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
+ }
+ }
+}
diff --git a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/FakeTaskDao.kt b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/FakeTaskDao.kt
new file mode 100644
index 000000000..a916afd88
--- /dev/null
+++ b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/FakeTaskDao.kt
@@ -0,0 +1,79 @@
+/*
+ * 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")
+ }
+}
diff --git a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/FakeNetworkDataSource.kt b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/FakeNetworkDataSource.kt
new file mode 100644
index 000000000..eb007ecd3
--- /dev/null
+++ b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/FakeNetworkDataSource.kt
@@ -0,0 +1,27 @@
+/*
+ * 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()
+ }
+}
diff --git a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DatabaseTestModule.kt b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DatabaseTestModule.kt
new file mode 100644
index 000000000..b9d4f04d2
--- /dev/null
+++ b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DatabaseTestModule.kt
@@ -0,0 +1,44 @@
+/*
+ * 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()
+ }
+}
diff --git a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/RepositoryTestModule.kt b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/RepositoryTestModule.kt
new file mode 100644
index 000000000..3d1448380
--- /dev/null
+++ b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/RepositoryTestModule.kt
@@ -0,0 +1,39 @@
+/*
+ * 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()
+ }
+}
diff --git a/spotless/copyright.kt b/spotless/copyright.kt
new file mode 100644
index 000000000..806db0fb5
--- /dev/null
+++ b/spotless/copyright.kt
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+
diff --git a/spotless/copyright.kts b/spotless/copyright.kts
new file mode 100644
index 000000000..8c5d92cc5
--- /dev/null
+++ b/spotless/copyright.kts
@@ -0,0 +1,15 @@
+/*
+ * 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.
+ */
diff --git a/spotless/copyright.xml b/spotless/copyright.xml
new file mode 100644
index 000000000..3085c43e7
--- /dev/null
+++ b/spotless/copyright.xml
@@ -0,0 +1,16 @@
+
+