├── .github ├── ci-gradle.properties └── workflows │ ├── build_test.yaml │ └── copy-branch.yml ├── .gitignore ├── .google └── packaging.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── proguardTest-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── example │ │ └── android │ │ └── architecture │ │ └── blueprints │ │ └── todoapp │ │ ├── addedittask │ │ └── AddEditTaskScreenTest.kt │ │ ├── data │ │ └── source │ │ │ └── local │ │ │ └── TaskDaoTest.kt │ │ ├── statistics │ │ └── StatisticsScreenTest.kt │ │ ├── taskdetail │ │ └── TaskDetailScreenTest.kt │ │ └── tasks │ │ ├── AppNavigationTest.kt │ │ ├── TasksScreenTest.kt │ │ └── TasksTest.kt │ ├── debug │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── example │ │ └── android │ │ └── architecture │ │ └── blueprints │ │ └── todoapp │ │ └── HiltTestActivity.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── android │ │ │ └── architecture │ │ │ └── blueprints │ │ │ └── todoapp │ │ │ ├── TodoActivity.kt │ │ │ ├── TodoApplication.kt │ │ │ ├── TodoNavGraph.kt │ │ │ ├── TodoNavigation.kt │ │ │ ├── TodoTheme.kt │ │ │ ├── addedittask │ │ │ ├── AddEditTaskScreen.kt │ │ │ └── AddEditTaskViewModel.kt │ │ │ ├── data │ │ │ ├── DefaultTaskRepository.kt │ │ │ ├── ModelMappingExt.kt │ │ │ ├── Task.kt │ │ │ ├── TaskRepository.kt │ │ │ └── source │ │ │ │ ├── local │ │ │ │ ├── LocalTask.kt │ │ │ │ ├── TaskDao.kt │ │ │ │ └── ToDoDatabase.kt │ │ │ │ └── network │ │ │ │ ├── NetworkDataSource.kt │ │ │ │ ├── NetworkTask.kt │ │ │ │ └── TaskNetworkDataSource.kt │ │ │ ├── di │ │ │ ├── CoroutinesModule.kt │ │ │ └── DataModules.kt │ │ │ ├── statistics │ │ │ ├── StatisticsScreen.kt │ │ │ ├── StatisticsUtils.kt │ │ │ └── StatisticsViewModel.kt │ │ │ ├── taskdetail │ │ │ ├── TaskDetailScreen.kt │ │ │ └── TaskDetailViewModel.kt │ │ │ ├── tasks │ │ │ ├── TasksFilterType.kt │ │ │ ├── TasksScreen.kt │ │ │ └── TasksViewModel.kt │ │ │ └── util │ │ │ ├── Async.kt │ │ │ ├── ComposeUtils.kt │ │ │ ├── CoroutinesUtils.kt │ │ │ ├── SimpleCountingIdlingResource.kt │ │ │ ├── TodoDrawer.kt │ │ │ └── TopAppBars.kt │ └── res │ │ ├── drawable │ │ ├── drawer_item_color.xml │ │ ├── ic_add.xml │ │ ├── ic_assignment_turned_in_24dp.xml │ │ ├── ic_check_circle_96dp.xml │ │ ├── ic_done.xml │ │ ├── ic_edit.xml │ │ ├── ic_filter_list.xml │ │ ├── ic_list.xml │ │ ├── ic_menu.xml │ │ ├── ic_statistics.xml │ │ ├── ic_statistics_100dp.xml │ │ ├── ic_statistics_24dp.xml │ │ ├── ic_verified_user_96dp.xml │ │ ├── list_completed_touch_feedback.xml │ │ ├── logo_no_fill.png │ │ ├── touch_feedback.xml │ │ └── trash_icon.png │ │ ├── font │ │ ├── opensans_font.xml │ │ ├── opensans_regular.ttf │ │ └── opensans_semibold.ttf │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ ├── values-w820dp │ │ └── dimens.xml │ │ └── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ ├── java │ └── com │ │ └── example │ │ └── android │ │ └── architecture │ │ └── blueprints │ │ └── todoapp │ │ ├── addedittask │ │ └── AddEditTaskViewModelTest.kt │ │ ├── data │ │ └── DefaultTaskRepositoryTest.kt │ │ ├── statistics │ │ ├── StatisticsUtilsTest.kt │ │ └── StatisticsViewModelTest.kt │ │ ├── taskdetail │ │ └── TaskDetailViewModelTest.kt │ │ └── tasks │ │ └── TasksViewModelTest.kt │ └── resources │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker ├── build.gradle.kts ├── gradle.properties ├── gradle ├── init.gradle.kts ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renovate.json ├── screenshots ├── screenshots.png └── todoapp.gif ├── settings.gradle.kts ├── shared-test ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── example │ └── android │ └── architecture │ └── blueprints │ └── todoapp │ ├── CustomTestRunner.kt │ ├── MainCoroutineRule.kt │ ├── data │ ├── FakeTaskRepository.kt │ └── source │ │ ├── local │ │ └── FakeTaskDao.kt │ │ └── network │ │ └── FakeNetworkDataSource.kt │ └── di │ ├── DatabaseTestModule.kt │ └── RepositoryTestModule.kt └── spotless ├── copyright.kt ├── copyright.kts └── copyright.xml /.github/ci-gradle.properties: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2020 The Android Open Source Project 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | 17 | org.gradle.daemon=false 18 | org.gradle.parallel=true 19 | org.gradle.workers.max=2 20 | 21 | kotlin.incremental=false 22 | 23 | # Controls KotlinOptions.allWarningsAsErrors. 24 | # This value used in CI and is currently set to false. 25 | # If you want to treat warnings as errors locally, set this property to true 26 | # in your ~/.gradle/gradle.properties file. 27 | warningsAsErrors=false 28 | -------------------------------------------------------------------------------- /.github/workflows/build_test.yaml: -------------------------------------------------------------------------------- 1 | name: build_test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 30 16 | strategy: 17 | matrix: 18 | api-level: [29] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Enable KVM group perms 24 | run: | 25 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 26 | sudo udevadm control --reload-rules 27 | sudo udevadm trigger --name-match=kvm 28 | ls /dev/kvm 29 | 30 | - name: Copy CI gradle.properties 31 | run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties 32 | 33 | - name: Set Up JDK 34 | uses: actions/setup-java@v4 35 | with: 36 | distribution: 'zulu' # See 'Supported distributions' for available options 37 | java-version: '17' 38 | cache: 'gradle' 39 | 40 | - name: Setup Gradle 41 | uses: gradle/actions/setup-gradle@v4 42 | 43 | - name: Setup Android SDK 44 | uses: android-actions/setup-android@v3 45 | 46 | - name: Run instrumentation tests 47 | uses: reactivecircus/android-emulator-runner@v2 48 | with: 49 | api-level: ${{ matrix.api-level }} 50 | arch: x86 51 | disable-animations: true 52 | script: ./gradlew :app:connectedCheck --stacktrace 53 | 54 | - name: Upload test reports 55 | if: always() 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: test-reports-${{ matrix.api-level }} 59 | path: ./app/build/reports/androidTests 60 | -------------------------------------------------------------------------------- /.github/workflows/copy-branch.yml: -------------------------------------------------------------------------------- 1 | # Duplicates default main branch to the old master branch 2 | 3 | name: Duplicates main to old master branch 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the main branch 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches: [ main ] 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | # This workflow contains a single job called "copy-branch" 15 | copy-branch: 16 | # The type of runner that the job will run on 17 | runs-on: ubuntu-latest 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it, 22 | # but specifies master branch (old default). 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | ref: master 27 | 28 | - run: | 29 | git config user.name github-actions 30 | git config user.email github-actions@github.com 31 | git merge origin/main 32 | git push 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | local.properties 4 | .idea 5 | .DS_Store 6 | build 7 | captures 8 | .externalNativeBuild 9 | -------------------------------------------------------------------------------- /.google/packaging.yaml: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 The Android Open Source Project 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # GOOGLE SAMPLE PACKAGING DATA 16 | # 17 | # This file is used by Google as part of our samples packaging process. 18 | # End users may safely ignore this file. It has no relevance to other systems. 19 | --- 20 | status: PUBLISHED 21 | technologies: [Android, JetpackCompose, Coroutines] 22 | categories: 23 | - AndroidTesting 24 | - AndroidArchitecture 25 | - AndroidArchitectureUILayer 26 | - AndroidArchitectureDataLayer 27 | - AndroidArchitectureStateProduction 28 | - AndroidArchitectureStateHolder 29 | - AndroidArchitectureUIEvents 30 | - JetpackComposeTesting 31 | - JetpackComposeArchitectureAndState 32 | - JetpackComposeNavigation 33 | languages: [Kotlin] 34 | solutions: 35 | - Mobile 36 | - Flow 37 | - JetpackHilt 38 | - JetpackRoom 39 | - JetpackNavigation 40 | - JetpackLifecycle 41 | github: android/architecture-samples 42 | level: INTERMEDIATE 43 | license: apache2 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to become a contributor and submit your own code 2 | 3 | ## Contributor License Agreements 4 | 5 | We'd love to accept your patches! Before we can take them, we 6 | have to jump a couple of legal hurdles. 7 | 8 | ### Before you contribute 9 | Before we can use your code, you must sign the 10 | [Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual) 11 | (CLA), which you can do online. The CLA is necessary mainly because you own the 12 | copyright to your changes, even after your contribution becomes part of our 13 | codebase, so we need your permission to use and distribute your code. We also 14 | need to be sure of various other things—for instance that you'll tell us if you 15 | know that your code infringes on other people's patents. You don't have to sign 16 | the CLA until after you've submitted your code for review and a member has 17 | approved it, but you must do it before we can put your code into our codebase. 18 | Before you start working on a larger contribution, you should get in touch with 19 | us first through the issue tracker with your idea so that we can help out and 20 | possibly guide you. Coordinating up front makes it much easier to avoid 21 | frustration later on. 22 | 23 | ### Code reviews 24 | All submissions, including submissions by project members, require review. We 25 | use Github pull requests for this purpose. 26 | 27 | ### The small print 28 | Contributions made by corporations are covered by a different agreement than 29 | the one above, the 30 | [Software Grant and Corporate Contributor License Agreement](https://cla.developers.google.com/about/google-corporate). 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Architecture Samples 2 | 3 | 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. 4 | 5 | In this branch you'll find: 6 | * User Interface built with **[Jetpack Compose](https://developer.android.com/jetpack/compose)** 7 | * A single-activity architecture, using **[Navigation Compose](https://developer.android.com/jetpack/compose/navigation)**. 8 | * A presentation layer that contains a Compose screen (View) and a **ViewModel** per screen (or feature). 9 | * Reactive UIs using **[Flow](https://developer.android.com/kotlin/flow)** and **[coroutines](https://kotlinlang.org/docs/coroutines-overview.html)** for asynchronous operations. 10 | * A **data layer** with a repository and two data sources (local using Room and a fake remote). 11 | * Two **product flavors**, `mock` and `prod`, [to ease development and testing](https://android-developers.googleblog.com/2015/12/leveraging-product-flavors-in-android.html). 12 | * A collection of unit, integration and e2e **tests**, including "shared" tests that can be run on emulator/device. 13 | * Dependency injection using [Hilt](https://developer.android.com/training/dependency-injection/hilt-android). 14 | 15 | ## Screenshots 16 | 17 | Screenshot 18 | 19 | ## Why a to-do app? 20 | 21 | 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). 22 | 23 | ## What is it not? 24 | * A template. Check out the [Architecture Templates](https://github.com/android/architecture-templates) instead. 25 | * 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. 26 | * A real production app with network access, user authentication, etc. Check out the [Now in Android app](https://github.com/android/nowinandroid) instead. 27 | 28 | ## Who is it for? 29 | 30 | * Intermediate developers and beginners looking for a way to structure their app in a testable and maintainable way. 31 | * Advanced developers looking for quick reference. 32 | 33 | ## Opening a sample in Android Studio 34 | 35 | To open one of the samples in Android Studio, begin by checking out one of the sample branches, and then open the root directory in Android Studio. The following series of steps illustrate how to open the sample. 36 | 37 | Clone the repository: 38 | 39 | ``` 40 | git clone git@github.com:android/architecture-samples.git 41 | ``` 42 | 43 | Finally open the `architecture-samples/` directory in Android Studio. 44 | 45 | ### License 46 | 47 | 48 | ``` 49 | Copyright 2024 Google, Inc. 50 | 51 | Licensed to the Apache Software Foundation (ASF) under one or more contributor 52 | license agreements. See the NOTICE file distributed with this work for 53 | additional information regarding copyright ownership. The ASF licenses this 54 | file to you under the Apache License, Version 2.0 (the "License"); you may not 55 | use this file except in compliance with the License. You may obtain a copy of 56 | the License at 57 | 58 | http://www.apache.org/licenses/LICENSE-2.0 59 | 60 | Unless required by applicable law or agreed to in writing, software 61 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 62 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 63 | License for the specific language governing permissions and limitations under 64 | the License. 65 | ``` 66 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -dontoptimize 2 | 3 | # Some methods are only called from tests, so make sure the shrinker keeps them. 4 | -keep class com.example.android.architecture.blueprints.** { *; } 5 | 6 | -keep class androidx.drawerlayout.widget.DrawerLayout { *; } 7 | -keep class androidx.test.espresso.** 8 | # keep the class and specified members from being removed or renamed 9 | -keep class androidx.test.espresso.IdlingRegistry { *; } 10 | -keep class androidx.test.espresso.IdlingResource { *; } 11 | 12 | -keep class com.google.common.base.Preconditions { *; } 13 | 14 | -keep class androidx.room.RoomDataBase { *; } 15 | -keep class androidx.room.Room { *; } 16 | -keep class android.arch.** { *; } 17 | 18 | # Proguard rules that are applied to your test apk/code. 19 | -ignorewarnings 20 | 21 | -keepattributes *Annotation* 22 | 23 | -dontnote junit.framework.** 24 | -dontnote junit.runner.** 25 | 26 | -dontwarn androidx.test.** 27 | -dontwarn org.junit.** 28 | -dontwarn org.hamcrest.** 29 | -dontwarn com.squareup.javawriter.JavaWriter 30 | # Uncomment this if you use Mockito 31 | -dontwarn org.mockito.** 32 | -------------------------------------------------------------------------------- /app/proguardTest-rules.pro: -------------------------------------------------------------------------------- 1 | # Proguard rules that are applied to your test apk/code. 2 | -ignorewarnings 3 | -dontoptimize 4 | 5 | -keepattributes *Annotation* 6 | 7 | -keep class androidx.test.espresso.** 8 | # keep the class and specified members from being removed or renamed 9 | -keep class androidx.test.espresso.IdlingRegistry { *; } 10 | -keep class androidx.test.espresso.IdlingResource { *; } 11 | 12 | -dontnote junit.framework.** 13 | -dontnote junit.runner.** 14 | 15 | -dontwarn androidx.test.** 16 | -dontwarn org.junit.** 17 | -dontwarn org.hamcrest.** 18 | -dontwarn com.squareup.javawriter.JavaWriter 19 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreenTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.addedittask 18 | 19 | import androidx.compose.material3.Surface 20 | import androidx.compose.ui.test.SemanticsNodeInteraction 21 | import androidx.compose.ui.test.assertIsDisplayed 22 | import androidx.compose.ui.test.hasSetTextAction 23 | import androidx.compose.ui.test.hasText 24 | import androidx.compose.ui.test.junit4.createAndroidComposeRule 25 | import androidx.compose.ui.test.onNodeWithContentDescription 26 | import androidx.compose.ui.test.onNodeWithText 27 | import androidx.compose.ui.test.performClick 28 | import androidx.compose.ui.test.performTextClearance 29 | import androidx.compose.ui.test.performTextInput 30 | import androidx.lifecycle.SavedStateHandle 31 | import androidx.test.ext.junit.runners.AndroidJUnit4 32 | import androidx.test.filters.MediumTest 33 | import com.example.android.architecture.blueprints.todoapp.HiltTestActivity 34 | import com.example.android.architecture.blueprints.todoapp.R 35 | import com.example.android.architecture.blueprints.todoapp.TodoTheme 36 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 37 | import dagger.hilt.android.testing.HiltAndroidRule 38 | import dagger.hilt.android.testing.HiltAndroidTest 39 | import kotlinx.coroutines.ExperimentalCoroutinesApi 40 | import kotlinx.coroutines.test.runTest 41 | import org.junit.Assert.assertEquals 42 | import org.junit.Before 43 | import org.junit.Rule 44 | import org.junit.Test 45 | import org.junit.runner.RunWith 46 | import javax.inject.Inject 47 | 48 | /** 49 | * Integration test for the Add Task screen. 50 | */ 51 | @RunWith(AndroidJUnit4::class) 52 | @MediumTest 53 | @HiltAndroidTest 54 | @ExperimentalCoroutinesApi 55 | class AddEditTaskScreenTest { 56 | 57 | @get:Rule(order = 0) 58 | var hiltRule = HiltAndroidRule(this) 59 | 60 | @get:Rule(order = 1) 61 | val composeTestRule = createAndroidComposeRule() 62 | private val activity get() = composeTestRule.activity 63 | 64 | @Inject 65 | lateinit var repository: TaskRepository 66 | 67 | @Before 68 | fun setup() { 69 | hiltRule.inject() 70 | 71 | // GIVEN - On the "Add Task" screen. 72 | composeTestRule.setContent { 73 | TodoTheme { 74 | Surface { 75 | AddEditTaskScreen( 76 | viewModel = AddEditTaskViewModel(repository, SavedStateHandle()), 77 | topBarTitle = R.string.add_task, 78 | onTaskUpdate = { }, 79 | onBack = { }, 80 | ) 81 | } 82 | } 83 | } 84 | } 85 | 86 | @Test 87 | fun emptyTask_isNotSaved() { 88 | // WHEN - Enter invalid title and description combination and click save 89 | findTextField(R.string.title_hint).performTextClearance() 90 | findTextField(R.string.description_hint).performTextClearance() 91 | composeTestRule.onNodeWithContentDescription(activity.getString(R.string.cd_save_task)) 92 | .performClick() 93 | 94 | // THEN - Entered Task is still displayed (a correct task would close it). 95 | composeTestRule 96 | .onNodeWithText(activity.getString(R.string.empty_task_message)) 97 | .assertIsDisplayed() 98 | } 99 | 100 | @Test 101 | fun validTask_isSaved() = runTest { 102 | // WHEN - Valid title and description combination and click save 103 | findTextField(R.string.title_hint).performTextInput("title") 104 | findTextField(R.string.description_hint).performTextInput("description") 105 | composeTestRule.onNodeWithContentDescription(activity.getString(R.string.cd_save_task)) 106 | .performClick() 107 | 108 | // THEN - Verify that the repository saved the task 109 | val tasks = repository.getTasks(true) 110 | assertEquals(1, tasks.size) 111 | assertEquals("title", tasks[0].title) 112 | assertEquals("description", tasks[0].description) 113 | } 114 | 115 | private fun findTextField(text: Int): SemanticsNodeInteraction { 116 | return composeTestRule.onNode( 117 | hasSetTextAction() and hasText(activity.getString(text)) 118 | ) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreenTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.statistics 18 | 19 | import androidx.compose.material3.Surface 20 | import androidx.compose.ui.test.assertIsDisplayed 21 | import androidx.compose.ui.test.junit4.createAndroidComposeRule 22 | import androidx.compose.ui.test.onNodeWithText 23 | import androidx.test.ext.junit.runners.AndroidJUnit4 24 | import androidx.test.filters.MediumTest 25 | import com.example.android.architecture.blueprints.todoapp.HiltTestActivity 26 | import com.example.android.architecture.blueprints.todoapp.R 27 | import com.example.android.architecture.blueprints.todoapp.TodoTheme 28 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 29 | import dagger.hilt.android.testing.HiltAndroidRule 30 | import dagger.hilt.android.testing.HiltAndroidTest 31 | import kotlinx.coroutines.ExperimentalCoroutinesApi 32 | import kotlinx.coroutines.test.runTest 33 | import org.junit.Before 34 | import org.junit.Rule 35 | import org.junit.Test 36 | import org.junit.runner.RunWith 37 | import javax.inject.Inject 38 | 39 | /** 40 | * Integration test for the statistics screen. 41 | */ 42 | @RunWith(AndroidJUnit4::class) 43 | @MediumTest 44 | @HiltAndroidTest 45 | @ExperimentalCoroutinesApi 46 | class StatisticsScreenTest { 47 | 48 | @get:Rule(order = 0) 49 | var hiltRule = HiltAndroidRule(this) 50 | 51 | @get:Rule(order = 1) 52 | val composeTestRule = createAndroidComposeRule() 53 | private val activity get() = composeTestRule.activity 54 | 55 | @Inject 56 | lateinit var repository: TaskRepository 57 | 58 | @Before 59 | fun setup() { 60 | hiltRule.inject() 61 | } 62 | 63 | @Test 64 | fun tasks_showsNonEmptyMessage() = runTest { 65 | // Given some tasks 66 | repository.apply { 67 | createTask("Title1", "Description1") 68 | createTask("Title2", "Description2").also { 69 | completeTask(it) 70 | } 71 | } 72 | 73 | composeTestRule.setContent { 74 | TodoTheme { 75 | Surface { 76 | StatisticsScreen( 77 | openDrawer = { }, 78 | viewModel = StatisticsViewModel(repository) 79 | ) 80 | } 81 | } 82 | } 83 | 84 | val expectedActiveTaskText = activity.getString(R.string.statistics_active_tasks, 50.0f) 85 | val expectedCompletedTaskText = activity 86 | .getString(R.string.statistics_completed_tasks, 50.0f) 87 | 88 | // check that both info boxes are displayed and contain the correct info 89 | composeTestRule.onNodeWithText(expectedActiveTaskText).assertIsDisplayed() 90 | composeTestRule.onNodeWithText(expectedCompletedTaskText).assertIsDisplayed() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreenTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.taskdetail 18 | 19 | import androidx.compose.material3.Surface 20 | import androidx.compose.ui.test.assertIsDisplayed 21 | import androidx.compose.ui.test.assertIsOff 22 | import androidx.compose.ui.test.assertIsOn 23 | import androidx.compose.ui.test.isToggleable 24 | import androidx.compose.ui.test.junit4.createAndroidComposeRule 25 | import androidx.compose.ui.test.onNodeWithText 26 | import androidx.lifecycle.SavedStateHandle 27 | import androidx.test.ext.junit.runners.AndroidJUnit4 28 | import androidx.test.filters.MediumTest 29 | import com.example.android.architecture.blueprints.todoapp.HiltTestActivity 30 | import com.example.android.architecture.blueprints.todoapp.TodoTheme 31 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 32 | import dagger.hilt.android.testing.HiltAndroidRule 33 | import dagger.hilt.android.testing.HiltAndroidTest 34 | import kotlinx.coroutines.ExperimentalCoroutinesApi 35 | import kotlinx.coroutines.test.runTest 36 | import org.junit.Before 37 | import org.junit.Rule 38 | import org.junit.Test 39 | import org.junit.runner.RunWith 40 | import javax.inject.Inject 41 | 42 | /** 43 | * Integration test for the Task Details screen. 44 | */ 45 | @MediumTest 46 | @RunWith(AndroidJUnit4::class) 47 | @HiltAndroidTest 48 | @ExperimentalCoroutinesApi 49 | class TaskDetailScreenTest { 50 | 51 | @get:Rule(order = 0) 52 | var hiltRule = HiltAndroidRule(this) 53 | 54 | @get:Rule(order = 1) 55 | val composeTestRule = createAndroidComposeRule() 56 | 57 | @Inject 58 | lateinit var repository: TaskRepository 59 | 60 | @Before 61 | fun setup() { 62 | hiltRule.inject() 63 | } 64 | 65 | @Test 66 | fun activeTaskDetails_DisplayedInUi() = runTest { 67 | // GIVEN - Add active (incomplete) task to the DB 68 | val activeTaskId = repository.createTask( 69 | title = "Active Task", 70 | description = "AndroidX Rocks" 71 | ) 72 | 73 | // WHEN - Details screen is opened 74 | setContent(activeTaskId) 75 | 76 | // THEN - Task details are displayed on the screen 77 | // make sure that the title/description are both shown and correct 78 | composeTestRule.onNodeWithText("Active Task").assertIsDisplayed() 79 | composeTestRule.onNodeWithText("AndroidX Rocks").assertIsDisplayed() 80 | // and make sure the "active" checkbox is shown unchecked 81 | composeTestRule.onNode(isToggleable()).assertIsOff() 82 | } 83 | 84 | @Test 85 | fun completedTaskDetails_DisplayedInUi() = runTest { 86 | // GIVEN - Add completed task to the DB 87 | val completedTaskId = repository.createTask("Completed Task", "AndroidX Rocks") 88 | repository.completeTask(completedTaskId) 89 | 90 | // WHEN - Details screen is opened 91 | setContent(completedTaskId) 92 | 93 | // THEN - Task details are displayed on the screen 94 | // make sure that the title/description are both shown and correct 95 | composeTestRule.onNodeWithText("Completed Task").assertIsDisplayed() 96 | composeTestRule.onNodeWithText("AndroidX Rocks").assertIsDisplayed() 97 | // and make sure the "active" checkbox is shown unchecked 98 | composeTestRule.onNode(isToggleable()).assertIsOn() 99 | } 100 | 101 | private fun setContent(activeTaskId: String) { 102 | composeTestRule.setContent { 103 | TodoTheme { 104 | Surface { 105 | TaskDetailScreen( 106 | viewModel = TaskDetailViewModel( 107 | repository, 108 | SavedStateHandle(mapOf("taskId" to activeTaskId)) 109 | ), 110 | onEditTask = { /*TODO*/ }, 111 | onBack = { }, 112 | onDeleteTask = { }, 113 | ) 114 | } 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/debug/java/com/example/android/architecture/blueprints/todoapp/HiltTestActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp 18 | 19 | import androidx.activity.ComponentActivity 20 | import dagger.hilt.android.AndroidEntryPoint 21 | 22 | @AndroidEntryPoint 23 | class HiltTestActivity : ComponentActivity() 24 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 26 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp 18 | 19 | import android.os.Bundle 20 | import androidx.activity.ComponentActivity 21 | import androidx.activity.compose.setContent 22 | import androidx.activity.enableEdgeToEdge 23 | import dagger.hilt.android.AndroidEntryPoint 24 | 25 | /** 26 | * Main activity for the todoapp 27 | */ 28 | @AndroidEntryPoint 29 | class TodoActivity : ComponentActivity() { 30 | 31 | override fun onCreate(savedInstanceState: Bundle?) { 32 | super.onCreate(savedInstanceState) 33 | enableEdgeToEdge() 34 | setContent { 35 | TodoTheme { 36 | TodoNavGraph() 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoApplication.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp 18 | 19 | import android.app.Application 20 | import dagger.hilt.android.HiltAndroidApp 21 | import timber.log.Timber 22 | import timber.log.Timber.DebugTree 23 | 24 | /** 25 | * Application that sets up Timber in the DEBUG BuildConfig. 26 | * Read Timber's documentation for production setups. 27 | */ 28 | @HiltAndroidApp 29 | class TodoApplication : Application() { 30 | 31 | override fun onCreate() { 32 | super.onCreate() 33 | if (BuildConfig.DEBUG) Timber.plant(DebugTree()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavGraph.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp 18 | 19 | import android.app.Activity 20 | import androidx.compose.material3.DrawerState 21 | import androidx.compose.material3.DrawerValue 22 | import androidx.compose.material3.rememberDrawerState 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.getValue 25 | import androidx.compose.runtime.remember 26 | import androidx.compose.runtime.rememberCoroutineScope 27 | import androidx.compose.ui.Modifier 28 | import androidx.navigation.NavHostController 29 | import androidx.navigation.NavType 30 | import androidx.navigation.compose.NavHost 31 | import androidx.navigation.compose.composable 32 | import androidx.navigation.compose.currentBackStackEntryAsState 33 | import androidx.navigation.compose.rememberNavController 34 | import androidx.navigation.navArgument 35 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.TASK_ID_ARG 36 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.TITLE_ARG 37 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.USER_MESSAGE_ARG 38 | import com.example.android.architecture.blueprints.todoapp.addedittask.AddEditTaskScreen 39 | import com.example.android.architecture.blueprints.todoapp.statistics.StatisticsScreen 40 | import com.example.android.architecture.blueprints.todoapp.taskdetail.TaskDetailScreen 41 | import com.example.android.architecture.blueprints.todoapp.tasks.TasksScreen 42 | import com.example.android.architecture.blueprints.todoapp.util.AppModalDrawer 43 | import kotlinx.coroutines.CoroutineScope 44 | import kotlinx.coroutines.launch 45 | 46 | @Composable 47 | fun TodoNavGraph( 48 | modifier: Modifier = Modifier, 49 | navController: NavHostController = rememberNavController(), 50 | coroutineScope: CoroutineScope = rememberCoroutineScope(), 51 | drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed), 52 | startDestination: String = TodoDestinations.TASKS_ROUTE, 53 | navActions: TodoNavigationActions = remember(navController) { 54 | TodoNavigationActions(navController) 55 | } 56 | ) { 57 | val currentNavBackStackEntry by navController.currentBackStackEntryAsState() 58 | val currentRoute = currentNavBackStackEntry?.destination?.route ?: startDestination 59 | 60 | NavHost( 61 | navController = navController, 62 | startDestination = startDestination, 63 | modifier = modifier 64 | ) { 65 | composable( 66 | TodoDestinations.TASKS_ROUTE, 67 | arguments = listOf( 68 | navArgument(USER_MESSAGE_ARG) { type = NavType.IntType; defaultValue = 0 } 69 | ) 70 | ) { entry -> 71 | AppModalDrawer(drawerState, currentRoute, navActions) { 72 | TasksScreen( 73 | userMessage = entry.arguments?.getInt(USER_MESSAGE_ARG)!!, 74 | onUserMessageDisplayed = { entry.arguments?.putInt(USER_MESSAGE_ARG, 0) }, 75 | onAddTask = { navActions.navigateToAddEditTask(R.string.add_task, null) }, 76 | onTaskClick = { task -> navActions.navigateToTaskDetail(task.id) }, 77 | openDrawer = { coroutineScope.launch { drawerState.open() } } 78 | ) 79 | } 80 | } 81 | composable(TodoDestinations.STATISTICS_ROUTE) { 82 | AppModalDrawer(drawerState, currentRoute, navActions) { 83 | StatisticsScreen(openDrawer = { coroutineScope.launch { drawerState.open() } }) 84 | } 85 | } 86 | composable( 87 | TodoDestinations.ADD_EDIT_TASK_ROUTE, 88 | arguments = listOf( 89 | navArgument(TITLE_ARG) { type = NavType.IntType }, 90 | navArgument(TASK_ID_ARG) { type = NavType.StringType; nullable = true }, 91 | ) 92 | ) { entry -> 93 | val taskId = entry.arguments?.getString(TASK_ID_ARG) 94 | AddEditTaskScreen( 95 | topBarTitle = entry.arguments?.getInt(TITLE_ARG)!!, 96 | onTaskUpdate = { 97 | navActions.navigateToTasks( 98 | if (taskId == null) ADD_EDIT_RESULT_OK else EDIT_RESULT_OK 99 | ) 100 | }, 101 | onBack = { navController.popBackStack() } 102 | ) 103 | } 104 | composable(TodoDestinations.TASK_DETAIL_ROUTE) { 105 | TaskDetailScreen( 106 | onEditTask = { taskId -> 107 | navActions.navigateToAddEditTask(R.string.edit_task, taskId) 108 | }, 109 | onBack = { navController.popBackStack() }, 110 | onDeleteTask = { navActions.navigateToTasks(DELETE_RESULT_OK) } 111 | ) 112 | } 113 | } 114 | } 115 | 116 | // Keys for navigation 117 | const val ADD_EDIT_RESULT_OK = Activity.RESULT_FIRST_USER + 1 118 | const val DELETE_RESULT_OK = Activity.RESULT_FIRST_USER + 2 119 | const val EDIT_RESULT_OK = Activity.RESULT_FIRST_USER + 3 120 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoNavigation.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp 18 | 19 | import androidx.navigation.NavGraph.Companion.findStartDestination 20 | import androidx.navigation.NavHostController 21 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.TASK_ID_ARG 22 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.TITLE_ARG 23 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs.USER_MESSAGE_ARG 24 | import com.example.android.architecture.blueprints.todoapp.TodoScreens.ADD_EDIT_TASK_SCREEN 25 | import com.example.android.architecture.blueprints.todoapp.TodoScreens.STATISTICS_SCREEN 26 | import com.example.android.architecture.blueprints.todoapp.TodoScreens.TASKS_SCREEN 27 | import com.example.android.architecture.blueprints.todoapp.TodoScreens.TASK_DETAIL_SCREEN 28 | 29 | /** 30 | * Screens used in [TodoDestinations] 31 | */ 32 | private object TodoScreens { 33 | const val TASKS_SCREEN = "tasks" 34 | const val STATISTICS_SCREEN = "statistics" 35 | const val TASK_DETAIL_SCREEN = "task" 36 | const val ADD_EDIT_TASK_SCREEN = "addEditTask" 37 | } 38 | 39 | /** 40 | * Arguments used in [TodoDestinations] routes 41 | */ 42 | object TodoDestinationsArgs { 43 | const val USER_MESSAGE_ARG = "userMessage" 44 | const val TASK_ID_ARG = "taskId" 45 | const val TITLE_ARG = "title" 46 | } 47 | 48 | /** 49 | * Destinations used in the [TodoActivity] 50 | */ 51 | object TodoDestinations { 52 | const val TASKS_ROUTE = "$TASKS_SCREEN?$USER_MESSAGE_ARG={$USER_MESSAGE_ARG}" 53 | const val STATISTICS_ROUTE = STATISTICS_SCREEN 54 | const val TASK_DETAIL_ROUTE = "$TASK_DETAIL_SCREEN/{$TASK_ID_ARG}" 55 | const val ADD_EDIT_TASK_ROUTE = "$ADD_EDIT_TASK_SCREEN/{$TITLE_ARG}?$TASK_ID_ARG={$TASK_ID_ARG}" 56 | } 57 | 58 | /** 59 | * Models the navigation actions in the app. 60 | */ 61 | class TodoNavigationActions(private val navController: NavHostController) { 62 | 63 | fun navigateToTasks(userMessage: Int = 0) { 64 | val navigatesFromDrawer = userMessage == 0 65 | navController.navigate( 66 | TASKS_SCREEN.let { 67 | if (userMessage != 0) "$it?$USER_MESSAGE_ARG=$userMessage" else it 68 | } 69 | ) { 70 | popUpTo(navController.graph.findStartDestination().id) { 71 | inclusive = !navigatesFromDrawer 72 | saveState = navigatesFromDrawer 73 | } 74 | launchSingleTop = true 75 | restoreState = navigatesFromDrawer 76 | } 77 | } 78 | 79 | fun navigateToStatistics() { 80 | navController.navigate(TodoDestinations.STATISTICS_ROUTE) { 81 | // Pop up to the start destination of the graph to 82 | // avoid building up a large stack of destinations 83 | // on the back stack as users select items 84 | popUpTo(navController.graph.findStartDestination().id) { 85 | saveState = true 86 | } 87 | // Avoid multiple copies of the same destination when 88 | // reselecting the same item 89 | launchSingleTop = true 90 | // Restore state when reselecting a previously selected item 91 | restoreState = true 92 | } 93 | } 94 | 95 | fun navigateToTaskDetail(taskId: String) { 96 | navController.navigate("$TASK_DETAIL_SCREEN/$taskId") 97 | } 98 | 99 | fun navigateToAddEditTask(title: Int, taskId: String?) { 100 | navController.navigate( 101 | "$ADD_EDIT_TASK_SCREEN/$title".let { 102 | if (taskId != null) "$it?$TASK_ID_ARG=$taskId" else it 103 | } 104 | ) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/TodoTheme.kt: -------------------------------------------------------------------------------- 1 | package com.example.android.architecture.blueprints.todoapp 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.lightColorScheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.graphics.Color 7 | 8 | @Composable 9 | fun TodoTheme(content: @Composable () -> Unit) { 10 | MaterialTheme( 11 | colorScheme = lightColorScheme( 12 | primary = Color(0xFF263238), 13 | secondary = Color(0xFF2E7D32), 14 | tertiary = Color(0xFFCCCCCC), 15 | ) 16 | ) { 17 | content() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskScreen.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:OptIn(ExperimentalMaterial3Api::class) 18 | 19 | package com.example.android.architecture.blueprints.todoapp.addedittask 20 | 21 | import androidx.annotation.StringRes 22 | import androidx.compose.foundation.layout.Column 23 | import androidx.compose.foundation.layout.fillMaxSize 24 | import androidx.compose.foundation.layout.fillMaxWidth 25 | import androidx.compose.foundation.layout.height 26 | import androidx.compose.foundation.layout.padding 27 | import androidx.compose.foundation.rememberScrollState 28 | import androidx.compose.foundation.verticalScroll 29 | import androidx.compose.material.icons.Icons 30 | import androidx.compose.material.icons.filled.Done 31 | import androidx.compose.material3.ExperimentalMaterial3Api 32 | import androidx.compose.material3.Icon 33 | import androidx.compose.material3.MaterialTheme 34 | import androidx.compose.material3.OutlinedTextField 35 | import androidx.compose.material3.OutlinedTextFieldDefaults 36 | import androidx.compose.material3.Scaffold 37 | import androidx.compose.material3.SmallFloatingActionButton 38 | import androidx.compose.material3.SnackbarHost 39 | import androidx.compose.material3.SnackbarHostState 40 | import androidx.compose.material3.Text 41 | import androidx.compose.material3.pulltorefresh.PullToRefreshBox 42 | import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState 43 | import androidx.compose.runtime.Composable 44 | import androidx.compose.runtime.LaunchedEffect 45 | import androidx.compose.runtime.getValue 46 | import androidx.compose.runtime.mutableStateOf 47 | import androidx.compose.runtime.remember 48 | import androidx.compose.runtime.setValue 49 | import androidx.compose.ui.Modifier 50 | import androidx.compose.ui.graphics.Color 51 | import androidx.compose.ui.res.dimensionResource 52 | import androidx.compose.ui.res.stringResource 53 | import androidx.compose.ui.text.font.FontWeight 54 | import androidx.compose.ui.unit.dp 55 | import androidx.hilt.navigation.compose.hiltViewModel 56 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 57 | import com.example.android.architecture.blueprints.todoapp.R 58 | import com.example.android.architecture.blueprints.todoapp.util.AddEditTaskTopAppBar 59 | 60 | @Composable 61 | fun AddEditTaskScreen( 62 | @StringRes topBarTitle: Int, 63 | onTaskUpdate: () -> Unit, 64 | onBack: () -> Unit, 65 | modifier: Modifier = Modifier, 66 | viewModel: AddEditTaskViewModel = hiltViewModel(), 67 | snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } 68 | ) { 69 | Scaffold( 70 | modifier = modifier.fillMaxSize(), 71 | snackbarHost = { SnackbarHost(snackbarHostState) }, 72 | topBar = { AddEditTaskTopAppBar(topBarTitle, onBack) }, 73 | floatingActionButton = { 74 | SmallFloatingActionButton(onClick = viewModel::saveTask) { 75 | Icon(Icons.Filled.Done, stringResource(id = R.string.cd_save_task)) 76 | } 77 | } 78 | ) { paddingValues -> 79 | val uiState by viewModel.uiState.collectAsStateWithLifecycle() 80 | 81 | AddEditTaskContent( 82 | loading = uiState.isLoading, 83 | title = uiState.title, 84 | description = uiState.description, 85 | onTitleChanged = viewModel::updateTitle, 86 | onDescriptionChanged = viewModel::updateDescription, 87 | modifier = Modifier.padding(paddingValues) 88 | ) 89 | 90 | // Check if the task is saved and call onTaskUpdate event 91 | LaunchedEffect(uiState.isTaskSaved) { 92 | if (uiState.isTaskSaved) { 93 | onTaskUpdate() 94 | } 95 | } 96 | 97 | // Check for user messages to display on the screen 98 | uiState.userMessage?.let { userMessage -> 99 | val snackbarText = stringResource(userMessage) 100 | LaunchedEffect(snackbarHostState, viewModel, userMessage, snackbarText) { 101 | snackbarHostState.showSnackbar(snackbarText) 102 | viewModel.snackbarMessageShown() 103 | } 104 | } 105 | } 106 | } 107 | 108 | @Composable 109 | private fun AddEditTaskContent( 110 | loading: Boolean, 111 | title: String, 112 | description: String, 113 | onTitleChanged: (String) -> Unit, 114 | onDescriptionChanged: (String) -> Unit, 115 | modifier: Modifier = Modifier 116 | ) { 117 | var isRefreshing by remember { mutableStateOf(false) } 118 | val refreshingState = rememberPullToRefreshState() 119 | if (loading) { 120 | PullToRefreshBox( 121 | isRefreshing = isRefreshing, 122 | state = refreshingState, 123 | onRefresh = { /* DO NOTHING */ }, 124 | content = { } 125 | ) 126 | } else { 127 | Column( 128 | modifier 129 | .fillMaxWidth() 130 | .padding(all = dimensionResource(id = R.dimen.horizontal_margin)) 131 | .verticalScroll(rememberScrollState()) 132 | ) { 133 | val textFieldColors = OutlinedTextFieldDefaults.colors( 134 | focusedBorderColor = Color.Transparent, 135 | unfocusedBorderColor = Color.Transparent, 136 | cursorColor = MaterialTheme.colorScheme.onSecondary 137 | ) 138 | OutlinedTextField( 139 | value = title, 140 | modifier = Modifier.fillMaxWidth(), 141 | onValueChange = onTitleChanged, 142 | placeholder = { 143 | Text( 144 | text = stringResource(id = R.string.title_hint), 145 | style = MaterialTheme.typography.headlineSmall 146 | ) 147 | }, 148 | textStyle = MaterialTheme.typography.headlineSmall 149 | .copy(fontWeight = FontWeight.Bold), 150 | maxLines = 1, 151 | colors = textFieldColors 152 | ) 153 | OutlinedTextField( 154 | value = description, 155 | onValueChange = onDescriptionChanged, 156 | placeholder = { Text(stringResource(id = R.string.description_hint)) }, 157 | modifier = Modifier 158 | .height(350.dp) 159 | .fillMaxWidth(), 160 | colors = textFieldColors 161 | ) 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.addedittask 18 | 19 | import androidx.lifecycle.SavedStateHandle 20 | import androidx.lifecycle.ViewModel 21 | import androidx.lifecycle.viewModelScope 22 | import com.example.android.architecture.blueprints.todoapp.R 23 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs 24 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 25 | import dagger.hilt.android.lifecycle.HiltViewModel 26 | import kotlinx.coroutines.flow.MutableStateFlow 27 | import kotlinx.coroutines.flow.StateFlow 28 | import kotlinx.coroutines.flow.asStateFlow 29 | import kotlinx.coroutines.flow.update 30 | import kotlinx.coroutines.launch 31 | import javax.inject.Inject 32 | 33 | /** 34 | * UiState for the Add/Edit screen 35 | */ 36 | data class AddEditTaskUiState( 37 | val title: String = "", 38 | val description: String = "", 39 | val isTaskCompleted: Boolean = false, 40 | val isLoading: Boolean = false, 41 | val userMessage: Int? = null, 42 | val isTaskSaved: Boolean = false 43 | ) 44 | 45 | /** 46 | * ViewModel for the Add/Edit screen. 47 | */ 48 | @HiltViewModel 49 | class AddEditTaskViewModel @Inject constructor( 50 | private val taskRepository: TaskRepository, 51 | savedStateHandle: SavedStateHandle 52 | ) : ViewModel() { 53 | 54 | private val taskId: String? = savedStateHandle[TodoDestinationsArgs.TASK_ID_ARG] 55 | 56 | // A MutableStateFlow needs to be created in this ViewModel. The source of truth of the current 57 | // editable Task is the ViewModel, we need to mutate the UI state directly in methods such as 58 | // `updateTitle` or `updateDescription` 59 | private val _uiState = MutableStateFlow(AddEditTaskUiState()) 60 | val uiState: StateFlow = _uiState.asStateFlow() 61 | 62 | init { 63 | if (taskId != null) { 64 | loadTask(taskId) 65 | } 66 | } 67 | 68 | // Called when clicking on fab. 69 | fun saveTask() { 70 | if (uiState.value.title.isEmpty() || uiState.value.description.isEmpty()) { 71 | _uiState.update { 72 | it.copy(userMessage = R.string.empty_task_message) 73 | } 74 | return 75 | } 76 | 77 | if (taskId == null) { 78 | createNewTask() 79 | } else { 80 | updateTask() 81 | } 82 | } 83 | 84 | fun snackbarMessageShown() { 85 | _uiState.update { 86 | it.copy(userMessage = null) 87 | } 88 | } 89 | 90 | fun updateTitle(newTitle: String) { 91 | _uiState.update { 92 | it.copy(title = newTitle) 93 | } 94 | } 95 | 96 | fun updateDescription(newDescription: String) { 97 | _uiState.update { 98 | it.copy(description = newDescription) 99 | } 100 | } 101 | 102 | private fun createNewTask() = viewModelScope.launch { 103 | taskRepository.createTask(uiState.value.title, uiState.value.description) 104 | _uiState.update { 105 | it.copy(isTaskSaved = true) 106 | } 107 | } 108 | 109 | private fun updateTask() { 110 | if (taskId == null) { 111 | throw RuntimeException("updateTask() was called but task is new.") 112 | } 113 | viewModelScope.launch { 114 | taskRepository.updateTask( 115 | taskId, 116 | title = uiState.value.title, 117 | description = uiState.value.description, 118 | ) 119 | _uiState.update { 120 | it.copy(isTaskSaved = true) 121 | } 122 | } 123 | } 124 | 125 | private fun loadTask(taskId: String) { 126 | _uiState.update { 127 | it.copy(isLoading = true) 128 | } 129 | viewModelScope.launch { 130 | taskRepository.getTask(taskId).let { task -> 131 | if (task != null) { 132 | _uiState.update { 133 | it.copy( 134 | title = task.title, 135 | description = task.description, 136 | isTaskCompleted = task.isCompleted, 137 | isLoading = false 138 | ) 139 | } 140 | } else { 141 | _uiState.update { 142 | it.copy(isLoading = false) 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data 18 | 19 | import com.example.android.architecture.blueprints.todoapp.data.source.local.TaskDao 20 | import com.example.android.architecture.blueprints.todoapp.data.source.network.NetworkDataSource 21 | import com.example.android.architecture.blueprints.todoapp.di.ApplicationScope 22 | import com.example.android.architecture.blueprints.todoapp.di.DefaultDispatcher 23 | import kotlinx.coroutines.CoroutineDispatcher 24 | import kotlinx.coroutines.CoroutineScope 25 | import kotlinx.coroutines.flow.Flow 26 | import kotlinx.coroutines.flow.map 27 | import kotlinx.coroutines.launch 28 | import kotlinx.coroutines.withContext 29 | import java.util.UUID 30 | import javax.inject.Inject 31 | import javax.inject.Singleton 32 | 33 | /** 34 | * Default implementation of [TaskRepository]. Single entry point for managing tasks' data. 35 | * 36 | * @param networkDataSource - The network data source 37 | * @param localDataSource - The local data source 38 | * @param dispatcher - The dispatcher to be used for long running or complex operations, such as ID 39 | * generation or mapping many models. 40 | * @param scope - The coroutine scope used for deferred jobs where the result isn't important, such 41 | * as sending data to the network. 42 | */ 43 | @Singleton 44 | class DefaultTaskRepository @Inject constructor( 45 | private val networkDataSource: NetworkDataSource, 46 | private val localDataSource: TaskDao, 47 | @DefaultDispatcher private val dispatcher: CoroutineDispatcher, 48 | @ApplicationScope private val scope: CoroutineScope, 49 | ) : TaskRepository { 50 | 51 | override suspend fun createTask(title: String, description: String): String { 52 | // ID creation might be a complex operation so it's executed using the supplied 53 | // coroutine dispatcher 54 | val taskId = withContext(dispatcher) { 55 | UUID.randomUUID().toString() 56 | } 57 | val task = Task( 58 | title = title, 59 | description = description, 60 | id = taskId, 61 | ) 62 | localDataSource.upsert(task.toLocal()) 63 | saveTasksToNetwork() 64 | return taskId 65 | } 66 | 67 | override suspend fun updateTask(taskId: String, title: String, description: String) { 68 | val task = getTask(taskId)?.copy( 69 | title = title, 70 | description = description 71 | ) ?: throw Exception("Task (id $taskId) not found") 72 | 73 | localDataSource.upsert(task.toLocal()) 74 | saveTasksToNetwork() 75 | } 76 | 77 | override suspend fun getTasks(forceUpdate: Boolean): List { 78 | if (forceUpdate) { 79 | refresh() 80 | } 81 | return withContext(dispatcher) { 82 | localDataSource.getAll().toExternal() 83 | } 84 | } 85 | 86 | override fun getTasksStream(): Flow> { 87 | return localDataSource.observeAll().map { tasks -> 88 | withContext(dispatcher) { 89 | tasks.toExternal() 90 | } 91 | } 92 | } 93 | 94 | override suspend fun refreshTask(taskId: String) { 95 | refresh() 96 | } 97 | 98 | override fun getTaskStream(taskId: String): Flow { 99 | return localDataSource.observeById(taskId).map { it.toExternal() } 100 | } 101 | 102 | /** 103 | * Get a Task with the given ID. Will return null if the task cannot be found. 104 | * 105 | * @param taskId - The ID of the task 106 | * @param forceUpdate - true if the task should be updated from the network data source first. 107 | */ 108 | override suspend fun getTask(taskId: String, forceUpdate: Boolean): Task? { 109 | if (forceUpdate) { 110 | refresh() 111 | } 112 | return localDataSource.getById(taskId)?.toExternal() 113 | } 114 | 115 | override suspend fun completeTask(taskId: String) { 116 | localDataSource.updateCompleted(taskId = taskId, completed = true) 117 | saveTasksToNetwork() 118 | } 119 | 120 | override suspend fun activateTask(taskId: String) { 121 | localDataSource.updateCompleted(taskId = taskId, completed = false) 122 | saveTasksToNetwork() 123 | } 124 | 125 | override suspend fun clearCompletedTasks() { 126 | localDataSource.deleteCompleted() 127 | saveTasksToNetwork() 128 | } 129 | 130 | override suspend fun deleteAllTasks() { 131 | localDataSource.deleteAll() 132 | saveTasksToNetwork() 133 | } 134 | 135 | override suspend fun deleteTask(taskId: String) { 136 | localDataSource.deleteById(taskId) 137 | saveTasksToNetwork() 138 | } 139 | 140 | /** 141 | * The following methods load tasks from (refresh), and save tasks to, the network. 142 | * 143 | * Real apps may want to do a proper sync, rather than the "one-way sync everything" approach 144 | * below. See https://developer.android.com/topic/architecture/data-layer/offline-first 145 | * for more efficient and robust synchronisation strategies. 146 | * 147 | * Note that the refresh operation is a suspend function (forces callers to wait) and the save 148 | * operation is not. It returns immediately so callers don't have to wait. 149 | */ 150 | 151 | /** 152 | * Delete everything in the local data source and replace it with everything from the network 153 | * data source. 154 | * 155 | * `withContext` is used here in case the bulk `toLocal` mapping operation is complex. 156 | */ 157 | override suspend fun refresh() { 158 | withContext(dispatcher) { 159 | val remoteTasks = networkDataSource.loadTasks() 160 | localDataSource.deleteAll() 161 | localDataSource.upsertAll(remoteTasks.toLocal()) 162 | } 163 | } 164 | 165 | /** 166 | * Send the tasks from the local data source to the network data source 167 | * 168 | * Returns immediately after launching the job. Real apps may want to suspend here until the 169 | * operation is complete or (better) use WorkManager to schedule this work. Both approaches 170 | * should provide a mechanism for failures to be communicated back to the user so that 171 | * they are aware that their data isn't being backed up. 172 | */ 173 | private fun saveTasksToNetwork() { 174 | scope.launch { 175 | try { 176 | val localTasks = localDataSource.getAll() 177 | val networkTasks = withContext(dispatcher) { 178 | localTasks.toNetwork() 179 | } 180 | networkDataSource.saveTasks(networkTasks) 181 | } catch (e: Exception) { 182 | // In a real app you'd handle the exception e.g. by exposing a `networkStatus` flow 183 | // to an app level UI state holder which could then display a Toast message. 184 | } 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/ModelMappingExt.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data 18 | 19 | import com.example.android.architecture.blueprints.todoapp.data.source.local.LocalTask 20 | import com.example.android.architecture.blueprints.todoapp.data.source.network.NetworkTask 21 | import com.example.android.architecture.blueprints.todoapp.data.source.network.TaskStatus 22 | 23 | /** 24 | * Data model mapping extension functions. There are three model types: 25 | * 26 | * - Task: External model exposed to other layers in the architecture. 27 | * Obtained using `toExternal`. 28 | * 29 | * - NetworkTask: Internal model used to represent a task from the network. Obtained using 30 | * `toNetwork`. 31 | * 32 | * - LocalTask: Internal model used to represent a task stored locally in a database. Obtained 33 | * using `toLocal`. 34 | * 35 | */ 36 | 37 | // External to local 38 | fun Task.toLocal() = LocalTask( 39 | id = id, 40 | title = title, 41 | description = description, 42 | isCompleted = isCompleted, 43 | ) 44 | 45 | fun List.toLocal() = map(Task::toLocal) 46 | 47 | // Local to External 48 | fun LocalTask.toExternal() = Task( 49 | id = id, 50 | title = title, 51 | description = description, 52 | isCompleted = isCompleted, 53 | ) 54 | 55 | // Note: JvmName is used to provide a unique name for each extension function with the same name. 56 | // Without this, type erasure will cause compiler errors because these methods will have the same 57 | // signature on the JVM. 58 | @JvmName("localToExternal") 59 | fun List.toExternal() = map(LocalTask::toExternal) 60 | 61 | // Network to Local 62 | fun NetworkTask.toLocal() = LocalTask( 63 | id = id, 64 | title = title, 65 | description = shortDescription, 66 | isCompleted = (status == TaskStatus.COMPLETE), 67 | ) 68 | 69 | @JvmName("networkToLocal") 70 | fun List.toLocal() = map(NetworkTask::toLocal) 71 | 72 | // Local to Network 73 | fun LocalTask.toNetwork() = NetworkTask( 74 | id = id, 75 | title = title, 76 | shortDescription = description, 77 | status = if (isCompleted) { TaskStatus.COMPLETE } else { TaskStatus.ACTIVE } 78 | ) 79 | 80 | fun List.toNetwork() = map(LocalTask::toNetwork) 81 | 82 | // External to Network 83 | fun Task.toNetwork() = toLocal().toNetwork() 84 | 85 | @JvmName("externalToNetwork") 86 | fun List.toNetwork() = map(Task::toNetwork) 87 | 88 | // Network to External 89 | fun NetworkTask.toExternal() = toLocal().toExternal() 90 | 91 | @JvmName("networkToExternal") 92 | fun List.toExternal() = map(NetworkTask::toExternal) 93 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/Task.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data 18 | 19 | /** 20 | * Immutable model class for a Task. 21 | * 22 | * @param title title of the task 23 | * @param description description of the task 24 | * @param isCompleted whether or not this task is completed 25 | * @param id id of the task 26 | * 27 | * TODO: The constructor of this class should be `internal` but it is used in previews and tests 28 | * so that's not possible until those previews/tests are refactored. 29 | */ 30 | data class Task( 31 | val title: String = "", 32 | val description: String = "", 33 | val isCompleted: Boolean = false, 34 | val id: String, 35 | ) { 36 | 37 | val titleForList: String 38 | get() = if (title.isNotEmpty()) title else description 39 | 40 | val isActive 41 | get() = !isCompleted 42 | 43 | val isEmpty 44 | get() = title.isEmpty() || description.isEmpty() 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data 18 | 19 | import kotlinx.coroutines.flow.Flow 20 | 21 | /** 22 | * Interface to the data layer. 23 | */ 24 | interface TaskRepository { 25 | 26 | fun getTasksStream(): Flow> 27 | 28 | suspend fun getTasks(forceUpdate: Boolean = false): List 29 | 30 | suspend fun refresh() 31 | 32 | fun getTaskStream(taskId: String): Flow 33 | 34 | suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Task? 35 | 36 | suspend fun refreshTask(taskId: String) 37 | 38 | suspend fun createTask(title: String, description: String): String 39 | 40 | suspend fun updateTask(taskId: String, title: String, description: String) 41 | 42 | suspend fun completeTask(taskId: String) 43 | 44 | suspend fun activateTask(taskId: String) 45 | 46 | suspend fun clearCompletedTasks() 47 | 48 | suspend fun deleteAllTasks() 49 | 50 | suspend fun deleteTask(taskId: String) 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/LocalTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data.source.local 18 | 19 | import androidx.room.Entity 20 | import androidx.room.PrimaryKey 21 | 22 | /** 23 | * Internal model used to represent a task stored locally in a Room database. This is used inside 24 | * the data layer only. 25 | * 26 | * See ModelMappingExt.kt for mapping functions used to convert this model to other 27 | * models. 28 | */ 29 | @Entity( 30 | tableName = "task" 31 | ) 32 | data class LocalTask( 33 | @PrimaryKey val id: String, 34 | var title: String, 35 | var description: String, 36 | var isCompleted: Boolean, 37 | ) 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/TaskDao.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data.source.local 18 | 19 | import androidx.room.Dao 20 | import androidx.room.Query 21 | import androidx.room.Upsert 22 | import kotlinx.coroutines.flow.Flow 23 | 24 | /** 25 | * Data Access Object for the task table. 26 | */ 27 | @Dao 28 | interface TaskDao { 29 | 30 | /** 31 | * Observes list of tasks. 32 | * 33 | * @return all tasks. 34 | */ 35 | @Query("SELECT * FROM task") 36 | fun observeAll(): Flow> 37 | 38 | /** 39 | * Observes a single task. 40 | * 41 | * @param taskId the task id. 42 | * @return the task with taskId. 43 | */ 44 | @Query("SELECT * FROM task WHERE id = :taskId") 45 | fun observeById(taskId: String): Flow 46 | 47 | /** 48 | * Select all tasks from the tasks table. 49 | * 50 | * @return all tasks. 51 | */ 52 | @Query("SELECT * FROM task") 53 | suspend fun getAll(): List 54 | 55 | /** 56 | * Select a task by id. 57 | * 58 | * @param taskId the task id. 59 | * @return the task with taskId. 60 | */ 61 | @Query("SELECT * FROM task WHERE id = :taskId") 62 | suspend fun getById(taskId: String): LocalTask? 63 | 64 | /** 65 | * Insert or update a task in the database. If a task already exists, replace it. 66 | * 67 | * @param task the task to be inserted or updated. 68 | */ 69 | @Upsert 70 | suspend fun upsert(task: LocalTask) 71 | 72 | /** 73 | * Insert or update tasks in the database. If a task already exists, replace it. 74 | * 75 | * @param tasks the tasks to be inserted or updated. 76 | */ 77 | @Upsert 78 | suspend fun upsertAll(tasks: List) 79 | 80 | /** 81 | * Update the complete status of a task 82 | * 83 | * @param taskId id of the task 84 | * @param completed status to be updated 85 | */ 86 | @Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId") 87 | suspend fun updateCompleted(taskId: String, completed: Boolean) 88 | 89 | /** 90 | * Delete a task by id. 91 | * 92 | * @return the number of tasks deleted. This should always be 1. 93 | */ 94 | @Query("DELETE FROM task WHERE id = :taskId") 95 | suspend fun deleteById(taskId: String): Int 96 | 97 | /** 98 | * Delete all tasks. 99 | */ 100 | @Query("DELETE FROM task") 101 | suspend fun deleteAll() 102 | 103 | /** 104 | * Delete all completed tasks from the table. 105 | * 106 | * @return the number of tasks deleted. 107 | */ 108 | @Query("DELETE FROM task WHERE isCompleted = 1") 109 | suspend fun deleteCompleted(): Int 110 | } 111 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/ToDoDatabase.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data.source.local 18 | 19 | import androidx.room.Database 20 | import androidx.room.RoomDatabase 21 | 22 | /** 23 | * The Room Database that contains the Task table. 24 | * 25 | * Note that exportSchema should be true in production databases. 26 | */ 27 | @Database(entities = [LocalTask::class], version = 1, exportSchema = false) 28 | abstract class ToDoDatabase : RoomDatabase() { 29 | 30 | abstract fun taskDao(): TaskDao 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkDataSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data.source.network 18 | 19 | /** 20 | * Main entry point for accessing tasks data from the network. 21 | * 22 | */ 23 | interface NetworkDataSource { 24 | 25 | suspend fun loadTasks(): List 26 | 27 | suspend fun saveTasks(tasks: List) 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/NetworkTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data.source.network 18 | 19 | /** 20 | * Internal model used to represent a task obtained from the network. This is used inside the data 21 | * layer only. 22 | * 23 | * See ModelMappingExt.kt for mapping functions used to convert this model to other 24 | * models. 25 | */ 26 | data class NetworkTask( 27 | val id: String, 28 | val title: String, 29 | val shortDescription: String, 30 | val priority: Int? = null, 31 | val status: TaskStatus = TaskStatus.ACTIVE 32 | ) 33 | 34 | enum class TaskStatus { 35 | ACTIVE, 36 | COMPLETE 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data.source.network 18 | 19 | import kotlinx.coroutines.delay 20 | import kotlinx.coroutines.sync.Mutex 21 | import kotlinx.coroutines.sync.withLock 22 | import javax.inject.Inject 23 | 24 | class TaskNetworkDataSource @Inject constructor() : NetworkDataSource { 25 | 26 | // A mutex is used to ensure that reads and writes are thread-safe. 27 | private val accessMutex = Mutex() 28 | private var tasks = listOf( 29 | NetworkTask( 30 | id = "PISA", 31 | title = "Build tower in Pisa", 32 | shortDescription = "Ground looks good, no foundation work required." 33 | ), 34 | NetworkTask( 35 | id = "TACOMA", 36 | title = "Finish bridge in Tacoma", 37 | shortDescription = "Found awesome girders at half the cost!" 38 | ) 39 | ) 40 | 41 | override suspend fun loadTasks(): List = accessMutex.withLock { 42 | delay(SERVICE_LATENCY_IN_MILLIS) 43 | return tasks 44 | } 45 | 46 | override suspend fun saveTasks(newTasks: List) = accessMutex.withLock { 47 | delay(SERVICE_LATENCY_IN_MILLIS) 48 | tasks = newTasks 49 | } 50 | } 51 | 52 | private const val SERVICE_LATENCY_IN_MILLIS = 2000L 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.di 18 | 19 | import dagger.Module 20 | import dagger.Provides 21 | import dagger.hilt.InstallIn 22 | import dagger.hilt.components.SingletonComponent 23 | import kotlinx.coroutines.CoroutineDispatcher 24 | import kotlinx.coroutines.CoroutineScope 25 | import kotlinx.coroutines.Dispatchers 26 | import kotlinx.coroutines.SupervisorJob 27 | import javax.inject.Qualifier 28 | import javax.inject.Singleton 29 | 30 | @Qualifier 31 | @Retention(AnnotationRetention.RUNTIME) 32 | annotation class IoDispatcher 33 | 34 | @Retention(AnnotationRetention.RUNTIME) 35 | @Qualifier 36 | annotation class DefaultDispatcher 37 | 38 | @Retention(AnnotationRetention.RUNTIME) 39 | @Qualifier 40 | annotation class ApplicationScope 41 | 42 | @Module 43 | @InstallIn(SingletonComponent::class) 44 | object CoroutinesModule { 45 | 46 | @Provides 47 | @IoDispatcher 48 | fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO 49 | 50 | @Provides 51 | @DefaultDispatcher 52 | fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default 53 | 54 | @Provides 55 | @Singleton 56 | @ApplicationScope 57 | fun providesCoroutineScope( 58 | @DefaultDispatcher dispatcher: CoroutineDispatcher 59 | ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.di 18 | 19 | import android.content.Context 20 | import androidx.room.Room 21 | import com.example.android.architecture.blueprints.todoapp.data.DefaultTaskRepository 22 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 23 | import com.example.android.architecture.blueprints.todoapp.data.source.local.TaskDao 24 | import com.example.android.architecture.blueprints.todoapp.data.source.local.ToDoDatabase 25 | import com.example.android.architecture.blueprints.todoapp.data.source.network.NetworkDataSource 26 | import com.example.android.architecture.blueprints.todoapp.data.source.network.TaskNetworkDataSource 27 | import dagger.Binds 28 | import dagger.Module 29 | import dagger.Provides 30 | import dagger.hilt.InstallIn 31 | import dagger.hilt.android.qualifiers.ApplicationContext 32 | import dagger.hilt.components.SingletonComponent 33 | import javax.inject.Singleton 34 | 35 | @Module 36 | @InstallIn(SingletonComponent::class) 37 | abstract class RepositoryModule { 38 | 39 | @Singleton 40 | @Binds 41 | abstract fun bindTaskRepository(repository: DefaultTaskRepository): TaskRepository 42 | } 43 | 44 | @Module 45 | @InstallIn(SingletonComponent::class) 46 | abstract class DataSourceModule { 47 | 48 | @Singleton 49 | @Binds 50 | abstract fun bindNetworkDataSource(dataSource: TaskNetworkDataSource): NetworkDataSource 51 | } 52 | 53 | @Module 54 | @InstallIn(SingletonComponent::class) 55 | object DatabaseModule { 56 | 57 | @Singleton 58 | @Provides 59 | fun provideDataBase(@ApplicationContext context: Context): ToDoDatabase { 60 | return Room.databaseBuilder( 61 | context.applicationContext, 62 | ToDoDatabase::class.java, 63 | "Tasks.db" 64 | ).build() 65 | } 66 | 67 | @Provides 68 | fun provideTaskDao(database: ToDoDatabase): TaskDao = database.taskDao() 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsScreen.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.statistics 18 | 19 | import androidx.compose.foundation.layout.Column 20 | import androidx.compose.foundation.layout.fillMaxSize 21 | import androidx.compose.foundation.layout.fillMaxWidth 22 | import androidx.compose.foundation.layout.padding 23 | import androidx.compose.foundation.rememberScrollState 24 | import androidx.compose.foundation.verticalScroll 25 | import androidx.compose.material3.Scaffold 26 | import androidx.compose.material3.SnackbarHost 27 | import androidx.compose.material3.SnackbarHostState 28 | import androidx.compose.material3.Surface 29 | import androidx.compose.material3.Text 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.runtime.remember 33 | import androidx.compose.ui.Modifier 34 | import androidx.compose.ui.res.dimensionResource 35 | import androidx.compose.ui.res.stringResource 36 | import androidx.compose.ui.tooling.preview.Preview 37 | import androidx.hilt.navigation.compose.hiltViewModel 38 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 39 | import com.example.android.architecture.blueprints.todoapp.R 40 | import com.example.android.architecture.blueprints.todoapp.util.LoadingContent 41 | import com.example.android.architecture.blueprints.todoapp.util.StatisticsTopAppBar 42 | 43 | @Composable 44 | fun StatisticsScreen( 45 | openDrawer: () -> Unit, 46 | modifier: Modifier = Modifier, 47 | viewModel: StatisticsViewModel = hiltViewModel(), 48 | snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } 49 | ) { 50 | Scaffold( 51 | modifier = modifier.fillMaxSize(), 52 | snackbarHost = { SnackbarHost(snackbarHostState) }, 53 | topBar = { StatisticsTopAppBar(openDrawer) }, 54 | ) { paddingValues -> 55 | val uiState by viewModel.uiState.collectAsStateWithLifecycle() 56 | 57 | StatisticsContent( 58 | loading = uiState.isLoading, 59 | empty = uiState.isEmpty, 60 | activeTasksPercent = uiState.activeTasksPercent, 61 | completedTasksPercent = uiState.completedTasksPercent, 62 | onRefresh = { viewModel.refresh() }, 63 | modifier = modifier.padding(paddingValues) 64 | ) 65 | } 66 | } 67 | 68 | @Composable 69 | private fun StatisticsContent( 70 | loading: Boolean, 71 | empty: Boolean, 72 | activeTasksPercent: Float, 73 | completedTasksPercent: Float, 74 | onRefresh: () -> Unit, 75 | modifier: Modifier = Modifier 76 | ) { 77 | val commonModifier = modifier 78 | .fillMaxSize() 79 | .padding(all = dimensionResource(id = R.dimen.horizontal_margin)) 80 | 81 | LoadingContent( 82 | loading = loading, 83 | empty = empty, 84 | onRefresh = onRefresh, 85 | modifier = modifier, 86 | emptyContent = { 87 | Text( 88 | text = stringResource(id = R.string.statistics_no_tasks), 89 | modifier = commonModifier 90 | ) 91 | } 92 | ) { 93 | Column( 94 | commonModifier 95 | .fillMaxSize() 96 | .verticalScroll(rememberScrollState()) 97 | ) { 98 | if (!loading) { 99 | Text(stringResource(id = R.string.statistics_active_tasks, activeTasksPercent)) 100 | Text( 101 | stringResource( 102 | id = R.string.statistics_completed_tasks, 103 | completedTasksPercent 104 | ) 105 | ) 106 | } 107 | } 108 | } 109 | } 110 | 111 | @Preview 112 | @Composable 113 | fun StatisticsContentPreview() { 114 | Surface { 115 | StatisticsContent( 116 | loading = false, 117 | empty = false, 118 | activeTasksPercent = 80f, 119 | completedTasksPercent = 20f, 120 | onRefresh = { } 121 | ) 122 | } 123 | } 124 | 125 | @Preview 126 | @Composable 127 | fun StatisticsContentEmptyPreview() { 128 | Surface { 129 | StatisticsScreen({}) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.statistics 18 | 19 | import com.example.android.architecture.blueprints.todoapp.data.Task 20 | 21 | /** 22 | * Function that does some trivial computation. Used to showcase unit tests. 23 | */ 24 | internal fun getActiveAndCompletedStats(tasks: List): StatsResult { 25 | 26 | return if (tasks.isEmpty()) { 27 | StatsResult(0f, 0f) 28 | } else { 29 | val totalTasks = tasks.size 30 | val numberOfActiveTasks = tasks.count { it.isActive } 31 | StatsResult( 32 | activeTasksPercent = 100f * numberOfActiveTasks / tasks.size, 33 | completedTasksPercent = 100f * (totalTasks - numberOfActiveTasks) / tasks.size 34 | ) 35 | } 36 | } 37 | 38 | data class StatsResult(val activeTasksPercent: Float, val completedTasksPercent: Float) 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.statistics 18 | 19 | import androidx.lifecycle.ViewModel 20 | import androidx.lifecycle.viewModelScope 21 | import com.example.android.architecture.blueprints.todoapp.R 22 | import com.example.android.architecture.blueprints.todoapp.data.Task 23 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 24 | import com.example.android.architecture.blueprints.todoapp.util.Async 25 | import com.example.android.architecture.blueprints.todoapp.util.WhileUiSubscribed 26 | import dagger.hilt.android.lifecycle.HiltViewModel 27 | import kotlinx.coroutines.flow.StateFlow 28 | import kotlinx.coroutines.flow.catch 29 | import kotlinx.coroutines.flow.map 30 | import kotlinx.coroutines.flow.stateIn 31 | import kotlinx.coroutines.launch 32 | import javax.inject.Inject 33 | 34 | /** 35 | * UiState for the statistics screen. 36 | */ 37 | data class StatisticsUiState( 38 | val isEmpty: Boolean = false, 39 | val isLoading: Boolean = false, 40 | val activeTasksPercent: Float = 0f, 41 | val completedTasksPercent: Float = 0f 42 | ) 43 | 44 | /** 45 | * ViewModel for the statistics screen. 46 | */ 47 | @HiltViewModel 48 | class StatisticsViewModel @Inject constructor( 49 | private val taskRepository: TaskRepository 50 | ) : ViewModel() { 51 | 52 | val uiState: StateFlow = 53 | taskRepository.getTasksStream() 54 | .map { Async.Success(it) } 55 | .catch>> { emit(Async.Error(R.string.loading_tasks_error)) } 56 | .map { taskAsync -> produceStatisticsUiState(taskAsync) } 57 | .stateIn( 58 | scope = viewModelScope, 59 | started = WhileUiSubscribed, 60 | initialValue = StatisticsUiState(isLoading = true) 61 | ) 62 | 63 | fun refresh() { 64 | viewModelScope.launch { 65 | taskRepository.refresh() 66 | } 67 | } 68 | 69 | private fun produceStatisticsUiState(taskLoad: Async>) = 70 | when (taskLoad) { 71 | Async.Loading -> { 72 | StatisticsUiState(isLoading = true, isEmpty = true) 73 | } 74 | is Async.Error -> { 75 | // TODO: Show error message? 76 | StatisticsUiState(isEmpty = true, isLoading = false) 77 | } 78 | is Async.Success -> { 79 | val stats = getActiveAndCompletedStats(taskLoad.data) 80 | StatisticsUiState( 81 | isEmpty = taskLoad.data.isEmpty(), 82 | activeTasksPercent = stats.activeTasksPercent, 83 | completedTasksPercent = stats.completedTasksPercent, 84 | isLoading = false 85 | ) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailScreen.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.taskdetail 18 | 19 | import androidx.compose.foundation.layout.Column 20 | import androidx.compose.foundation.layout.Row 21 | import androidx.compose.foundation.layout.fillMaxSize 22 | import androidx.compose.foundation.layout.fillMaxWidth 23 | import androidx.compose.foundation.layout.padding 24 | import androidx.compose.foundation.rememberScrollState 25 | import androidx.compose.foundation.verticalScroll 26 | import androidx.compose.material.icons.Icons 27 | import androidx.compose.material.icons.filled.Edit 28 | import androidx.compose.material3.Checkbox 29 | import androidx.compose.material3.Icon 30 | import androidx.compose.material3.MaterialTheme 31 | import androidx.compose.material3.Scaffold 32 | import androidx.compose.material3.SmallFloatingActionButton 33 | import androidx.compose.material3.SnackbarHost 34 | import androidx.compose.material3.SnackbarHostState 35 | import androidx.compose.material3.Surface 36 | import androidx.compose.material3.Text 37 | import androidx.compose.runtime.Composable 38 | import androidx.compose.runtime.LaunchedEffect 39 | import androidx.compose.runtime.getValue 40 | import androidx.compose.runtime.remember 41 | import androidx.compose.ui.Modifier 42 | import androidx.compose.ui.res.dimensionResource 43 | import androidx.compose.ui.res.stringResource 44 | import androidx.compose.ui.tooling.preview.Preview 45 | import androidx.hilt.navigation.compose.hiltViewModel 46 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 47 | import com.example.android.architecture.blueprints.todoapp.R 48 | import com.example.android.architecture.blueprints.todoapp.data.Task 49 | import com.example.android.architecture.blueprints.todoapp.util.LoadingContent 50 | import com.example.android.architecture.blueprints.todoapp.util.TaskDetailTopAppBar 51 | 52 | @Composable 53 | fun TaskDetailScreen( 54 | onEditTask: (String) -> Unit, 55 | onBack: () -> Unit, 56 | onDeleteTask: () -> Unit, 57 | modifier: Modifier = Modifier, 58 | viewModel: TaskDetailViewModel = hiltViewModel(), 59 | snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } 60 | 61 | ) { 62 | Scaffold( 63 | modifier = modifier.fillMaxSize(), 64 | snackbarHost = { SnackbarHost(snackbarHostState) }, 65 | topBar = { TaskDetailTopAppBar(onBack = onBack, onDelete = viewModel::deleteTask) }, 66 | floatingActionButton = { 67 | SmallFloatingActionButton(onClick = { onEditTask(viewModel.taskId) }) { 68 | Icon(Icons.Filled.Edit, stringResource(id = R.string.edit_task)) 69 | } 70 | } 71 | ) { paddingValues -> 72 | val uiState by viewModel.uiState.collectAsStateWithLifecycle() 73 | 74 | EditTaskContent( 75 | loading = uiState.isLoading, 76 | empty = uiState.task == null && !uiState.isLoading, 77 | task = uiState.task, 78 | onRefresh = viewModel::refresh, 79 | onTaskCheck = viewModel::setCompleted, 80 | modifier = Modifier.padding(paddingValues) 81 | ) 82 | 83 | // Check for user messages to display on the screen 84 | uiState.userMessage?.let { userMessage -> 85 | val snackbarText = stringResource(userMessage) 86 | LaunchedEffect(snackbarHostState, viewModel, userMessage, snackbarText) { 87 | snackbarHostState.showSnackbar(snackbarText) 88 | viewModel.snackbarMessageShown() 89 | } 90 | } 91 | 92 | // Check if the task is deleted and call onDeleteTask 93 | LaunchedEffect(uiState.isTaskDeleted) { 94 | if (uiState.isTaskDeleted) { 95 | onDeleteTask() 96 | } 97 | } 98 | } 99 | } 100 | 101 | @Composable 102 | private fun EditTaskContent( 103 | loading: Boolean, 104 | empty: Boolean, 105 | task: Task?, 106 | onTaskCheck: (Boolean) -> Unit, 107 | onRefresh: () -> Unit, 108 | modifier: Modifier = Modifier 109 | ) { 110 | val screenPadding = Modifier.padding( 111 | horizontal = dimensionResource(id = R.dimen.horizontal_margin), 112 | vertical = dimensionResource(id = R.dimen.vertical_margin), 113 | ) 114 | val commonModifier = modifier 115 | .fillMaxWidth() 116 | .then(screenPadding) 117 | 118 | LoadingContent( 119 | loading = loading, 120 | empty = empty, 121 | emptyContent = { 122 | Text( 123 | text = stringResource(id = R.string.no_data), 124 | modifier = commonModifier 125 | ) 126 | }, 127 | onRefresh = onRefresh 128 | ) { 129 | Column(commonModifier.verticalScroll(rememberScrollState())) { 130 | Row( 131 | Modifier 132 | .fillMaxWidth() 133 | .then(screenPadding), 134 | 135 | ) { 136 | if (task != null) { 137 | Checkbox(task.isCompleted, onTaskCheck) 138 | Column { 139 | Text(text = task.title, style = MaterialTheme.typography.headlineSmall) 140 | Text(text = task.description, style = MaterialTheme.typography.bodySmall) 141 | } 142 | } 143 | } 144 | } 145 | } 146 | } 147 | 148 | @Preview 149 | @Composable 150 | private fun EditTaskContentPreview() { 151 | Surface { 152 | EditTaskContent( 153 | loading = false, 154 | empty = false, 155 | Task( 156 | title = "Title", 157 | description = "Description", 158 | isCompleted = false, 159 | id = "ID" 160 | ), 161 | onTaskCheck = { }, 162 | onRefresh = { } 163 | ) 164 | } 165 | 166 | } 167 | 168 | @Preview 169 | @Composable 170 | private fun EditTaskContentTaskCompletedPreview() { 171 | Surface { 172 | EditTaskContent( 173 | loading = false, 174 | empty = false, 175 | Task( 176 | title = "Title", 177 | description = "Description", 178 | isCompleted = false, 179 | id = "ID" 180 | ), 181 | onTaskCheck = { }, 182 | onRefresh = { } 183 | ) 184 | } 185 | } 186 | 187 | @Preview 188 | @Composable 189 | private fun EditTaskContentEmptyPreview() { 190 | Surface { 191 | EditTaskContent( 192 | loading = false, 193 | empty = true, 194 | Task( 195 | title = "Title", 196 | description = "Description", 197 | isCompleted = false, 198 | id = "ID" 199 | ), 200 | onTaskCheck = { }, 201 | onRefresh = { } 202 | ) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.taskdetail 18 | 19 | import androidx.lifecycle.SavedStateHandle 20 | import androidx.lifecycle.ViewModel 21 | import androidx.lifecycle.viewModelScope 22 | import com.example.android.architecture.blueprints.todoapp.R 23 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs 24 | import com.example.android.architecture.blueprints.todoapp.data.Task 25 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 26 | import com.example.android.architecture.blueprints.todoapp.util.Async 27 | import com.example.android.architecture.blueprints.todoapp.util.WhileUiSubscribed 28 | import dagger.hilt.android.lifecycle.HiltViewModel 29 | import kotlinx.coroutines.flow.MutableStateFlow 30 | import kotlinx.coroutines.flow.StateFlow 31 | import kotlinx.coroutines.flow.catch 32 | import kotlinx.coroutines.flow.combine 33 | import kotlinx.coroutines.flow.map 34 | import kotlinx.coroutines.flow.stateIn 35 | import kotlinx.coroutines.launch 36 | import javax.inject.Inject 37 | 38 | /** 39 | * UiState for the Details screen. 40 | */ 41 | data class TaskDetailUiState( 42 | val task: Task? = null, 43 | val isLoading: Boolean = false, 44 | val userMessage: Int? = null, 45 | val isTaskDeleted: Boolean = false 46 | ) 47 | 48 | /** 49 | * ViewModel for the Details screen. 50 | */ 51 | @HiltViewModel 52 | class TaskDetailViewModel @Inject constructor( 53 | private val taskRepository: TaskRepository, 54 | savedStateHandle: SavedStateHandle 55 | ) : ViewModel() { 56 | 57 | val taskId: String = savedStateHandle[TodoDestinationsArgs.TASK_ID_ARG]!! 58 | 59 | private val _userMessage: MutableStateFlow = MutableStateFlow(null) 60 | private val _isLoading = MutableStateFlow(false) 61 | private val _isTaskDeleted = MutableStateFlow(false) 62 | private val _taskAsync = taskRepository.getTaskStream(taskId) 63 | .map { handleTask(it) } 64 | .catch { emit(Async.Error(R.string.loading_task_error)) } 65 | 66 | val uiState: StateFlow = combine( 67 | _userMessage, _isLoading, _isTaskDeleted, _taskAsync 68 | ) { userMessage, isLoading, isTaskDeleted, taskAsync -> 69 | when (taskAsync) { 70 | Async.Loading -> { 71 | TaskDetailUiState(isLoading = true) 72 | } 73 | is Async.Error -> { 74 | TaskDetailUiState( 75 | userMessage = taskAsync.errorMessage, 76 | isTaskDeleted = isTaskDeleted 77 | ) 78 | } 79 | is Async.Success -> { 80 | TaskDetailUiState( 81 | task = taskAsync.data, 82 | isLoading = isLoading, 83 | userMessage = userMessage, 84 | isTaskDeleted = isTaskDeleted 85 | ) 86 | } 87 | } 88 | } 89 | .stateIn( 90 | scope = viewModelScope, 91 | started = WhileUiSubscribed, 92 | initialValue = TaskDetailUiState(isLoading = true) 93 | ) 94 | 95 | fun deleteTask() = viewModelScope.launch { 96 | taskRepository.deleteTask(taskId) 97 | _isTaskDeleted.value = true 98 | } 99 | 100 | fun setCompleted(completed: Boolean) = viewModelScope.launch { 101 | val task = uiState.value.task ?: return@launch 102 | if (completed) { 103 | taskRepository.completeTask(task.id) 104 | showSnackbarMessage(R.string.task_marked_complete) 105 | } else { 106 | taskRepository.activateTask(task.id) 107 | showSnackbarMessage(R.string.task_marked_active) 108 | } 109 | } 110 | 111 | fun refresh() { 112 | _isLoading.value = true 113 | viewModelScope.launch { 114 | taskRepository.refreshTask(taskId) 115 | _isLoading.value = false 116 | } 117 | } 118 | 119 | fun snackbarMessageShown() { 120 | _userMessage.value = null 121 | } 122 | 123 | private fun showSnackbarMessage(message: Int) { 124 | _userMessage.value = message 125 | } 126 | 127 | private fun handleTask(task: Task?): Async { 128 | if (task == null) { 129 | return Async.Error(R.string.task_not_found) 130 | } 131 | return Async.Success(task) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksFilterType.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.tasks 18 | 19 | /** 20 | * Used with the filter spinner in the tasks list. 21 | */ 22 | enum class TasksFilterType { 23 | /** 24 | * Do not filter tasks. 25 | */ 26 | ALL_TASKS, 27 | 28 | /** 29 | * Filters only the active (not completed yet) tasks. 30 | */ 31 | ACTIVE_TASKS, 32 | 33 | /** 34 | * Filters only the completed tasks. 35 | */ 36 | COMPLETED_TASKS 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.tasks 18 | 19 | import androidx.lifecycle.SavedStateHandle 20 | import androidx.lifecycle.ViewModel 21 | import androidx.lifecycle.viewModelScope 22 | import com.example.android.architecture.blueprints.todoapp.ADD_EDIT_RESULT_OK 23 | import com.example.android.architecture.blueprints.todoapp.DELETE_RESULT_OK 24 | import com.example.android.architecture.blueprints.todoapp.EDIT_RESULT_OK 25 | import com.example.android.architecture.blueprints.todoapp.R 26 | import com.example.android.architecture.blueprints.todoapp.data.Task 27 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 28 | import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.ACTIVE_TASKS 29 | import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.ALL_TASKS 30 | import com.example.android.architecture.blueprints.todoapp.tasks.TasksFilterType.COMPLETED_TASKS 31 | import com.example.android.architecture.blueprints.todoapp.util.Async 32 | import com.example.android.architecture.blueprints.todoapp.util.WhileUiSubscribed 33 | import dagger.hilt.android.lifecycle.HiltViewModel 34 | import kotlinx.coroutines.flow.MutableStateFlow 35 | import kotlinx.coroutines.flow.StateFlow 36 | import kotlinx.coroutines.flow.catch 37 | import kotlinx.coroutines.flow.combine 38 | import kotlinx.coroutines.flow.distinctUntilChanged 39 | import kotlinx.coroutines.flow.map 40 | import kotlinx.coroutines.flow.stateIn 41 | import kotlinx.coroutines.launch 42 | import javax.inject.Inject 43 | 44 | /** 45 | * UiState for the task list screen. 46 | */ 47 | data class TasksUiState( 48 | val items: List = emptyList(), 49 | val isLoading: Boolean = false, 50 | val filteringUiInfo: FilteringUiInfo = FilteringUiInfo(), 51 | val userMessage: Int? = null 52 | ) 53 | 54 | /** 55 | * ViewModel for the task list screen. 56 | */ 57 | @HiltViewModel 58 | class TasksViewModel @Inject constructor( 59 | private val taskRepository: TaskRepository, 60 | private val savedStateHandle: SavedStateHandle 61 | ) : ViewModel() { 62 | 63 | private val _savedFilterType = 64 | savedStateHandle.getStateFlow(TASKS_FILTER_SAVED_STATE_KEY, ALL_TASKS) 65 | 66 | private val _filterUiInfo = _savedFilterType.map { getFilterUiInfo(it) }.distinctUntilChanged() 67 | private val _userMessage: MutableStateFlow = MutableStateFlow(null) 68 | private val _isLoading = MutableStateFlow(false) 69 | private val _filteredTasksAsync = 70 | combine(taskRepository.getTasksStream(), _savedFilterType) { tasks, type -> 71 | filterTasks(tasks, type) 72 | } 73 | .map { Async.Success(it) } 74 | .catch>> { emit(Async.Error(R.string.loading_tasks_error)) } 75 | 76 | val uiState: StateFlow = combine( 77 | _filterUiInfo, _isLoading, _userMessage, _filteredTasksAsync 78 | ) { filterUiInfo, isLoading, userMessage, tasksAsync -> 79 | when (tasksAsync) { 80 | Async.Loading -> { 81 | TasksUiState(isLoading = true) 82 | } 83 | is Async.Error -> { 84 | TasksUiState(userMessage = tasksAsync.errorMessage) 85 | } 86 | is Async.Success -> { 87 | TasksUiState( 88 | items = tasksAsync.data, 89 | filteringUiInfo = filterUiInfo, 90 | isLoading = isLoading, 91 | userMessage = userMessage 92 | ) 93 | } 94 | } 95 | } 96 | .stateIn( 97 | scope = viewModelScope, 98 | started = WhileUiSubscribed, 99 | initialValue = TasksUiState(isLoading = true) 100 | ) 101 | 102 | fun setFiltering(requestType: TasksFilterType) { 103 | savedStateHandle[TASKS_FILTER_SAVED_STATE_KEY] = requestType 104 | } 105 | 106 | fun clearCompletedTasks() { 107 | viewModelScope.launch { 108 | taskRepository.clearCompletedTasks() 109 | showSnackbarMessage(R.string.completed_tasks_cleared) 110 | refresh() 111 | } 112 | } 113 | 114 | fun completeTask(task: Task, completed: Boolean) = viewModelScope.launch { 115 | if (completed) { 116 | taskRepository.completeTask(task.id) 117 | showSnackbarMessage(R.string.task_marked_complete) 118 | } else { 119 | taskRepository.activateTask(task.id) 120 | showSnackbarMessage(R.string.task_marked_active) 121 | } 122 | } 123 | 124 | fun showEditResultMessage(result: Int) { 125 | when (result) { 126 | EDIT_RESULT_OK -> showSnackbarMessage(R.string.successfully_saved_task_message) 127 | ADD_EDIT_RESULT_OK -> showSnackbarMessage(R.string.successfully_added_task_message) 128 | DELETE_RESULT_OK -> showSnackbarMessage(R.string.successfully_deleted_task_message) 129 | } 130 | } 131 | 132 | fun snackbarMessageShown() { 133 | _userMessage.value = null 134 | } 135 | 136 | private fun showSnackbarMessage(message: Int) { 137 | _userMessage.value = message 138 | } 139 | 140 | fun refresh() { 141 | _isLoading.value = true 142 | viewModelScope.launch { 143 | taskRepository.refresh() 144 | _isLoading.value = false 145 | } 146 | } 147 | 148 | private fun filterTasks(tasks: List, filteringType: TasksFilterType): List { 149 | val tasksToShow = ArrayList() 150 | // We filter the tasks based on the requestType 151 | for (task in tasks) { 152 | when (filteringType) { 153 | ALL_TASKS -> tasksToShow.add(task) 154 | ACTIVE_TASKS -> if (task.isActive) { 155 | tasksToShow.add(task) 156 | } 157 | COMPLETED_TASKS -> if (task.isCompleted) { 158 | tasksToShow.add(task) 159 | } 160 | } 161 | } 162 | return tasksToShow 163 | } 164 | 165 | private fun getFilterUiInfo(requestType: TasksFilterType): FilteringUiInfo = 166 | when (requestType) { 167 | ALL_TASKS -> { 168 | FilteringUiInfo( 169 | R.string.label_all, R.string.no_tasks_all, 170 | R.drawable.logo_no_fill 171 | ) 172 | } 173 | ACTIVE_TASKS -> { 174 | FilteringUiInfo( 175 | R.string.label_active, R.string.no_tasks_active, 176 | R.drawable.ic_check_circle_96dp 177 | ) 178 | } 179 | COMPLETED_TASKS -> { 180 | FilteringUiInfo( 181 | R.string.label_completed, R.string.no_tasks_completed, 182 | R.drawable.ic_verified_user_96dp 183 | ) 184 | } 185 | } 186 | } 187 | 188 | // Used to save the current filtering in SavedStateHandle. 189 | const val TASKS_FILTER_SAVED_STATE_KEY = "TASKS_FILTER_SAVED_STATE_KEY" 190 | 191 | data class FilteringUiInfo( 192 | val currentFilteringLabel: Int = R.string.label_all, 193 | val noTasksLabel: Int = R.string.no_tasks_all, 194 | val noTaskIconRes: Int = R.drawable.logo_no_fill, 195 | ) 196 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/Async.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.util 18 | 19 | /** 20 | * A generic class that holds a loading signal or the result of an async operation. 21 | */ 22 | sealed class Async { 23 | object Loading : Async() 24 | 25 | data class Error(val errorMessage: Int) : Async() 26 | 27 | data class Success(val data: T) : Async() 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/ComposeUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.util 18 | 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.Color 22 | import com.google.accompanist.swiperefresh.SwipeRefresh 23 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 24 | 25 | val primaryDarkColor: Color = Color(0xFF263238) 26 | 27 | /** 28 | * Display an initial empty state or swipe to refresh content. 29 | * 30 | * @param loading (state) when true, display a loading spinner over [content] 31 | * @param empty (state) when true, display [emptyContent] 32 | * @param emptyContent (slot) the content to display for the empty state 33 | * @param onRefresh (event) event to request refresh 34 | * @param modifier the modifier to apply to this layout. 35 | * @param content (slot) the main content to show 36 | */ 37 | @Composable 38 | fun LoadingContent( 39 | loading: Boolean, 40 | empty: Boolean, 41 | emptyContent: @Composable () -> Unit, 42 | onRefresh: () -> Unit, 43 | modifier: Modifier = Modifier, 44 | content: @Composable () -> Unit 45 | ) { 46 | if (empty) { 47 | emptyContent() 48 | } else { 49 | SwipeRefresh( 50 | state = rememberSwipeRefreshState(loading), 51 | onRefresh = onRefresh, 52 | modifier = modifier, 53 | content = content, 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/CoroutinesUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.util 18 | 19 | import kotlinx.coroutines.flow.SharingStarted 20 | 21 | private const val StopTimeoutMillis: Long = 5000 22 | 23 | /** 24 | * A [SharingStarted] meant to be used with a [StateFlow] to expose data to the UI. 25 | * 26 | * When the UI stops observing, upstream flows stay active for some time to allow the system to 27 | * come back from a short-lived configuration change (such as rotations). If the UI stops 28 | * observing for longer, the cache is kept but the upstream flows are stopped. When the UI comes 29 | * back, the latest value is replayed and the upstream flows are executed again. This is done to 30 | * save resources when the app is in the background but let users switch between apps quickly. 31 | */ 32 | val WhileUiSubscribed: SharingStarted = SharingStarted.WhileSubscribed(StopTimeoutMillis) 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/SimpleCountingIdlingResource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.util 18 | 19 | import androidx.test.espresso.IdlingResource 20 | import java.util.concurrent.atomic.AtomicInteger 21 | 22 | /** 23 | * An simple counter implementation of [IdlingResource] that determines idleness by 24 | * maintaining an internal counter. When the counter is 0 - it is considered to be idle, when it is 25 | * non-zero it is not idle. This is very similar to the way a [java.util.concurrent.Semaphore] 26 | * behaves. 27 | * 28 | * 29 | * This class can then be used to wrap up operations that while in progress should block tests from 30 | * accessing the UI. 31 | */ 32 | class SimpleCountingIdlingResource(private val resourceName: String) : IdlingResource { 33 | 34 | private val counter = AtomicInteger(0) 35 | 36 | // written from main thread, read from any thread. 37 | @Volatile 38 | private var resourceCallback: IdlingResource.ResourceCallback? = null 39 | 40 | override fun getName() = resourceName 41 | 42 | override fun isIdleNow() = counter.get() == 0 43 | 44 | override fun registerIdleTransitionCallback(resourceCallback: IdlingResource.ResourceCallback) { 45 | this.resourceCallback = resourceCallback 46 | } 47 | 48 | /** 49 | * Increments the count of in-flight transactions to the resource being monitored. 50 | */ 51 | fun increment() { 52 | counter.getAndIncrement() 53 | } 54 | 55 | /** 56 | * Decrements the count of in-flight transactions to the resource being monitored. 57 | * If this operation results in the counter falling below 0 - an exception is raised. 58 | * 59 | * @throws IllegalStateException if the counter is below 0. 60 | */ 61 | fun decrement() { 62 | val counterVal = counter.decrementAndGet() 63 | if (counterVal == 0) { 64 | // we've gone from non-zero to zero. That means we're idle now! Tell espresso. 65 | resourceCallback?.onTransitionToIdle() 66 | } else if (counterVal < 0) { 67 | throw IllegalStateException("Counter has been corrupted!") 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/android/architecture/blueprints/todoapp/util/TodoDrawer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.util 18 | 19 | import androidx.compose.foundation.Image 20 | import androidx.compose.foundation.background 21 | import androidx.compose.foundation.layout.Arrangement 22 | import androidx.compose.foundation.layout.Column 23 | import androidx.compose.foundation.layout.Row 24 | import androidx.compose.foundation.layout.Spacer 25 | import androidx.compose.foundation.layout.fillMaxSize 26 | import androidx.compose.foundation.layout.fillMaxWidth 27 | import androidx.compose.foundation.layout.height 28 | import androidx.compose.foundation.layout.padding 29 | import androidx.compose.foundation.layout.width 30 | import androidx.compose.material3.DrawerState 31 | import androidx.compose.material3.Icon 32 | import androidx.compose.material3.MaterialTheme 33 | import androidx.compose.material3.ModalNavigationDrawer 34 | import androidx.compose.material3.Surface 35 | import androidx.compose.material3.Text 36 | import androidx.compose.material3.TextButton 37 | import androidx.compose.runtime.Composable 38 | import androidx.compose.runtime.rememberCoroutineScope 39 | import androidx.compose.ui.Alignment 40 | import androidx.compose.ui.Modifier 41 | import androidx.compose.ui.graphics.painter.Painter 42 | import androidx.compose.ui.res.dimensionResource 43 | import androidx.compose.ui.res.painterResource 44 | import androidx.compose.ui.res.stringResource 45 | import androidx.compose.ui.tooling.preview.Preview 46 | import androidx.compose.ui.unit.dp 47 | import com.example.android.architecture.blueprints.todoapp.R 48 | import com.example.android.architecture.blueprints.todoapp.TodoDestinations 49 | import com.example.android.architecture.blueprints.todoapp.TodoNavigationActions 50 | import com.example.android.architecture.blueprints.todoapp.TodoTheme 51 | import kotlinx.coroutines.CoroutineScope 52 | import kotlinx.coroutines.launch 53 | 54 | @Composable 55 | fun AppModalDrawer( 56 | drawerState: DrawerState, 57 | currentRoute: String, 58 | navigationActions: TodoNavigationActions, 59 | coroutineScope: CoroutineScope = rememberCoroutineScope(), 60 | content: @Composable () -> Unit 61 | ) { 62 | ModalNavigationDrawer( 63 | drawerState = drawerState, 64 | drawerContent = { 65 | AppDrawer( 66 | currentRoute = currentRoute, 67 | navigateToTasks = { navigationActions.navigateToTasks() }, 68 | navigateToStatistics = { navigationActions.navigateToStatistics() }, 69 | closeDrawer = { coroutineScope.launch { drawerState.close() } } 70 | ) 71 | } 72 | ) { 73 | content() 74 | } 75 | } 76 | 77 | @Composable 78 | private fun AppDrawer( 79 | currentRoute: String, 80 | navigateToTasks: () -> Unit, 81 | navigateToStatistics: () -> Unit, 82 | closeDrawer: () -> Unit, 83 | modifier: Modifier = Modifier 84 | ) { 85 | Surface(color = MaterialTheme.colorScheme.background) { 86 | Column(modifier = modifier.fillMaxSize()) { 87 | DrawerHeader() 88 | DrawerButton( 89 | painter = painterResource(id = R.drawable.ic_list), 90 | label = stringResource(id = R.string.list_title), 91 | isSelected = currentRoute == TodoDestinations.TASKS_ROUTE, 92 | action = { 93 | navigateToTasks() 94 | closeDrawer() 95 | } 96 | ) 97 | DrawerButton( 98 | painter = painterResource(id = R.drawable.ic_statistics), 99 | label = stringResource(id = R.string.statistics_title), 100 | isSelected = currentRoute == TodoDestinations.STATISTICS_ROUTE, 101 | action = { 102 | navigateToStatistics() 103 | closeDrawer() 104 | } 105 | ) 106 | } 107 | } 108 | } 109 | 110 | @Composable 111 | private fun DrawerHeader( 112 | modifier: Modifier = Modifier 113 | ) { 114 | Column( 115 | horizontalAlignment = Alignment.CenterHorizontally, 116 | verticalArrangement = Arrangement.Center, 117 | modifier = modifier 118 | .fillMaxWidth() 119 | .background(primaryDarkColor) 120 | .height(dimensionResource(id = R.dimen.header_height)) 121 | .padding(dimensionResource(id = R.dimen.header_padding)) 122 | ) { 123 | Image( 124 | painter = painterResource(id = R.drawable.logo_no_fill), 125 | contentDescription = 126 | stringResource(id = R.string.tasks_header_image_content_description), 127 | modifier = Modifier.width(dimensionResource(id = R.dimen.header_image_width)) 128 | ) 129 | Text( 130 | text = stringResource(id = R.string.navigation_view_header_title), 131 | color = MaterialTheme.colorScheme.surface 132 | ) 133 | } 134 | } 135 | 136 | @Composable 137 | private fun DrawerButton( 138 | painter: Painter, 139 | label: String, 140 | isSelected: Boolean, 141 | action: () -> Unit, 142 | modifier: Modifier = Modifier 143 | ) { 144 | val tintColor = if (isSelected) { 145 | MaterialTheme.colorScheme.secondary 146 | } else { 147 | MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) 148 | } 149 | 150 | TextButton( 151 | onClick = action, 152 | modifier = modifier 153 | .fillMaxWidth() 154 | .padding(horizontal = dimensionResource(id = R.dimen.horizontal_margin)) 155 | ) { 156 | Row( 157 | horizontalArrangement = Arrangement.Start, 158 | verticalAlignment = Alignment.CenterVertically, 159 | modifier = Modifier.fillMaxWidth() 160 | ) { 161 | Icon( 162 | painter = painter, 163 | contentDescription = null, // decorative 164 | tint = tintColor 165 | ) 166 | Spacer(Modifier.width(16.dp)) 167 | Text( 168 | text = label, 169 | style = MaterialTheme.typography.bodySmall, 170 | color = tintColor 171 | ) 172 | } 173 | } 174 | } 175 | 176 | @Preview("Drawer contents") 177 | @Composable 178 | fun PreviewAppDrawer() { 179 | TodoTheme { 180 | Surface { 181 | AppDrawer( 182 | currentRoute = TodoDestinations.TASKS_ROUTE, 183 | navigateToTasks = {}, 184 | navigateToStatistics = {}, 185 | closeDrawer = {} 186 | ) 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/drawer_item_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_add.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_assignment_turned_in_24dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 23 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_check_circle_96dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 23 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_done.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_edit.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_filter_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_statistics.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_statistics_100dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 23 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_statistics_24dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 22 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_verified_user_96dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 24 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/list_completed_touch_feedback.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/logo_no_fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/drawable/logo_no_fill.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/touch_feedback.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/trash_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/drawable/trash_icon.png -------------------------------------------------------------------------------- /app/src/main/res/font/opensans_font.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 22 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/font/opensans_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/font/opensans_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/opensans_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/font/opensans_semibold.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/android/architecture-samples/9a02de62550f4e6e856009ebf304024fe1619a58/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/values-w820dp/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 21 | 64dp 22 | 23 | 24dp 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | #FFFFF0 19 | #263238 20 | #2E7D32 21 | #000000 22 | #757575 23 | 24 | #CCCCCC 25 | 26 | #CFD8DC 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 16dp 20 | 16dp 21 | 22 | 16dp 23 | 24 | 8dp 25 | 192dp 26 | 16dp 27 | 100dp 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | Todo 19 | New Task 20 | Edit Task 21 | Task Details 22 | Task marked complete 23 | Task marked active 24 | Error while loading tasks 25 | Error while loading task 26 | Task not found 27 | Completed tasks cleared 28 | Filter 29 | Open Drawer 30 | Back 31 | Clear completed 32 | Delete task 33 | More 34 | Todo 35 | Title 36 | Enter your task here. 37 | Save task 38 | Tasks cannot be empty 39 | Task saved 40 | Task List 41 | Statistics 42 | You have no tasks. 43 | Active tasks: %.1f%% 44 | Completed tasks: %.1f%% 45 | Error loading statistics. 46 | No data 47 | LOADING 48 | 49 | 50 | @string/nav_all 51 | @string/nav_active 52 | @string/nav_completed 53 | 54 | All 55 | Active 56 | Completed 57 | All Tasks 58 | Active Tasks 59 | Completed Tasks 60 | You have no tasks! 61 | You have no active tasks! 62 | You have no completed tasks! 63 | Refresh 64 | Task was deleted 65 | Task added 66 | 67 | 68 | 69 | Tasks header image 70 | No tasks image 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | 27 | 28 | 37 | 38 | -------------------------------------------------------------------------------- /app/src/test/java/com/example/android/architecture/blueprints/todoapp/addedittask/AddEditTaskViewModelTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.addedittask 18 | 19 | import androidx.lifecycle.SavedStateHandle 20 | import com.example.android.architecture.blueprints.todoapp.MainCoroutineRule 21 | import com.example.android.architecture.blueprints.todoapp.R.string 22 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs 23 | import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository 24 | import com.example.android.architecture.blueprints.todoapp.data.Task 25 | import com.google.common.truth.Truth.assertThat 26 | import kotlinx.coroutines.Dispatchers 27 | import kotlinx.coroutines.ExperimentalCoroutinesApi 28 | import kotlinx.coroutines.test.StandardTestDispatcher 29 | import kotlinx.coroutines.test.advanceUntilIdle 30 | import kotlinx.coroutines.test.runTest 31 | import kotlinx.coroutines.test.setMain 32 | import org.junit.Before 33 | import org.junit.Rule 34 | import org.junit.Test 35 | 36 | /** 37 | * Unit tests for the implementation of [AddEditTaskViewModel]. 38 | */ 39 | @ExperimentalCoroutinesApi 40 | class AddEditTaskViewModelTest { 41 | 42 | // Subject under test 43 | private lateinit var addEditTaskViewModel: AddEditTaskViewModel 44 | 45 | // Use a fake repository to be injected into the viewmodel 46 | private lateinit var tasksRepository: FakeTaskRepository 47 | private val task = Task(title = "Title1", description = "Description1", id = "0") 48 | 49 | // Set the main coroutines dispatcher for unit testing. 50 | @ExperimentalCoroutinesApi 51 | @get:Rule 52 | val mainCoroutineRule = MainCoroutineRule() 53 | 54 | @Before 55 | fun setupViewModel() { 56 | // We initialise the repository with no tasks 57 | tasksRepository = FakeTaskRepository().apply { 58 | addTasks(task) 59 | } 60 | } 61 | 62 | @Test 63 | fun saveNewTaskToRepository_showsSuccessMessageUi() { 64 | addEditTaskViewModel = AddEditTaskViewModel( 65 | tasksRepository, 66 | SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) 67 | ) 68 | 69 | val newTitle = "New Task Title" 70 | val newDescription = "Some Task Description" 71 | addEditTaskViewModel.apply { 72 | updateTitle(newTitle) 73 | updateDescription(newDescription) 74 | } 75 | addEditTaskViewModel.saveTask() 76 | 77 | val newTask = tasksRepository.savedTasks.value.values.first() 78 | 79 | // Then a task is saved in the repository and the view updated 80 | assertThat(newTask.title).isEqualTo(newTitle) 81 | assertThat(newTask.description).isEqualTo(newDescription) 82 | } 83 | 84 | @Test 85 | fun loadTasks_loading() = runTest { 86 | // Set Main dispatcher to not run coroutines eagerly, for just this one test 87 | Dispatchers.setMain(StandardTestDispatcher()) 88 | 89 | addEditTaskViewModel = AddEditTaskViewModel( 90 | tasksRepository, 91 | SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) 92 | ) 93 | 94 | // Then progress indicator is shown 95 | assertThat(addEditTaskViewModel.uiState.value.isLoading).isTrue() 96 | 97 | // Execute pending coroutines actions 98 | advanceUntilIdle() 99 | 100 | // Then progress indicator is hidden 101 | assertThat(addEditTaskViewModel.uiState.value.isLoading).isFalse() 102 | } 103 | 104 | @Test 105 | fun loadTasks_taskShown() { 106 | addEditTaskViewModel = AddEditTaskViewModel( 107 | tasksRepository, 108 | SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) 109 | ) 110 | 111 | // Add task to repository 112 | tasksRepository.addTasks(task) 113 | 114 | // Verify a task is loaded 115 | val uiState = addEditTaskViewModel.uiState.value 116 | assertThat(uiState.title).isEqualTo(task.title) 117 | assertThat(uiState.description).isEqualTo(task.description) 118 | assertThat(uiState.isLoading).isFalse() 119 | } 120 | 121 | @Test 122 | fun saveNewTaskToRepository_emptyTitle_error() { 123 | addEditTaskViewModel = AddEditTaskViewModel( 124 | tasksRepository, 125 | SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) 126 | ) 127 | 128 | saveTaskAndAssertUserMessage("", "Some Task Description") 129 | } 130 | 131 | @Test 132 | fun saveNewTaskToRepository_emptyDescription_error() { 133 | addEditTaskViewModel = AddEditTaskViewModel( 134 | tasksRepository, 135 | SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) 136 | ) 137 | 138 | saveTaskAndAssertUserMessage("Title", "") 139 | } 140 | 141 | @Test 142 | fun saveNewTaskToRepository_emptyDescriptionEmptyTitle_error() { 143 | addEditTaskViewModel = AddEditTaskViewModel( 144 | tasksRepository, 145 | SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) 146 | ) 147 | 148 | saveTaskAndAssertUserMessage("", "") 149 | } 150 | 151 | private fun saveTaskAndAssertUserMessage(title: String, description: String) { 152 | addEditTaskViewModel.apply { 153 | updateTitle(title) 154 | updateDescription(description) 155 | } 156 | 157 | // When saving an incomplete task 158 | addEditTaskViewModel.saveTask() 159 | 160 | assertThat( 161 | addEditTaskViewModel.uiState.value.userMessage 162 | ).isEqualTo(string.empty_task_message) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsUtilsTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.statistics 18 | 19 | import com.example.android.architecture.blueprints.todoapp.data.Task 20 | import org.hamcrest.core.Is.`is` 21 | import org.junit.Assert.assertThat 22 | import org.junit.Test 23 | 24 | /** 25 | * Unit tests for [getActiveAndCompletedStats]. 26 | */ 27 | class StatisticsUtilsTest { 28 | 29 | @Test 30 | fun getActiveAndCompletedStats_noCompleted() { 31 | val tasks = listOf( 32 | Task( 33 | id = "id", 34 | title = "title", 35 | description = "desc", 36 | isCompleted = false, 37 | ) 38 | ) 39 | // When the list of tasks is computed with an active task 40 | val result = getActiveAndCompletedStats(tasks) 41 | 42 | // Then the percentages are 100 and 0 43 | assertThat(result.activeTasksPercent, `is`(100f)) 44 | assertThat(result.completedTasksPercent, `is`(0f)) 45 | } 46 | 47 | @Test 48 | fun getActiveAndCompletedStats_noActive() { 49 | val tasks = listOf( 50 | Task( 51 | id = "id", 52 | title = "title", 53 | description = "desc", 54 | isCompleted = true, 55 | ) 56 | ) 57 | // When the list of tasks is computed with a completed task 58 | val result = getActiveAndCompletedStats(tasks) 59 | 60 | // Then the percentages are 0 and 100 61 | assertThat(result.activeTasksPercent, `is`(0f)) 62 | assertThat(result.completedTasksPercent, `is`(100f)) 63 | } 64 | 65 | @Test 66 | fun getActiveAndCompletedStats_both() { 67 | // Given 3 completed tasks and 2 active tasks 68 | val tasks = listOf( 69 | Task(id = "1", title = "title", description = "desc", isCompleted = true), 70 | Task(id = "2", title = "title", description = "desc", isCompleted = true), 71 | Task(id = "3", title = "title", description = "desc", isCompleted = true), 72 | Task(id = "4", title = "title", description = "desc", isCompleted = false), 73 | Task(id = "5", title = "title", description = "desc", isCompleted = false), 74 | ) 75 | // When the list of tasks is computed 76 | val result = getActiveAndCompletedStats(tasks) 77 | 78 | // Then the result is 40-60 79 | assertThat(result.activeTasksPercent, `is`(40f)) 80 | assertThat(result.completedTasksPercent, `is`(60f)) 81 | } 82 | 83 | @Test 84 | fun getActiveAndCompletedStats_empty() { 85 | // When there are no tasks 86 | val result = getActiveAndCompletedStats(emptyList()) 87 | 88 | // Both active and completed tasks are 0 89 | assertThat(result.activeTasksPercent, `is`(0f)) 90 | assertThat(result.completedTasksPercent, `is`(0f)) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/test/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModelTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.statistics 18 | 19 | import com.example.android.architecture.blueprints.todoapp.MainCoroutineRule 20 | import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository 21 | import com.example.android.architecture.blueprints.todoapp.data.Task 22 | import com.google.common.truth.Truth.assertThat 23 | import kotlinx.coroutines.Dispatchers 24 | import kotlinx.coroutines.ExperimentalCoroutinesApi 25 | import kotlinx.coroutines.flow.first 26 | import kotlinx.coroutines.launch 27 | import kotlinx.coroutines.test.StandardTestDispatcher 28 | import kotlinx.coroutines.test.advanceUntilIdle 29 | import kotlinx.coroutines.test.runTest 30 | import kotlinx.coroutines.test.setMain 31 | import org.junit.Before 32 | import org.junit.Rule 33 | import org.junit.Test 34 | 35 | /** 36 | * Unit tests for the implementation of [StatisticsViewModel] 37 | */ 38 | @ExperimentalCoroutinesApi 39 | class StatisticsViewModelTest { 40 | 41 | // Subject under test 42 | private lateinit var statisticsViewModel: StatisticsViewModel 43 | 44 | // Use a fake repository to be injected into the viewmodel 45 | private lateinit var tasksRepository: FakeTaskRepository 46 | 47 | // Set the main coroutines dispatcher for unit testing. 48 | @ExperimentalCoroutinesApi 49 | @get:Rule 50 | val mainCoroutineRule = MainCoroutineRule() 51 | 52 | @Before 53 | fun setupStatisticsViewModel() { 54 | tasksRepository = FakeTaskRepository() 55 | statisticsViewModel = StatisticsViewModel(tasksRepository) 56 | } 57 | 58 | @Test 59 | fun loadEmptyTasksFromRepository_EmptyResults() = runTest { 60 | // Given an initialized StatisticsViewModel with no tasks 61 | 62 | // Then the results are empty 63 | val uiState = statisticsViewModel.uiState.first() 64 | assertThat(uiState.isEmpty).isTrue() 65 | } 66 | 67 | @Test 68 | fun loadNonEmptyTasksFromRepository_NonEmptyResults() = runTest { 69 | // We initialise the tasks to 3, with one active and two completed 70 | val task1 = Task(id = "1", title = "Title1", description = "Desc1") 71 | val task2 = Task(id = "2", title = "Title2", description = "Desc2", isCompleted = true) 72 | val task3 = Task(id = "3", title = "Title3", description = "Desc3", isCompleted = true) 73 | val task4 = Task(id = "4", title = "Title4", description = "Desc4", isCompleted = true) 74 | tasksRepository.addTasks(task1, task2, task3, task4) 75 | 76 | // Then the results are not empty 77 | val uiState = statisticsViewModel.uiState.first() 78 | assertThat(uiState.isEmpty).isFalse() 79 | assertThat(uiState.activeTasksPercent).isEqualTo(25f) 80 | assertThat(uiState.completedTasksPercent).isEqualTo(75f) 81 | assertThat(uiState.isLoading).isEqualTo(false) 82 | } 83 | 84 | @Test 85 | fun loadTasks_loading() = runTest { 86 | // Set Main dispatcher to not run coroutines eagerly, for just this one test 87 | Dispatchers.setMain(StandardTestDispatcher()) 88 | 89 | var isLoading: Boolean? = true 90 | val job = launch { 91 | statisticsViewModel.uiState.collect { 92 | isLoading = it.isLoading 93 | } 94 | } 95 | 96 | // Then progress indicator is shown 97 | assertThat(isLoading).isTrue() 98 | 99 | // Execute pending coroutines actions 100 | advanceUntilIdle() 101 | 102 | // Then progress indicator is hidden 103 | assertThat(isLoading).isFalse() 104 | job.cancel() 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /app/src/test/java/com/example/android/architecture/blueprints/todoapp/taskdetail/TaskDetailViewModelTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.taskdetail 18 | 19 | import androidx.lifecycle.SavedStateHandle 20 | import com.example.android.architecture.blueprints.todoapp.MainCoroutineRule 21 | import com.example.android.architecture.blueprints.todoapp.R 22 | import com.example.android.architecture.blueprints.todoapp.TodoDestinationsArgs 23 | import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository 24 | import com.example.android.architecture.blueprints.todoapp.data.Task 25 | import com.google.common.truth.Truth.assertThat 26 | import kotlinx.coroutines.Dispatchers 27 | import kotlinx.coroutines.ExperimentalCoroutinesApi 28 | import kotlinx.coroutines.flow.first 29 | import kotlinx.coroutines.launch 30 | import kotlinx.coroutines.test.StandardTestDispatcher 31 | import kotlinx.coroutines.test.advanceUntilIdle 32 | import kotlinx.coroutines.test.runTest 33 | import kotlinx.coroutines.test.setMain 34 | import org.junit.Assert.assertTrue 35 | import org.junit.Before 36 | import org.junit.Rule 37 | import org.junit.Test 38 | 39 | /** 40 | * Unit tests for the implementation of [TaskDetailViewModel] 41 | */ 42 | @ExperimentalCoroutinesApi 43 | class TaskDetailViewModelTest { 44 | 45 | // Set the main coroutines dispatcher for unit testing. 46 | @ExperimentalCoroutinesApi 47 | @get:Rule 48 | val mainCoroutineRule = MainCoroutineRule() 49 | 50 | // Subject under test 51 | private lateinit var taskDetailViewModel: TaskDetailViewModel 52 | 53 | // Use a fake repository to be injected into the viewmodel 54 | private lateinit var tasksRepository: FakeTaskRepository 55 | private val task = Task(title = "Title1", description = "Description1", id = "0") 56 | 57 | @Before 58 | fun setupViewModel() { 59 | tasksRepository = FakeTaskRepository() 60 | tasksRepository.addTasks(task) 61 | 62 | taskDetailViewModel = TaskDetailViewModel( 63 | tasksRepository, 64 | SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "0")) 65 | ) 66 | } 67 | 68 | @Test 69 | fun getActiveTaskFromRepositoryAndLoadIntoView() = runTest { 70 | val uiState = taskDetailViewModel.uiState.first() 71 | // Then verify that the view was notified 72 | assertThat(uiState.task?.title).isEqualTo(task.title) 73 | assertThat(uiState.task?.description).isEqualTo(task.description) 74 | } 75 | 76 | @Test 77 | fun completeTask() = runTest { 78 | // Verify that the task was active initially 79 | assertThat(tasksRepository.savedTasks.value[task.id]?.isCompleted).isFalse() 80 | 81 | // When the ViewModel is asked to complete the task 82 | assertThat(taskDetailViewModel.uiState.first().task?.id).isEqualTo("0") 83 | taskDetailViewModel.setCompleted(true) 84 | 85 | // Then the task is completed and the snackbar shows the correct message 86 | assertThat(tasksRepository.savedTasks.value[task.id]?.isCompleted).isTrue() 87 | assertThat(taskDetailViewModel.uiState.first().userMessage) 88 | .isEqualTo(R.string.task_marked_complete) 89 | } 90 | 91 | @Test 92 | fun activateTask() = runTest { 93 | tasksRepository.deleteAllTasks() 94 | tasksRepository.addTasks(task.copy(isCompleted = true)) 95 | 96 | // Verify that the task was completed initially 97 | assertThat(tasksRepository.savedTasks.value[task.id]?.isCompleted).isTrue() 98 | 99 | // When the ViewModel is asked to complete the task 100 | assertThat(taskDetailViewModel.uiState.first().task?.id).isEqualTo("0") 101 | taskDetailViewModel.setCompleted(false) 102 | 103 | // Then the task is not completed and the snackbar shows the correct message 104 | val newTask = tasksRepository.getTask(task.id) 105 | assertTrue((newTask?.isActive) ?: false) 106 | assertThat(taskDetailViewModel.uiState.first().userMessage) 107 | .isEqualTo(R.string.task_marked_active) 108 | } 109 | 110 | @Test 111 | fun taskDetailViewModel_repositoryError() = runTest { 112 | // Given a repository that throws errors 113 | tasksRepository.setShouldThrowError(true) 114 | 115 | // Then the task is null and the snackbar shows a loading error message 116 | assertThat(taskDetailViewModel.uiState.value.task).isNull() 117 | assertThat(taskDetailViewModel.uiState.first().userMessage) 118 | .isEqualTo(R.string.loading_task_error) 119 | } 120 | 121 | @Test 122 | fun taskDetailViewModel_taskNotFound() = runTest { 123 | // Given an ID for a non existent task 124 | taskDetailViewModel = TaskDetailViewModel( 125 | tasksRepository, 126 | SavedStateHandle(mapOf(TodoDestinationsArgs.TASK_ID_ARG to "nonexistent_id")) 127 | ) 128 | 129 | // The task is null and the snackbar shows a "not found" error message 130 | assertThat(taskDetailViewModel.uiState.value.task).isNull() 131 | assertThat(taskDetailViewModel.uiState.first().userMessage) 132 | .isEqualTo(R.string.task_not_found) 133 | } 134 | 135 | @Test 136 | fun deleteTask() = runTest { 137 | assertThat(tasksRepository.savedTasks.value.containsValue(task)).isTrue() 138 | 139 | // When the deletion of a task is requested 140 | taskDetailViewModel.deleteTask() 141 | 142 | assertThat(tasksRepository.savedTasks.value.containsValue(task)).isFalse() 143 | } 144 | 145 | @Test 146 | fun loadTask_loading() = runTest { 147 | // Set Main dispatcher to not run coroutines eagerly, for just this one test 148 | Dispatchers.setMain(StandardTestDispatcher()) 149 | 150 | var isLoading: Boolean? = true 151 | val job = launch { 152 | taskDetailViewModel.uiState.collect { 153 | isLoading = it.isLoading 154 | } 155 | } 156 | 157 | // Then progress indicator is shown 158 | assertThat(isLoading).isTrue() 159 | 160 | // Execute pending coroutines actions 161 | advanceUntilIdle() 162 | 163 | // Then progress indicator is hidden 164 | assertThat(isLoading).isFalse() 165 | job.cancel() 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline 2 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | plugins { 17 | alias(libs.plugins.android.application) apply false 18 | alias(libs.plugins.android.library) apply false 19 | alias(libs.plugins.kotlin.android) apply false 20 | alias(libs.plugins.ksp) apply false 21 | alias(libs.plugins.hilt) apply false 22 | alias(libs.plugins.compose.compiler) apply false 23 | } 24 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx10248m -XX:MaxPermSize=256m 13 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | # org.gradle.parallel=true 19 | android.enableJetifier=true 20 | android.useAndroidX=true 21 | ksp.incremental.apt=true 22 | org.gradle.unsafe.configuration-cache=true 23 | -------------------------------------------------------------------------------- /gradle/init.gradle.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // The init script is used to run Spotless in a gradle configuration cache compliant manner as 18 | // Spotless itself is not gradle configuration cache compliant. 19 | // Note that the init script needs to be run with the configuration cache turned off. 20 | 21 | val ktlintVersion = "0.44.0" 22 | 23 | initscript { 24 | val spotlessVersion = "6.25.0" 25 | 26 | repositories { 27 | mavenCentral() 28 | } 29 | 30 | dependencies { 31 | classpath("com.diffplug.spotless:spotless-plugin-gradle:$spotlessVersion") 32 | } 33 | } 34 | 35 | rootProject { 36 | subprojects { 37 | apply() 38 | extensions.configure { 39 | kotlin { 40 | target("**/*.kt") 41 | targetExclude("**/build/**/*.kt") 42 | ktlint(ktlintVersion).userData(mapOf("android" to "true")) 43 | licenseHeaderFile(rootProject.file("spotless/copyright.kt")) 44 | } 45 | format("kts") { 46 | target("**/*.kts") 47 | targetExclude("**/build/**/*.kts") 48 | // Look for the first line that doesn't have a block comment (assumed to be the license) 49 | licenseHeaderFile(rootProject.file("spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)") 50 | } 51 | format("xml") { 52 | target("**/*.xml") 53 | targetExclude("**/build/**/*.xml") 54 | // Look for the first XML tag that isn't a comment ( 17 | 18 | -------------------------------------------------------------------------------- /shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/CustomTestRunner.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp 18 | 19 | import android.app.Application 20 | import android.content.Context 21 | import androidx.test.runner.AndroidJUnitRunner 22 | import dagger.hilt.android.testing.HiltTestApplication 23 | 24 | class CustomTestRunner : AndroidJUnitRunner() { 25 | override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { 26 | return super.newApplication(cl, HiltTestApplication::class.java.name, context) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/MainCoroutineRule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp 18 | 19 | import kotlinx.coroutines.Dispatchers 20 | import kotlinx.coroutines.ExperimentalCoroutinesApi 21 | import kotlinx.coroutines.test.TestDispatcher 22 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 23 | import kotlinx.coroutines.test.resetMain 24 | import kotlinx.coroutines.test.setMain 25 | import org.junit.rules.TestWatcher 26 | import org.junit.runner.Description 27 | 28 | /** 29 | * Sets the main coroutines dispatcher to a [TestDispatcher] for unit testing. 30 | * 31 | * Declare it as a JUnit Rule: 32 | * 33 | * ``` 34 | * @get:Rule 35 | * val mainCoroutineRule = MainCoroutineRule() 36 | * ``` 37 | * 38 | * Then, use `runTest` to execute your tests. 39 | */ 40 | @ExperimentalCoroutinesApi 41 | class MainCoroutineRule( 42 | val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() 43 | ) : TestWatcher() { 44 | 45 | override fun starting(description: Description?) { 46 | super.starting(description) 47 | Dispatchers.setMain(testDispatcher) 48 | } 49 | 50 | override fun finished(description: Description?) { 51 | super.finished(description) 52 | Dispatchers.resetMain() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data 18 | 19 | import androidx.annotation.VisibleForTesting 20 | import java.util.UUID 21 | import kotlinx.coroutines.flow.Flow 22 | import kotlinx.coroutines.flow.MutableStateFlow 23 | import kotlinx.coroutines.flow.StateFlow 24 | import kotlinx.coroutines.flow.asStateFlow 25 | import kotlinx.coroutines.flow.first 26 | import kotlinx.coroutines.flow.map 27 | import kotlinx.coroutines.flow.update 28 | 29 | /** 30 | * Implementation of a tasks repository with static access to the data for easy testing. 31 | */ 32 | class FakeTaskRepository : TaskRepository { 33 | 34 | private var shouldThrowError = false 35 | 36 | private val _savedTasks = MutableStateFlow(LinkedHashMap()) 37 | val savedTasks: StateFlow> = _savedTasks.asStateFlow() 38 | 39 | private val observableTasks: Flow> = savedTasks.map { 40 | if (shouldThrowError) { 41 | throw Exception("Test exception") 42 | } else { 43 | it.values.toList() 44 | } 45 | } 46 | 47 | fun setShouldThrowError(value: Boolean) { 48 | shouldThrowError = value 49 | } 50 | 51 | override suspend fun refresh() { 52 | // Tasks already refreshed 53 | } 54 | 55 | override suspend fun refreshTask(taskId: String) { 56 | refresh() 57 | } 58 | 59 | override suspend fun createTask(title: String, description: String): String { 60 | val taskId = generateTaskId() 61 | Task(title = title, description = description, id = taskId).also { 62 | saveTask(it) 63 | } 64 | return taskId 65 | } 66 | 67 | override fun getTasksStream(): Flow> = observableTasks 68 | 69 | override fun getTaskStream(taskId: String): Flow { 70 | return observableTasks.map { tasks -> 71 | return@map tasks.firstOrNull { it.id == taskId } 72 | } 73 | } 74 | 75 | override suspend fun getTask(taskId: String, forceUpdate: Boolean): Task? { 76 | if (shouldThrowError) { 77 | throw Exception("Test exception") 78 | } 79 | return savedTasks.value[taskId] 80 | } 81 | 82 | override suspend fun getTasks(forceUpdate: Boolean): List { 83 | if (shouldThrowError) { 84 | throw Exception("Test exception") 85 | } 86 | return observableTasks.first() 87 | } 88 | 89 | override suspend fun updateTask(taskId: String, title: String, description: String) { 90 | val updatedTask = _savedTasks.value[taskId]?.copy( 91 | title = title, 92 | description = description 93 | ) ?: throw Exception("Task (id $taskId) not found") 94 | 95 | saveTask(updatedTask) 96 | } 97 | 98 | private fun saveTask(task: Task) { 99 | _savedTasks.update { tasks -> 100 | val newTasks = LinkedHashMap(tasks) 101 | newTasks[task.id] = task 102 | newTasks 103 | } 104 | } 105 | 106 | override suspend fun completeTask(taskId: String) { 107 | _savedTasks.value[taskId]?.let { 108 | saveTask(it.copy(isCompleted = true)) 109 | } 110 | } 111 | 112 | override suspend fun activateTask(taskId: String) { 113 | _savedTasks.value[taskId]?.let { 114 | saveTask(it.copy(isCompleted = false)) 115 | } 116 | } 117 | 118 | override suspend fun clearCompletedTasks() { 119 | _savedTasks.update { tasks -> 120 | tasks.filterValues { 121 | !it.isCompleted 122 | } as LinkedHashMap 123 | } 124 | } 125 | 126 | override suspend fun deleteTask(taskId: String) { 127 | _savedTasks.update { tasks -> 128 | val newTasks = LinkedHashMap(tasks) 129 | newTasks.remove(taskId) 130 | newTasks 131 | } 132 | } 133 | 134 | override suspend fun deleteAllTasks() { 135 | _savedTasks.update { 136 | LinkedHashMap() 137 | } 138 | } 139 | 140 | private fun generateTaskId() = UUID.randomUUID().toString() 141 | 142 | @VisibleForTesting 143 | fun addTasks(vararg tasks: Task) { 144 | _savedTasks.update { oldTasks -> 145 | val newTasks = LinkedHashMap(oldTasks) 146 | for (task in tasks) { 147 | newTasks[task.id] = task 148 | } 149 | newTasks 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/local/FakeTaskDao.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data.source.local 18 | 19 | import kotlinx.coroutines.flow.Flow 20 | 21 | class FakeTaskDao(initialTasks: List? = emptyList()) : TaskDao { 22 | 23 | private var _tasks: MutableMap? = null 24 | 25 | var tasks: List? 26 | get() = _tasks?.values?.toList() 27 | set(newTasks) { 28 | _tasks = newTasks?.associateBy { it.id }?.toMutableMap() 29 | } 30 | 31 | init { 32 | tasks = initialTasks 33 | } 34 | 35 | override suspend fun getAll() = tasks ?: throw Exception("Task list is null") 36 | 37 | override suspend fun getById(taskId: String): LocalTask? = _tasks?.get(taskId) 38 | 39 | override suspend fun upsertAll(tasks: List) { 40 | _tasks?.putAll(tasks.associateBy { it.id }) 41 | } 42 | 43 | override suspend fun upsert(task: LocalTask) { 44 | _tasks?.put(task.id, task) 45 | } 46 | 47 | override suspend fun updateCompleted(taskId: String, completed: Boolean) { 48 | _tasks?.get(taskId)?.let { it.isCompleted = completed } 49 | } 50 | 51 | override suspend fun deleteAll() { 52 | _tasks?.clear() 53 | } 54 | 55 | override suspend fun deleteById(taskId: String): Int { 56 | return if (_tasks?.remove(taskId) == null) { 57 | 0 58 | } else { 59 | 1 60 | } 61 | } 62 | 63 | override suspend fun deleteCompleted(): Int { 64 | _tasks?.apply { 65 | val originalSize = size 66 | entries.removeIf { it.value.isCompleted } 67 | return originalSize - size 68 | } 69 | return 0 70 | } 71 | 72 | override fun observeAll(): Flow> { 73 | TODO("Not implemented") 74 | } 75 | 76 | override fun observeById(taskId: String): Flow { 77 | TODO("Not implemented") 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/FakeNetworkDataSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.data.source.network 18 | 19 | class FakeNetworkDataSource( 20 | var tasks: MutableList? = mutableListOf() 21 | ) : NetworkDataSource { 22 | override suspend fun loadTasks() = tasks ?: throw Exception("Task list is null") 23 | 24 | override suspend fun saveTasks(tasks: List) { 25 | this.tasks = tasks.toMutableList() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DatabaseTestModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.di 18 | 19 | import android.content.Context 20 | import androidx.room.Room 21 | import com.example.android.architecture.blueprints.todoapp.data.source.local.ToDoDatabase 22 | import dagger.Module 23 | import dagger.Provides 24 | import dagger.hilt.android.qualifiers.ApplicationContext 25 | import dagger.hilt.components.SingletonComponent 26 | import dagger.hilt.testing.TestInstallIn 27 | import javax.inject.Singleton 28 | 29 | @Module 30 | @TestInstallIn( 31 | components = [SingletonComponent::class], 32 | replaces = [DatabaseModule::class] 33 | ) 34 | object DatabaseTestModule { 35 | 36 | @Singleton 37 | @Provides 38 | fun provideDataBase(@ApplicationContext context: Context): ToDoDatabase { 39 | return Room 40 | .inMemoryDatabaseBuilder(context.applicationContext, ToDoDatabase::class.java) 41 | .allowMainThreadQueries() 42 | .build() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/di/RepositoryTestModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.android.architecture.blueprints.todoapp.di 18 | 19 | import com.example.android.architecture.blueprints.todoapp.data.FakeTaskRepository 20 | import com.example.android.architecture.blueprints.todoapp.data.TaskRepository 21 | import dagger.Module 22 | import dagger.Provides 23 | import dagger.hilt.components.SingletonComponent 24 | import dagger.hilt.testing.TestInstallIn 25 | import javax.inject.Singleton 26 | 27 | @Module 28 | @TestInstallIn( 29 | components = [SingletonComponent::class], 30 | replaces = [RepositoryModule::class] 31 | ) 32 | object RepositoryTestModule { 33 | 34 | @Singleton 35 | @Provides 36 | fun provideTasksRepository(): TaskRepository { 37 | return FakeTaskRepository() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /spotless/copyright.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright $YEAR The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | -------------------------------------------------------------------------------- /spotless/copyright.kts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright $YEAR The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | -------------------------------------------------------------------------------- /spotless/copyright.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | --------------------------------------------------------------------------------