Merge branch 'main' into bw/initialMetrics

pull/145/head
Ben Weiss 3 years ago
commit fa16a25ac8
No known key found for this signature in database
GPG Key ID: 8424F9C1E763A74C

@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v1
@ -46,27 +46,37 @@ jobs:
- name: Check lint - name: Check lint
run: ./gradlew lintDebug --stacktrace run: ./gradlew lintDebug --stacktrace
- name: Build debug - name: Build all build type and flavor permutations
run: ./gradlew assembleDebug --stacktrace run: ./gradlew assemble --stacktrace
- name: Build release
run: ./gradlew assembleRelease --stacktrace
- name: Run local tests - name: Run local tests
run: ./gradlew testDebug --stacktrace run: ./gradlew testDemoDebug testProdDebug --stacktrace
- name: Upload Demo build outputs (APKs)
uses: actions/upload-artifact@v2
with:
name: build-outputs-demo
path: app/demo/build/outputs
- name: Upload build outputs (APKs) - name: Upload Prod build outputs (APKs)
uses: actions/upload-artifact@v2
with:
name: build-outputs-prod
path: app/prod/build/outputs
- name: Upload Demo build reports
if: always()
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: build-outputs name: build-reports-demo
path: app/build/outputs path: app/demo/build/reports
- name: Upload build reports - name: Upload Prod build reports
if: always() if: always()
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: build-reports name: build-reports-prod
path: app/build/reports path: app/prod/build/reports
androidTest: androidTest:
needs: build needs: build
@ -78,7 +88,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Copy CI gradle.properties - name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
@ -107,11 +117,11 @@ jobs:
disable-animations: true disable-animations: true
disk-size: 1500M disk-size: 1500M
heap-size: 512M heap-size: 512M
script: ./gradlew connectedAndroidTest -x :benchmark:connectedBenchmarkAndroidTest script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedBenchmarkAndroidTest
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: test-reports name: test-reports
path: app/build/reports/androidTests path: '*/build/reports/androidTests'

@ -12,7 +12,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v1

@ -52,18 +52,26 @@ and is described in detail in the
# Build # Build
The `debug` variant of `app` uses local data to allow immediate building and exploring the UI. The app contains the usual `debug` and `release` build variants.
The `staging` and `release` variants of `app` make real network calls to a backend server, providing In addition, the `benchmark` variant of `app` is used to test startup performance and generate a
up-to-date data as new episodes of Now in Android are released. At this time, there is not a baseline profile (see below for more information).
public backend available.
The `benchmark` variant of `app` is used to test startup performance and generate a baseline profile
(see below for more information).
`app-nia-catalog` is a standalone app that displays the list of components that are stylized for `app-nia-catalog` is a standalone app that displays the list of components that are stylized for
Now in Android. Now in Android.
The app also uses
[product flavors](https://developer.android.com/studio/build/build-variants#product-flavors) to
control where content for the app should be loaded from.
The `demo` flavor uses static local data to allow immediate building and exploring the UI.
The `prod` flavor makes real network calls to a backend server, providing up-to-date content. At
this time, there is not a public backend available.
For normal development use the `demoDebug` variant. For UI performance testing use the
`demoRelease` variant.
# Testing # Testing
To facilitate testing of components, Now in Android uses dependency injection with To facilitate testing of components, Now in Android uses dependency injection with

@ -22,6 +22,10 @@ plugins {
android { android {
defaultConfig { defaultConfig {
applicationId = "com.google.samples.apps.niacatalog" applicationId = "com.google.samples.apps.niacatalog"
// The UI catalog does not depend on content from the app, however, it depends on modules
// which do, so we must specify a default value for the contentType dimension.
missingDimensionStrategy("contentType", "demo")
} }
packagingOptions { packagingOptions {

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import com.google.samples.apps.nowinandroid.FlavorDimension
import com.google.samples.apps.nowinandroid.Flavor
plugins { plugins {
id("nowinandroid.android.application") id("nowinandroid.android.application")
id("nowinandroid.android.application.compose") id("nowinandroid.android.application.compose")
@ -43,20 +46,41 @@ android {
val release by getting { val release by getting {
isMinifyEnabled = true isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// To publish on the Play store a private signing key is required, but to allow anyone
// who clones the code to sign and run the release variant, use the debug signing key.
// TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
signingConfig = signingConfigs.getByName("debug")
} }
val benchmark by creating { val benchmark by creating {
// Enable all the optimizations from release build through initWith(release).
initWith(release) initWith(release)
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks.add("release") matchingFallbacks.add("release")
// Debug key signing is available to everyone.
signingConfig = signingConfigs.getByName("debug")
// Only use benchmark proguard rules
proguardFiles("benchmark-rules.pro") proguardFiles("benchmark-rules.pro")
// FIXME enabling minification breaks access to demo backend.
isMinifyEnabled = false
// Keep the build type debuggable so we can attach a debugger if needed.
isDebuggable = true
applicationIdSuffix = ".benchmark"
} }
val staging by creating { }
initWith(debug)
signingConfig = signingConfigs.getByName("debug") // @see Flavor for more details on the app product flavors.
matchingFallbacks.add("debug") flavorDimensions += FlavorDimension.contentType.name
applicationIdSuffix = ".staging" productFlavors {
Flavor.values().forEach {
create(it.name) {
dimension = it.dimension.name
if (it.applicationIdSuffix != null) {
applicationIdSuffix = it.applicationIdSuffix
}
}
} }
} }
packagingOptions { packagingOptions {
resources { resources {
excludes.add("/META-INF/{AL2.0,LGPL2.1}") excludes.add("/META-INF/{AL2.0,LGPL2.1}")
@ -105,7 +129,7 @@ dependencies {
resolutionStrategy { resolutionStrategy {
force(libs.junit4) force(libs.junit4)
// Temporary workaround for https://issuetracker.google.com/174733673 // Temporary workaround for https://issuetracker.google.com/174733673
force("org.objenesis:objenesis:2.6") force("org.objenesis:objenesis:3.2")
} }
} }
} }

File diff suppressed because it is too large Load Diff

@ -25,6 +25,7 @@ android {
defaultConfig { defaultConfig {
minSdk = 23 minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
missingDimensionStrategy("contentType", "demo")
} }
buildTypes { buildTypes {
@ -32,7 +33,7 @@ android {
// release build (for example, with minification on). It's signed with a debug key // release build (for example, with minification on). It's signed with a debug key
// for easy local/CI testing. // for easy local/CI testing.
val benchmark by creating { val benchmark by creating {
isDebuggable = true isDebuggable = false
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
matchingFallbacks.add("release") matchingFallbacks.add("release")
} }

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.baselineprofile
import androidx.benchmark.macro.ExperimentalBaselineProfilesApi import androidx.benchmark.macro.ExperimentalBaselineProfilesApi
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -32,7 +33,7 @@ class BaselineProfileGenerator {
@Test @Test
fun startup() = fun startup() =
baselineProfileRule.collectBaselineProfile( baselineProfileRule.collectBaselineProfile(
packageName = "com.google.samples.apps.nowinandroid" packageName = "com.google.samples.apps.nowinandroid.demo.benchmark"
) { ) {
pressHome() pressHome()
// This block defines the app's critical user journey. Here we are interested in // This block defines the app's critical user journey. Here we are interested in
@ -45,6 +46,12 @@ class BaselineProfileGenerator {
findObject(By.text("Interests")) findObject(By.text("Interests"))
.click() .click()
waitForIdle() waitForIdle()
findObject(By.text("Accessibility")).scroll(Direction.DOWN, 2000f)
waitForIdle()
findObject(By.text("People")).click()
waitForIdle()
findObject(By.textStartsWith("Android")).scroll(Direction.DOWN, 2000f)
waitForIdle()
} }
} }
} }

@ -15,6 +15,7 @@
*/ */
import com.android.build.gradle.LibraryExtension import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
@ -34,6 +35,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 32 defaultConfig.targetSdk = 32
configureFlavors(this)
} }
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs") val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
@ -49,4 +51,4 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
} }
} }
} }

@ -0,0 +1,31 @@
package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
enum class FlavorDimension {
contentType
}
// The content for the app can either come from local static data which is useful for demo
// purposes, or from a production backend server which supplies up-to-date, real content.
// These two product flavors reflect this behaviour.
enum class Flavor (val dimension : FlavorDimension, val applicationIdSuffix : String? = null) {
demo(FlavorDimension.contentType, ".demo"),
prod(FlavorDimension.contentType)
}
fun Project.configureFlavors(
commonExtension: CommonExtension<*, *, *, *>
) {
commonExtension.apply {
flavorDimensions += FlavorDimension.contentType.name
productFlavors {
Flavor.values().forEach{
create(it.name) {
dimension = it.dimension.name
}
}
}
}
}

@ -16,6 +16,8 @@
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.model.data
/* ktlint-disable max-line-length */
/** /**
* External data layer representation of an NiA Author * External data layer representation of an NiA Author
*/ */
@ -27,3 +29,22 @@ data class Author(
val mediumPage: String, val mediumPage: String,
val bio: String, val bio: String,
) )
val previewAuthors = listOf(
Author(
id = "22",
name = "Alex Vanyo",
mediumPage = "https://medium.com/@alexvanyo",
twitter = "https://twitter.com/alex_vanyo",
imageUrl = "https://pbs.twimg.com/profile_images/1431339735931305989/nOE2mmi2_400x400.jpg",
bio = "Alex joined Android DevRel in 2021, and has worked supporting form factors from small watches to large foldables and tablets. His special interests include insets, Compose, testing and state."
),
Author(
id = "3",
name = "Simona Stojanovic",
mediumPage = "https://medium.com/@anomisSi",
twitter = "https://twitter.com/anomisSi",
imageUrl = "https://pbs.twimg.com/profile_images/1437506849016778756/pG0NZALw_400x400.jpg",
bio = "Android Developer Relations Engineer @Google, working on the Compose team and taking care of Layouts & Navigation."
)
)

@ -16,7 +16,13 @@
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.model.data
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
/* ktlint-disable max-line-length */
/** /**
* External data layer representation of a fully populated NiA news resource * External data layer representation of a fully populated NiA news resource
@ -33,3 +39,26 @@ data class NewsResource(
val authors: List<Author>, val authors: List<Author>,
val topics: List<Topic> val topics: List<Topic>
) )
val previewNewsResources = listOf(
NewsResource(
id = "1",
episodeId = "60",
title = "Android Basics with Compose",
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
authors = listOf(previewAuthors[0]),
publishDate = LocalDateTime(
year = 2022,
monthNumber = 5,
dayOfMonth = 4,
hour = 23,
minute = 0,
second = 0,
nanosecond = 0
).toInstant(TimeZone.UTC),
type = Codelab,
topics = listOf(previewTopics[1])
)
)

@ -16,6 +16,8 @@
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.model.data
/* ktlint-disable max-line-length */
/** /**
* External data layer representation of a NiA Topic * External data layer representation of a NiA Topic
*/ */
@ -27,3 +29,30 @@ data class Topic(
val url: String, val url: String,
val imageUrl: String, val imageUrl: String,
) )
val previewTopics = listOf(
Topic(
id = "2",
name = "Headlines",
shortDescription = "News we want everyone to see",
longDescription = "Stay up to date with the latest events and announcements from Android!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f",
url = ""
),
Topic(
id = "3",
name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets",
longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594",
url = ""
),
Topic(
id = "4",
name = "Testing",
shortDescription = "CI, Espresso, TestLab, etc",
longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428",
url = ""
),
)

@ -23,22 +23,6 @@ plugins {
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
} }
android {
buildTypes {
val staging by creating {
initWith(getByName("debug"))
matchingFallbacks.add("debug")
}
}
// Force the staging variant to use the release source directory. This is necessary so that the
// staging variant uses the remote network.
sourceSets {
getByName("staging") {
java.srcDir("src/release/java")
}
}
}
secrets { secrets {
defaultPropertiesFileName = "secrets.defaults.properties" defaultPropertiesFileName = "secrets.defaults.properties"
} }

@ -0,0 +1,45 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.network.di
import com.google.samples.apps.nowinandroid.core.network.NiANetwork
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiANetwork
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.serialization.json.Json
@Module
@InstallIn(SingletonComponent::class)
interface NetworkModule {
@Binds
fun bindsNiANetwork(
niANetwork: FakeNiANetwork
): NiANetwork
companion object {
@Provides
@Singleton
fun providesNetworkJson(): Json = Json {
ignoreUnknownKeys = true
}
}
}

@ -156,8 +156,14 @@ fun NewsResourceAuthors(
if (authors.isNotEmpty()) { if (authors.isNotEmpty()) {
// Only display first author for now // Only display first author for now
val author = authors[0] val author = authors[0]
val authorNameFormatted =
author.name.uppercase(ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)) val locale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
val authorNameFormatted = if (locale != null) {
author.name.uppercase(locale)
} else {
author.name.uppercase()
}
val authorImageUrl = author.imageUrl val authorImageUrl = author.imageUrl

@ -0,0 +1,247 @@
# Modularization learning journey
In this learning journey you will learn about modularization, and the modularization strategy used
to create the modules in the Now in Android app.
## Overview
Modularization is the practice of breaking the concept of a monolithic, one-module codebase into
loosely coupled, self contained modules.
### Benefits of modularization
This offers many benefits, including:
**Scalability** - In a tightly coupled codebase, a single change can trigger a cascade of
alterations. A properly modularized project will embrace
the [separation of concerns](https://en.wikipedia.org/wiki/Separation_of_concerns) principle. This
in turn empowers the contributors with more autonomy while also enforcing architectural patterns.
**Enabling work in parallel** - Modularization helps decrease version control conflicts and enables
more efficient work in parallel for developers in larger teams.
**Ownership** - A module can have a dedicated owner who is responsible for maintaining the code and
tests, fixing bugs, and reviewing changes.
**Encapsulation** - Isolated code is easier to read, understand, test and maintain.
**Reduced build time** - Leveraging Gradles parallel and incremental build can reduce build times.
**Dynamic delivery** - Modularization is a requirement
for [Play Feature Delivery](https://developer.android.com/guide/playcore/feature-delivery) which
allows certain features of your app to be delivered conditionally or downloaded on demand.
**Reusability** - Proper modularization enables opportunities for code sharing and building multiple
apps, across different platforms, from the same foundation.
### Modularization pitfalls
However, modularization is a pattern that can be misused, and there are some gotchas to be aware of
when modularizing an app:
**Too many modules** - each module has an overhead that comes in the form of increased complexity of
the build configuration. This can cause Gradle sync times to increase, and incurs an ongoing
maintenance cost. In addition, adding more modules increases the complexity of the projects Gradle
setup, when compared to a single monolithic module. This can be mitigated by making use of
convention plugins, to extract reusable and composable build configuration into type-safe Kotlin
code. In the Now in Android app, these convention plugins can be found in
the [`build-logic` folder](https://github.com/android/nowinandroid/tree/main/build-logic).
**Not enough modules** - conversely if your modules are few, large and tightly coupled, you end up
with yet another monolith. This means you lose some benefits of modularization. If your module is
bloated and has no single, well defined purpose, you should consider splitting it.
**Too complex** - there is no silver bullet here. In fact it doesnt always make sense to modularize
your project. A dominating factor is the size and relative complexity of the codebase. If your
project is not expected to grow beyond a certain threshold, the scalability and build time gains
wont apply.
## Modularization strategy
Its important to note that there is no single modularization strategy that fits all projects.
However, there are general guidelines that can be followed to ensure you maximize its benefits and
minimize its downsides.
A barebone module is simply a directory with a Gradle build script inside. Usually though, a module
will consist of one or more source sets and possibly a collection of resources or assets. Modules
can be built and tested independently. Due to Gradle's flexibility there are few constraints as to
how you can organize your project. In general, you should strive for low coupling and high cohesion.
* **Low coupling** - Modules should be as independent as possible from one another, so that changes
to one module have zero or minimal impact on other modules. They should not possess knowledge of
the inner workings of other modules.
* **High cohesion** - A module should comprise a collection of code that acts as a system. It should
have clearly defined responsibilities and stay within boundaries of certain domain knowledge. For
example,
the [`core-network` module](https://github.com/android/nowinandroid/tree/main/core-network) in Now
in Android is responsible for making network requests, handling responses from a remote data
source, and supplying data to other modules.
## Types of modules in Now in Android
![Diagram showing types of modules and their dependencies in Now in Android](images/modularization-graph.png "Diagram showing types of modules and their dependencies in Now in Android")
**Top tip**: A module graph (shown above) can be useful during modularization planning for
visualizing dependencies between modules.
The Now in Android app contains the following types of modules:
* The `app` module - contains app level and scaffolding classes that bind the rest of the codebase,
such as `MainActivity`, `NiaApp` and app-level controlled navigation. A good example of this is
the navigation setup through `NiaNavHost` and the bottom navigation bar setup
through `NiaTopLevelNavigation`. The `app` module depends on all `feature` modules and
required `core` modules.
* `feature-` modules - feature specific modules which are scoped to handle a single responsibility
in the app. These modules can be reused by any app, including test or other flavoured apps, when
needed, while still keeping it separated and isolated. If a class is needed only by one `feature`
module, it should remain within that module. If not, it should be extracted into an
appropriate `core` module. A `feature` module should have no dependencies on other feature
modules. They only depend on the `core` modules that they require.
* `core-` modules - common library modules containing auxiliary code and specific dependencies that
need to be shared between other modules in the app. These modules can depend on other core
modules, but they shouldnt depend on feature nor app modules.
* Miscellaneous modules - such as `sync`, `benchmark` and `test` modules, as well
as `app-nia-catalog` - a catalog app for displaying our design system quickly.
## Modules
Using the above modularization strategy, the Now in Android app has the following modules:
<table>
<tr>
<td><strong>Name</strong>
</td>
<td><strong>Responsibilities</strong>
</td>
<td><strong>Key classes and good examples</strong>
</td>
</tr>
<tr>
<td><code>app</code>
</td>
<td>Brings everything together required for the app to function correctly. This includes UI scaffolding and navigation.
</td>
<td><code>NiaApp, MainActivity</code><br>
App-level controlled navigation via <code>NiaNavHost, NiaTopLevelNavigation</code>
</td>
</tr>
<tr>
<td><code>feature-1,</code><br>
<code>feature-2</code><br>
...
</td>
<td>Functionality associated with a specific feature or user journey. Typically contains UI components and ViewModels which read data from other modules.<br>
Examples include:<br>
<ul>
<li><a href="https://github.com/android/nowinandroid/tree/main/feature-author"><code>feature-author</code></a> displays information about an author on the AuthorScreen.</li>
<li><a href="https://github.com/android/nowinandroid/tree/main/feature-foryou"><code>feature-foryou</code></a> which displays the user's news feed, and onboarding during first run, on the For You screen.</li>
</ul>
</td>
<td><code>AuthorScreen</code><br>
<code>AuthorViewModel</code>
</td>
</tr>
<tr>
<td><code>core-data</code>
</td>
<td>Fetching app data from multiple sources, shared by different features.
</td>
<td><code>TopicsRepository</code><br>
<code>AuthorsRepository</code>
</td>
</tr>
<tr>
<td><code>core-ui</code>
</td>
<td>UI components, composables and resources, such as icons, used by different features.
</td>
<td><code>NiaIcons</code><br>
<code>NewsResourceCardExpanded</code>
</td>
</tr>
<tr>
<td><code>core-common</code>
</td>
<td>Common classes shared between modules.
</td>
<td><code>NiaDispatchers</code><br>
<code>Result</code>
</td>
</tr>
<tr>
<td><code>core-network</code>
</td>
<td>Making network requests and handling responses from a remote data source.
</td>
<td><code>RetrofitNiANetworkApi</code>
</td>
</tr>
<tr>
<td><code>core-testing</code>
</td>
<td>Testing dependencies, repositories and util classes.
</td>
<td><code>NiaTestRunner</code><br>
<code>TestDispatcherRule</code>
</td>
</tr>
<tr>
<td><code>core-datastore</code>
</td>
<td>Storing persistent data using DataStore.
</td>
<td><code>NiaPreferences</code><br>
<code>UserPreferencesSerializer</code>
</td>
</tr>
<tr>
<td><code>core-database</code>
</td>
<td>Local database storage using Room.
</td>
<td><code>NiADatabase</code><br>
<code>DatabaseMigrations</code><br>
<code>Dao</code> classes
</td>
</tr>
<tr>
<td><code>core-model</code>
</td>
<td>Model classes used throughout the app.
</td>
<td><code>Author</code><br>
<code>Episode</code><br>
<code>NewsResource</code>
</td>
</tr>
<tr>
<td><code>core-navigation</code>
</td>
<td>Navigation dependencies and shared navigation classes.
</td>
<td><code>NiaNavigationDestination</code>
</td>
</tr>
</table>
## Modularization in Now in Android
Our modularization approach was defined taking into account the “Now in Android” project roadmap, upcoming work and new features. Additionally, our aim this time around was to find the right balance between overmodularizing a relatively small app and using this opportunity to showcase a modularization pattern fit for a much larger codebase, closer to real world apps in production environments.
This approach was discussed with the Android community, and evolved taking their feedback into account. With modularization however, there isnt one right answer that makes all others wrong. Ultimately, there are many ways and approaches to modularizing an app and rarely does one approach fit all purposes, codebases and team preferences. This is why planning beforehand and taking into account all goals, problems youre trying to solve, future work and predicting potential stepping stones are all crucial steps for defining the best fit structure under your own, unique circumstances. Developers can benefit from a brainstorming session to draw out a graph of modules and dependencies to visualize and plan this better.
Our approach is such an example - we dont expect it to be an unchangeable structure applicable to all cases, and in fact, it could evolve and change in the future. Its a general guideline we found to be the best fit for our project and offer it as one example you can further modify, expand and build on top of. One way of doing this would be to increase the granularity of the codebase even more. Granularity is the extent to which your codebase is composed of modules. If your data layer is small, its fine to keep it in a single module. But once the number of repositories and data sources starts to grow, it might be worth considering splitting them into separate modules.
We are also always open to your constructive feedback - learning from the community and exchanging ideas is one of the key elements to improving our guidance.

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.author package com.google.samples.apps.nowinandroid.feature.author
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -53,11 +52,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
import com.google.samples.apps.nowinandroid.core.ui.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.author.AuthorUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import com.google.samples.apps.nowinandroid.feature.author.R.string
@Composable @Composable
fun AuthorRoute( fun AuthorRoute(
@ -76,7 +77,6 @@ fun AuthorRoute(
) )
} }
@OptIn(ExperimentalFoundationApi::class)
@VisibleForTesting @VisibleForTesting
@Composable @Composable
internal fun AuthorScreen( internal fun AuthorScreen(
@ -100,11 +100,11 @@ internal fun AuthorScreen(
) )
} }
when (authorState) { when (authorState) {
Loading -> { AuthorUiState.Loading -> {
item { item {
LoadingWheel( LoadingWheel(
modifier = modifier, modifier = modifier,
contentDesc = stringResource(id = string.author_loading), contentDesc = stringResource(id = R.string.author_loading),
) )
} }
} }
@ -221,29 +221,45 @@ private fun AuthorToolbar(
onCheckedChange = onFollowClick, onCheckedChange = onFollowClick,
) { ) {
if (selected) { if (selected) {
Text(stringResource(id = string.author_following)) Text(stringResource(id = R.string.author_following))
} else { } else {
Text(stringResource(id = string.author_not_following)) Text(stringResource(id = R.string.author_not_following))
} }
} }
} }
} }
@Preview @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable @Composable
private fun AuthorBodyPreview() { fun AuthorScreenPopulated() {
MaterialTheme { NiaTheme {
LazyColumn { NiaBackground {
authorBody( AuthorScreen(
author = Author( authorState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)),
id = "0", newsState = NewsUiState.Success(previewNewsResources),
name = "Android Dev", onBackClick = {},
bio = "Works on Compose", onFollowClick = {}
twitter = "dev", )
mediumPage = "", }
imageUrl = "", }
), }
news = NewsUiState.Success(emptyList())
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable
fun AuthorScreenLoading() {
NiaTheme {
NiaBackground {
AuthorScreen(
authorState = AuthorUiState.Loading,
newsState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {}
) )
} }
} }

@ -75,8 +75,6 @@ class AuthorViewModelTest {
successAuthorUiState.followableAuthor.author successAuthorUiState.followableAuthor.author
assertEquals(authorFromRepository, successAuthorUiState.followableAuthor.author) assertEquals(authorFromRepository, successAuthorUiState.followableAuthor.author)
cancel()
} }
} }
@ -84,7 +82,6 @@ class AuthorViewModelTest {
fun uiStateNews_whenInitialized_thenShowLoading() = runTest { fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { viewModel.uiState.test {
assertEquals(NewsUiState.Loading, awaitItem().newsState) assertEquals(NewsUiState.Loading, awaitItem().newsState)
cancel()
} }
} }
@ -92,7 +89,6 @@ class AuthorViewModelTest {
fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest { fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { viewModel.uiState.test {
assertEquals(AuthorUiState.Loading, awaitItem().authorState) assertEquals(AuthorUiState.Loading, awaitItem().authorState)
cancel()
} }
} }
@ -101,7 +97,6 @@ class AuthorViewModelTest {
viewModel.uiState.test { viewModel.uiState.test {
authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) authorsRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
assertEquals(AuthorUiState.Loading, awaitItem().authorState) assertEquals(AuthorUiState.Loading, awaitItem().authorState)
cancel()
} }
} }
@ -115,7 +110,6 @@ class AuthorViewModelTest {
val item = awaitItem() val item = awaitItem()
assertTrue(item.authorState is AuthorUiState.Success) assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading) assertTrue(item.newsState is NewsUiState.Loading)
cancel()
} }
} }
@ -130,7 +124,6 @@ class AuthorViewModelTest {
val item = awaitItem() val item = awaitItem()
assertTrue(item.authorState is AuthorUiState.Success) assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Success) assertTrue(item.newsState is NewsUiState.Success)
cancel()
} }
} }
@ -149,7 +142,6 @@ class AuthorViewModelTest {
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]), AuthorUiState.Success(followableAuthor = testOutputAuthors[0]),
awaitItem().authorState awaitItem().authorState
) )
cancel()
} }
} }
} }

@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Companion
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasScrollToNodeAction
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
@ -44,7 +42,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Assert.assertTrue import org.junit.Assert
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -85,7 +83,7 @@ class ForYouScreenTest {
} }
@Test @Test
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() { fun topicSelector_whenNoTopicsSelected_showsAuthorAndTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
@ -94,65 +92,8 @@ class ForYouScreenTest {
), ),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = listOf( topics = testTopics,
FollowableTopic( authors = testAuthors
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
)
), ),
feedState = ForYouFeedUiState.Success( feedState = ForYouFeedUiState.Success(
feed = emptyList() feed = emptyList()
@ -165,20 +106,19 @@ class ForYouScreenTest {
} }
} }
composeTestRule testAuthors.forEach { testAuthor ->
.onNodeWithText("Headlines") composeTestRule
.assertIsDisplayed() .onNodeWithText(testAuthor.author.name)
.assertHasClickAction() .assertIsDisplayed()
.assertHasClickAction()
composeTestRule }
.onNodeWithText("UI")
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule testTopics.forEach { testTopic ->
.onNodeWithText("Tools") composeTestRule
.assertIsDisplayed() .onNodeWithText(testTopic.topic.name)
.assertHasClickAction() .assertIsDisplayed()
.assertHasClickAction()
}
// Scroll until the Done button is visible // Scroll until the Done button is visible
composeTestRule composeTestRule
@ -194,7 +134,7 @@ class ForYouScreenTest {
} }
@Test @Test
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeTopicsSelected_showsAuthorAndTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
@ -203,65 +143,11 @@ class ForYouScreenTest {
), ),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = listOf( // Follow one topic
FollowableTopic( topics = testTopics.mapIndexed { index, testTopic ->
topic = Topic( testTopic.copy(isFollowed = index == 1)
id = "0", },
name = "Headlines", authors = testAuthors
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = true
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
),
), ),
feedState = ForYouFeedUiState.Success( feedState = ForYouFeedUiState.Success(
feed = emptyList() feed = emptyList()
@ -274,26 +160,19 @@ class ForYouScreenTest {
} }
} }
composeTestRule testAuthors.forEach { testAuthor ->
.onNodeWithText("Headlines") composeTestRule
.assertIsDisplayed() .onNodeWithText(testAuthor.author.name)
.assertHasClickAction() .assertIsDisplayed()
.assertHasClickAction()
composeTestRule }
.onNodeWithText("UI")
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule
.onNodeWithText("Tools")
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule testTopics.forEach { testTopic ->
.onNodeWithText("Android Dev") composeTestRule
.assertIsDisplayed() .onNodeWithText(testTopic.topic.name)
.assertIsOff() .assertIsDisplayed()
.assertHasClickAction() .assertHasClickAction()
}
// Scroll until the Done button is visible // Scroll until the Done button is visible
composeTestRule composeTestRule
@ -309,7 +188,7 @@ class ForYouScreenTest {
} }
@Test @Test
fun topicSelector_whenSomeAuthorsSelected_showsTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeAuthorsSelected_showsAuthorAndTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
ForYouScreen( ForYouScreen(
@ -318,65 +197,11 @@ class ForYouScreenTest {
), ),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = listOf( // Follow one topic
FollowableTopic( topics = testTopics,
topic = Topic( authors = testAuthors.mapIndexed { index, testAuthor ->
id = "0", testAuthor.copy(isFollowed = index == 1)
name = "Headlines", }
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = true
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
),
), ),
feedState = ForYouFeedUiState.Success( feedState = ForYouFeedUiState.Success(
feed = emptyList() feed = emptyList()
@ -389,26 +214,19 @@ class ForYouScreenTest {
} }
} }
composeTestRule testAuthors.forEach { testAuthor ->
.onNodeWithText("Headlines") composeTestRule
.assertIsDisplayed() .onNodeWithText(testAuthor.author.name)
.assertHasClickAction() .assertIsDisplayed()
.assertHasClickAction()
composeTestRule }
.onNodeWithText("UI")
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule
.onNodeWithText("Tools")
.assertIsDisplayed()
.assertHasClickAction()
composeTestRule testTopics.forEach { testTopic ->
.onNodeWithText("Android Dev") composeTestRule
.assertIsDisplayed() .onNodeWithText(testTopic.topic.name)
.assertIsOff() .assertIsDisplayed()
.assertHasClickAction() .assertHasClickAction()
}
// Scroll until the Done button is visible // Scroll until the Done button is visible
composeTestRule composeTestRule
@ -433,65 +251,8 @@ class ForYouScreenTest {
), ),
interestsSelectionState = interestsSelectionState =
ForYouInterestsSelectionUiState.WithInterestsSelection( ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = listOf( topics = testTopics,
FollowableTopic( authors = testAuthors
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Tools",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = true
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
),
), ),
feedState = ForYouFeedUiState.Loading, feedState = ForYouFeedUiState.Loading,
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
@ -558,87 +319,6 @@ class ForYouScreenTest {
fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() { fun feed_whenNoInterestsSelectionAndLoaded_showsFeed() {
lateinit var windowSizeClass: WindowSizeClass lateinit var windowSizeClass: WindowSizeClass
val saveableNewsResources = listOf(
SaveableNewsResource(
newsResource = NewsResource(
id = "1",
episodeId = "52",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series " +
"and everything the Android Developers YouTube channel has to offer. " +
"During the Android Developer Summit, our YouTube channel reached 1 " +
"million subscribers! Heres a small video to thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
)
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "2",
episodeId = "52",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "3",
episodeId = "52",
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
)
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
@ -649,7 +329,7 @@ class ForYouScreenTest {
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = ForYouFeedUiState.Success( feedState = ForYouFeedUiState.Success(
feed = saveableNewsResources feed = testNewsResources
), ),
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
@ -659,22 +339,9 @@ class ForYouScreenTest {
} }
} }
// Scroll until the second feed item is visible
// This will cause both the first and second feed items to be visible at the same time,
// so we can compare their positions to each other.
composeTestRule
.onAllNodes(hasScrollToNodeAction())
.onFirst()
.performScrollToNode(
hasText(
"Transformations and customisations in the Paging Library",
substring = true
)
)
val firstFeedItem = composeTestRule val firstFeedItem = composeTestRule
.onNodeWithText( .onNodeWithText(
"Thanks for helping us reach 1M YouTube Subscribers", testNewsResources[0].newsResource.title,
substring = true substring = true
) )
.assertHasClickAction() .assertHasClickAction()
@ -682,27 +349,136 @@ class ForYouScreenTest {
val secondFeedItem = composeTestRule val secondFeedItem = composeTestRule
.onNodeWithText( .onNodeWithText(
"Transformations and customisations in the Paging Library", testNewsResources[1].newsResource.title,
substring = true substring = true
) )
.assertHasClickAction() .assertHasClickAction()
.fetchSemanticsNode() .fetchSemanticsNode()
when (windowSizeClass.widthSizeClass) { when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact, Companion.Medium -> { WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> {
// On smaller screen widths, the second feed item should be below the first because // On smaller screen widths, the second feed item should be below the first because
// they are displayed in a single column // they are displayed in a single column
assertTrue( Assert.assertTrue(
firstFeedItem.positionInRoot.y < secondFeedItem.positionInRoot.y firstFeedItem.positionInRoot.y < secondFeedItem.positionInRoot.y
) )
} }
else -> { else -> {
// On larger screen widths, the second feed item should be inline with the first // On larger screen widths, the second feed item should be inline with the first
// because they are displayed in more than one column // because they are displayed in more than one column
assertTrue( Assert.assertTrue(
firstFeedItem.positionInRoot.y == secondFeedItem.positionInRoot.y firstFeedItem.positionInRoot.y == secondFeedItem.positionInRoot.y
) )
} }
} }
} }
} }
private val testTopic = Topic(
id = "",
name = "",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
)
private val testAuthor = Author(
id = "",
name = "",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = ""
)
private val testTopics = listOf(
FollowableTopic(
topic = testTopic.copy(id = "0", name = "Headlines"),
isFollowed = false
),
FollowableTopic(
topic = testTopic.copy(id = "1", name = "UI"),
isFollowed = false
),
FollowableTopic(
topic = testTopic.copy(id = "2", name = "Tools"),
isFollowed = false
),
)
private val testAuthors = listOf(
FollowableAuthor(
author = testAuthor.copy(id = "0", name = "Android Dev"),
isFollowed = false
),
FollowableAuthor(
author = testAuthor.copy(id = "1", name = "Android Dev 2"),
isFollowed = false
),
)
private val testNewsResources = listOf(
SaveableNewsResource(
newsResource = NewsResource(
id = "1",
episodeId = "52",
title = "Small Title",
content = "small.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = null,
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = emptyList(),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "2",
episodeId = "52",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "3",
episodeId = "52",
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
)

@ -82,12 +82,12 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource import com.google.samples.apps.nowinandroid.core.model.data.SaveableNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.ui.JankMetricEffect import com.google.samples.apps.nowinandroid.core.ui.JankMetricEffect
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
@ -524,17 +524,22 @@ private fun LazyListScope.Feed(
} }
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Preview(device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable @Composable
fun ForYouScreenLoading() { fun ForYouScreenPopulatedFeed() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.Loading, interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection,
feedState = ForYouFeedUiState.Loading, feedState = ForYouFeedUiState.Success(
feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
@ -545,9 +550,10 @@ fun ForYouScreenLoading() {
} }
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Preview(device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable @Composable
fun ForYouScreenTopicSelection() { fun ForYouScreenTopicSelection() {
BoxWithConstraints { BoxWithConstraints {
@ -555,79 +561,13 @@ fun ForYouScreenTopicSelection() {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection( interestsSelectionState = ForYouInterestsSelectionUiState.WithInterestsSelection(
topics = listOf( topics = previewTopics.map { FollowableTopic(it, false) },
FollowableTopic( authors = previewAuthors.map { FollowableAuthor(it, false) }
topic = Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
FollowableTopic(
topic = Topic(
id = "2",
name = "Publishing and Distribution",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
isFollowed = false
),
),
authors = listOf(
FollowableAuthor(
author = Author(
id = "0",
name = "Android Dev",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "1",
name = "Android Dev 2",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
),
FollowableAuthor(
author = Author(
id = "2",
name = "Android Dev 3",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
),
isFollowed = false
)
)
), ),
feedState = ForYouFeedUiState.Success( feedState = ForYouFeedUiState.Success(
feed = saveableNewsResource, feed = previewNewsResources.map {
SaveableNewsResource(it, false)
}
), ),
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
@ -639,19 +579,18 @@ fun ForYouScreenTopicSelection() {
} }
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Preview(device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable @Composable
fun PopulatedFeed() { fun ForYouScreenLoading() {
BoxWithConstraints { BoxWithConstraints {
NiaTheme { NiaTheme {
ForYouScreen( ForYouScreen(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
interestsSelectionState = ForYouInterestsSelectionUiState.NoInterestsSelection, interestsSelectionState = ForYouInterestsSelectionUiState.Loading,
feedState = ForYouFeedUiState.Success( feedState = ForYouFeedUiState.Loading,
feed = saveableNewsResource
),
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
onAuthorCheckedChanged = { _, _ -> }, onAuthorCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
@ -660,84 +599,3 @@ fun PopulatedFeed() {
} }
} }
} }
private val saveableNewsResource = listOf(
SaveableNewsResource(
newsResource = NewsResource(
id = "1",
episodeId = "52",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series " +
"and everything the Android Developers YouTube channel has to offer. " +
"During the Android Developer Summit, our YouTube channel reached 1 " +
"million subscribers! Heres a small video to thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "0",
name = "Headlines",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
)
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "2",
episodeId = "52",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
SaveableNewsResource(
newsResource = NewsResource(
id = "3",
episodeId = "52",
title = "Community tip on Paging",
content = "Tips for using the Paging library from the developer community",
url = "https://youtu.be/r5JgIyS3t3s",
headerImageUrl = "https://i.ytimg.com/vi/r5JgIyS3t3s/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-08T00:00:00.000Z"),
type = Video,
topics = listOf(
Topic(
id = "1",
name = "UI",
shortDescription = "",
longDescription = "",
url = "",
imageUrl = ""
),
),
authors = emptyList()
),
isSaved = false
),
)

@ -84,7 +84,6 @@ class ForYouViewModelTest {
), ),
awaitItem() awaitItem()
) )
cancel()
} }
} }
@ -99,8 +98,6 @@ class ForYouViewModelTest {
awaitItem() awaitItem()
) )
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
cancel()
} }
} }
@ -115,8 +112,6 @@ class ForYouViewModelTest {
awaitItem() awaitItem()
) )
authorsRepository.sendAuthors(sampleAuthors) authorsRepository.sendAuthors(sampleAuthors)
cancel()
} }
} }
@ -131,8 +126,6 @@ class ForYouViewModelTest {
awaitItem() awaitItem()
) )
topicsRepository.setFollowedTopicIds(emptySet()) topicsRepository.setFollowedTopicIds(emptySet())
cancel()
} }
} }
@ -147,8 +140,6 @@ class ForYouViewModelTest {
awaitItem() awaitItem()
) )
authorsRepository.setFollowedAuthorIds(emptySet()) authorsRepository.setFollowedAuthorIds(emptySet())
cancel()
} }
} }
@ -244,8 +235,6 @@ class ForYouViewModelTest {
), ),
expectMostRecentItem() expectMostRecentItem()
) )
cancel()
} }
} }
@ -341,7 +330,6 @@ class ForYouViewModelTest {
), ),
expectMostRecentItem() expectMostRecentItem()
) )
cancel()
} }
} }
@ -382,7 +370,6 @@ class ForYouViewModelTest {
), ),
awaitItem() awaitItem()
) )
cancel()
} }
} }
@ -423,7 +410,6 @@ class ForYouViewModelTest {
), ),
awaitItem() awaitItem()
) )
cancel()
} }
} }
@ -692,7 +678,6 @@ class ForYouViewModelTest {
), ),
awaitItem() awaitItem()
) )
cancel()
} }
} }
@ -961,7 +946,6 @@ class ForYouViewModelTest {
), ),
awaitItem() awaitItem()
) )
cancel()
} }
} }
@ -1059,7 +1043,6 @@ class ForYouViewModelTest {
), ),
expectMostRecentItem() expectMostRecentItem()
) )
cancel()
} }
} }
@ -1157,7 +1140,6 @@ class ForYouViewModelTest {
), ),
expectMostRecentItem() expectMostRecentItem()
) )
cancel()
} }
} }
@ -1199,7 +1181,6 @@ class ForYouViewModelTest {
) )
assertEquals(setOf("1"), topicsRepository.getCurrentFollowedTopics()) assertEquals(setOf("1"), topicsRepository.getCurrentFollowedTopics())
assertEquals(emptySet<Int>(), authorsRepository.getCurrentFollowedAuthors()) assertEquals(emptySet<Int>(), authorsRepository.getCurrentFollowedAuthors())
cancel()
} }
} }
@ -1237,7 +1218,6 @@ class ForYouViewModelTest {
) )
assertEquals(emptySet<Int>(), topicsRepository.getCurrentFollowedTopics()) assertEquals(emptySet<Int>(), topicsRepository.getCurrentFollowedTopics())
assertEquals(setOf("0"), authorsRepository.getCurrentFollowedAuthors()) assertEquals(setOf("0"), authorsRepository.getCurrentFollowedAuthors())
cancel()
} }
} }
@ -1280,7 +1260,6 @@ class ForYouViewModelTest {
) )
assertEquals(setOf("1"), topicsRepository.getCurrentFollowedTopics()) assertEquals(setOf("1"), topicsRepository.getCurrentFollowedTopics())
assertEquals(setOf("1"), authorsRepository.getCurrentFollowedAuthors()) assertEquals(setOf("1"), authorsRepository.getCurrentFollowedAuthors())
cancel()
} }
} }
@ -1384,7 +1363,6 @@ class ForYouViewModelTest {
expectMostRecentItem() expectMostRecentItem()
) )
cancel()
} }
} }
@ -1487,7 +1465,6 @@ class ForYouViewModelTest {
), ),
expectMostRecentItem() expectMostRecentItem()
) )
cancel()
} }
} }
@ -1522,7 +1499,6 @@ class ForYouViewModelTest {
), ),
expectMostRecentItem() expectMostRecentItem()
) )
cancel()
} }
} }
} }

@ -34,13 +34,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
import com.google.samples.apps.nowinandroid.core.ui.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTab import com.google.samples.apps.nowinandroid.core.ui.component.NiaTab
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTabRow import com.google.samples.apps.nowinandroid.core.ui.component.NiaTabRow
import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.ui.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
@Composable @Composable
fun InterestsRoute( fun InterestsRoute(
@ -51,6 +58,7 @@ fun InterestsRoute(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val tabState by viewModel.tabState.collectAsState() val tabState by viewModel.tabState.collectAsState()
InterestsScreen( InterestsScreen(
uiState = uiState, uiState = uiState,
tabState = tabState, tabState = tabState,
@ -172,3 +180,78 @@ private fun InterestsContent(
private fun InterestsEmptyScreen() { private fun InterestsEmptyScreen() {
Text(text = stringResource(id = R.string.interests_empty_header)) Text(text = stringResource(id = R.string.interests_empty_header))
} }
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable
fun InterestsScreenPopulated() {
NiaTheme {
NiaBackground {
InterestsScreen(
uiState = InterestsUiState.Interests(
authors = previewAuthors.map { FollowableAuthor(it, false) },
topics = previewTopics.map { FollowableTopic(it, false) }
),
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {}
)
}
}
}
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable
fun InterestsScreenLoading() {
NiaTheme {
NiaBackground {
InterestsScreen(
uiState = InterestsUiState.Loading,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {},
)
}
}
}
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable
fun InterestsScreenEmpty() {
NiaTheme {
NiaBackground {
InterestsScreen(
uiState = InterestsUiState.Empty,
tabState = InterestsTabState(
titles = listOf(R.string.interests_topics, R.string.interests_people),
currentIndex = 0
),
followAuthor = { _, _ -> },
followTopic = { _, _ -> },
navigateToAuthor = {},
navigateToTopic = {},
switchTab = {}
)
}
}
}

@ -50,7 +50,6 @@ class InterestsViewModelTest {
fun uiState_whenInitialized_thenShowLoading() = runTest { fun uiState_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { viewModel.uiState.test {
assertEquals(InterestsUiState.Loading, awaitItem()) assertEquals(InterestsUiState.Loading, awaitItem())
cancel()
} }
} }
@ -60,7 +59,6 @@ class InterestsViewModelTest {
assertEquals(InterestsUiState.Loading, awaitItem()) assertEquals(InterestsUiState.Loading, awaitItem())
authorsRepository.setFollowedAuthorIds(setOf("1")) authorsRepository.setFollowedAuthorIds(setOf("1"))
topicsRepository.setFollowedTopicIds(emptySet()) topicsRepository.setFollowedTopicIds(emptySet())
cancel()
} }
} }
@ -70,7 +68,6 @@ class InterestsViewModelTest {
assertEquals(InterestsUiState.Loading, awaitItem()) assertEquals(InterestsUiState.Loading, awaitItem())
authorsRepository.setFollowedAuthorIds(emptySet()) authorsRepository.setFollowedAuthorIds(emptySet())
topicsRepository.setFollowedTopicIds(setOf("1")) topicsRepository.setFollowedTopicIds(setOf("1"))
cancel()
} }
} }
@ -100,7 +97,6 @@ class InterestsViewModelTest {
InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()), InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()),
awaitItem() awaitItem()
) )
cancel()
} }
} }
@ -124,7 +120,6 @@ class InterestsViewModelTest {
InterestsUiState.Interests(topics = emptyList(), authors = testOutputAuthors), InterestsUiState.Interests(topics = emptyList(), authors = testOutputAuthors),
awaitItem() awaitItem()
) )
cancel()
} }
} }
@ -156,7 +151,6 @@ class InterestsViewModelTest {
InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()), InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()),
awaitItem() awaitItem()
) )
cancel()
} }
} }
@ -182,7 +176,6 @@ class InterestsViewModelTest {
InterestsUiState.Interests(topics = emptyList(), authors = testInputAuthors), InterestsUiState.Interests(topics = emptyList(), authors = testInputAuthors),
awaitItem() awaitItem()
) )
cancel()
} }
} }
} }

@ -49,9 +49,13 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel import com.google.samples.apps.nowinandroid.core.ui.LoadingWheel
import com.google.samples.apps.nowinandroid.core.ui.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.ui.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
import com.google.samples.apps.nowinandroid.core.ui.theme.NiaTheme
import com.google.samples.apps.nowinandroid.feature.topic.R.string import com.google.samples.apps.nowinandroid.feature.topic.R.string
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@ -188,19 +192,6 @@ private fun LazyListScope.TopicCards(news: NewsUiState) {
} }
} }
@Preview
@Composable
private fun TopicBodyPreview() {
MaterialTheme {
LazyColumn {
TopicBody(
"Jetpack Compose", "Lorem ipsum maximum",
NewsUiState.Success(emptyList()), ""
)
}
}
}
@Composable @Composable
private fun TopicToolbar( private fun TopicToolbar(
uiState: FollowableTopic, uiState: FollowableTopic,
@ -235,3 +226,39 @@ private fun TopicToolbar(
} }
} }
} }
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable
fun TopicScreenPopulated() {
NiaTheme {
NiaBackground {
TopicScreen(
topicState = TopicUiState.Success(FollowableTopic(previewTopics[0], false)),
newsState = NewsUiState.Success(previewNewsResources),
onBackClick = {},
onFollowClick = {}
)
}
}
}
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480")
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480")
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480")
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480")
@Composable
fun TopicScreenLoading() {
NiaTheme {
NiaBackground {
TopicScreen(
topicState = TopicUiState.Loading,
newsState = NewsUiState.Loading,
onBackClick = {},
onFollowClick = {}
)
}
}
}

@ -69,7 +69,6 @@ class TopicViewModelTest {
).first() ).first()
assertEquals(topicFromRepository, successTopicState.followableTopic.topic) assertEquals(topicFromRepository, successTopicState.followableTopic.topic)
cancel()
} }
} }
@ -77,7 +76,6 @@ class TopicViewModelTest {
fun uiStateNews_whenInitialized_thenShowLoading() = runTest { fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { viewModel.uiState.test {
assertEquals(NewsUiState.Loading, awaitItem().newsState) assertEquals(NewsUiState.Loading, awaitItem().newsState)
cancel()
} }
} }
@ -85,7 +83,6 @@ class TopicViewModelTest {
fun uiStateTopic_whenInitialized_thenShowLoading() = runTest { fun uiStateTopic_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { viewModel.uiState.test {
assertEquals(TopicUiState.Loading, awaitItem().topicState) assertEquals(TopicUiState.Loading, awaitItem().topicState)
cancel()
} }
} }
@ -94,7 +91,6 @@ class TopicViewModelTest {
viewModel.uiState.test { viewModel.uiState.test {
topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) topicsRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
assertEquals(TopicUiState.Loading, awaitItem().topicState) assertEquals(TopicUiState.Loading, awaitItem().topicState)
cancel()
} }
} }
@ -108,7 +104,6 @@ class TopicViewModelTest {
val item = awaitItem() val item = awaitItem()
assertTrue(item.topicState is TopicUiState.Success) assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading) assertTrue(item.newsState is NewsUiState.Loading)
cancel()
} }
} }
@ -123,7 +118,6 @@ class TopicViewModelTest {
val item = awaitItem() val item = awaitItem()
assertTrue(item.topicState is TopicUiState.Success) assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Success) assertTrue(item.newsState is NewsUiState.Success)
cancel()
} }
} }
@ -142,7 +136,6 @@ class TopicViewModelTest {
TopicUiState.Success(followableTopic = testOutputTopics[0]), TopicUiState.Success(followableTopic = testOutputTopics[0]),
awaitItem().topicState awaitItem().topicState
) )
cancel()
} }
} }
} }

@ -3,19 +3,19 @@ accompanist = "0.24.8-beta"
androidDesugarJdkLibs = "1.1.5" androidDesugarJdkLibs = "1.1.5"
androidGradlePlugin = "7.2.1" androidGradlePlugin = "7.2.1"
androidxActivity = "1.4.0" androidxActivity = "1.4.0"
androidxAppCompat = "1.3.0" androidxAppCompat = "1.4.2"
androidxCompose = "1.2.0-beta03" androidxCompose = "1.2.0-beta03"
androidxComposeMaterial3 = "1.0.0-alpha12" androidxComposeMaterial3 = "1.0.0-alpha13"
androidxCore = "1.7.0" androidxCore = "1.8.0"
androidxCustomView = "1.0.0-beta02" androidxCustomView = "1.0.0-beta02"
androidxDataStore = "1.0.0" androidxDataStore = "1.0.0"
androidxEspresso = "3.3.0" androidxEspresso = "3.4.0"
androidxHiltNavigationCompose = "1.0.0" androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.5.0-rc01" androidxLifecycle = "2.5.0-rc01"
androidxMacroBenchmark = "1.1.0-rc03" androidxMacroBenchmark = "1.1.0-rc03"
androidxNavigation = "2.4.2" androidxNavigation = "2.4.2"
androidxMetrics = "1.0.0-alpha01" androidxMetrics = "1.0.0-alpha01"
androidxProfileinstaller = "1.2.0-beta01" androidxProfileinstaller = "1.2.0-beta03"
androidxSavedState = "1.1.0" androidxSavedState = "1.1.0"
androidxStartup = "1.1.1" androidxStartup = "1.1.1"
androidxWindowManager = "1.0.0" androidxWindowManager = "1.0.0"
@ -24,27 +24,27 @@ androidxTestExt = "1.1.3"
androidxTracing = "1.1.0" androidxTracing = "1.1.0"
androidxUiAutomator = "2.2.0" androidxUiAutomator = "2.2.0"
androidxWork = "2.7.1" androidxWork = "2.7.1"
coil = "2.0.0-rc01" coil = "2.1.0"
hilt = "2.41" hilt = "2.42"
hiltExt = "1.0.0" hiltExt = "1.0.0"
jacoco = "0.8.7" jacoco = "0.8.7"
junit4 = "4.13.2" junit4 = "4.13.2"
kotlin = "1.6.21" kotlin = "1.6.21"
kotlinxCoroutines = "1.6.0" kotlinxCoroutines = "1.6.2"
kotlinxDatetime = "0.3.3" kotlinxDatetime = "0.3.3"
kotlinxSerializationJson = "1.3.3" kotlinxSerializationJson = "1.3.3"
ksp = "1.6.21-1.0.5" ksp = "1.6.21-1.0.5"
ktlint = "0.43.0" ktlint = "0.43.0"
material3 = "1.5.0-alpha05" material3 = "1.6.1"
okhttp = "4.9.3" okhttp = "4.9.3"
protobuf = "3.20.0" protobuf = "3.21.1"
protobufPlugin = "0.8.18" protobufPlugin = "0.8.18"
retrofit = "2.9.0" retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "0.8.0" retrofitKotlinxSerializationJson = "0.8.0"
room = "2.4.2" room = "2.4.2"
secrets = "2.0.1" secrets = "2.0.1"
spotless = "6.3.0" spotless = "6.7.0"
turbine = "0.7.0" turbine = "0.8.0"
[libraries] [libraries]
accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" }

Binary file not shown.

@ -1,6 +1,5 @@
#Thu Feb 24 14:19:14 GMT 2022
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

@ -62,8 +62,8 @@ run_firebase_test_lab() {
while [ $result != 0 -a $counter -lt $MAX_RETRY ]; do while [ $result != 0 -a $counter -lt $MAX_RETRY ]; do
gcloud firebase test android run \ gcloud firebase test android run \
--type instrumentation \ --type instrumentation \
--app "app/build/outputs/apk/debug/app-debug.apk" \ --app "app/build/outputs/apk/demo/debug/app-demo-debug.apk" \
--test "$module/build/outputs/apk/androidTest/debug/$module-debug-androidTest.apk" \ --test "$module/build/outputs/apk/androidTest/demo/debug/$module-demo-debug-androidTest.apk" \
--device-ids $deviceIds \ --device-ids $deviceIds \
--os-version-ids $osVersionIds \ --os-version-ids $osVersionIds \
--locales en \ --locales en \

Loading…
Cancel
Save