├── .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 |
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 |
20 |
21 |
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 |
--------------------------------------------------------------------------------