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 -Android Architecture Blueprints +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 +Screenshot ## Why a to-do app? The app in this project aims to be simple enough that you can understand it quickly, but complex enough to showcase difficult design decisions and testing scenarios. For more information, see the [app's specification](https://github.com/googlesamples/android-architecture/wiki/To-do-app-specification). -The following screenshot illustrates the UI of the app: - -A screenshot illustratrating 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 @@ + + + + + + + + + +