parent
56b3c1d0b9
commit
4ba63c0de8
@ -1,29 +0,0 @@
|
||||
/*
|
||||
* 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.data.model
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
|
||||
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
|
||||
|
||||
fun NetworkAuthor.asEntity() = AuthorEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
imageUrl = imageUrl,
|
||||
twitter = twitter,
|
||||
mediumPage = mediumPage,
|
||||
bio = bio,
|
||||
)
|
@ -1,33 +0,0 @@
|
||||
/*
|
||||
* 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.data.repository
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.data.Syncable
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AuthorsRepository : Syncable {
|
||||
/**
|
||||
* Gets the available Authors as a stream
|
||||
*/
|
||||
fun getAuthors(): Flow<List<Author>>
|
||||
|
||||
/**
|
||||
* Gets data for a specific author
|
||||
*/
|
||||
fun getAuthor(id: String): Flow<Author>
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
/*
|
||||
* 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.data.repository
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
|
||||
import com.google.samples.apps.nowinandroid.core.data.changeListSync
|
||||
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
|
||||
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
|
||||
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
|
||||
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
|
||||
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
|
||||
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Disk storage backed implementation of the [AuthorsRepository].
|
||||
* Reads are exclusively from local storage to support offline access.
|
||||
*/
|
||||
class OfflineFirstAuthorsRepository @Inject constructor(
|
||||
private val authorDao: AuthorDao,
|
||||
private val network: NiaNetworkDataSource,
|
||||
) : AuthorsRepository {
|
||||
|
||||
override fun getAuthor(id: String): Flow<Author> =
|
||||
authorDao.getAuthorEntity(id).map {
|
||||
it.asExternalModel()
|
||||
}
|
||||
|
||||
override fun getAuthors(): Flow<List<Author>> =
|
||||
authorDao.getAuthorEntities()
|
||||
.map { it.map(AuthorEntity::asExternalModel) }
|
||||
|
||||
override suspend fun syncWith(synchronizer: Synchronizer): Boolean =
|
||||
synchronizer.changeListSync(
|
||||
versionReader = ChangeListVersions::authorVersion,
|
||||
changeListFetcher = { currentVersion ->
|
||||
network.getAuthorChangeList(after = currentVersion)
|
||||
},
|
||||
versionUpdater = { latestVersion ->
|
||||
copy(authorVersion = latestVersion)
|
||||
},
|
||||
modelDeleter = authorDao::deleteAuthors,
|
||||
modelUpdater = { changedIds ->
|
||||
val networkAuthors = network.getAuthors(ids = changedIds)
|
||||
authorDao.upsertAuthors(
|
||||
entities = networkAuthors.map(NetworkAuthor::asEntity)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
/*
|
||||
* 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.data.repository.fake
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
|
||||
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
|
||||
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Fake implementation of the [AuthorsRepository] that returns hardcoded authors.
|
||||
*
|
||||
* This allows us to run the app with fake data, without needing an internet connection or working
|
||||
* backend.
|
||||
*/
|
||||
class FakeAuthorsRepository @Inject constructor(
|
||||
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
|
||||
private val datasource: FakeNiaNetworkDataSource
|
||||
) : AuthorsRepository {
|
||||
|
||||
override fun getAuthors(): Flow<List<Author>> = flow {
|
||||
emit(
|
||||
datasource.getAuthors().map {
|
||||
Author(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
imageUrl = it.imageUrl,
|
||||
twitter = it.twitter,
|
||||
mediumPage = it.mediumPage,
|
||||
bio = it.bio,
|
||||
)
|
||||
}
|
||||
)
|
||||
}.flowOn(ioDispatcher)
|
||||
|
||||
override fun getAuthor(id: String): Flow<Author> {
|
||||
return getAuthors().map { it.first { author -> author.id == id } }
|
||||
}
|
||||
|
||||
override suspend fun syncWith(synchronizer: Synchronizer) = true
|
||||
}
|
@ -1,180 +0,0 @@
|
||||
/*
|
||||
* 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.data.repository
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
|
||||
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
|
||||
import com.google.samples.apps.nowinandroid.core.data.testdoubles.CollectionType
|
||||
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestAuthorDao
|
||||
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource
|
||||
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
|
||||
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
|
||||
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
|
||||
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
|
||||
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
|
||||
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
|
||||
import kotlin.test.assertEquals
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class OfflineFirstAuthorsRepositoryTest {
|
||||
|
||||
private lateinit var subject: OfflineFirstAuthorsRepository
|
||||
|
||||
private lateinit var authorDao: AuthorDao
|
||||
|
||||
private lateinit var network: TestNiaNetworkDataSource
|
||||
|
||||
private lateinit var synchronizer: Synchronizer
|
||||
|
||||
@get:Rule
|
||||
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
authorDao = TestAuthorDao()
|
||||
network = TestNiaNetworkDataSource()
|
||||
val niaPreferencesDataSource = NiaPreferencesDataSource(
|
||||
tmpFolder.testUserPreferencesDataStore()
|
||||
)
|
||||
synchronizer = TestSynchronizer(niaPreferencesDataSource)
|
||||
|
||||
subject = OfflineFirstAuthorsRepository(
|
||||
authorDao = authorDao,
|
||||
network = network,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineFirstAuthorsRepository_Authors_stream_is_backed_by_Authors_dao() =
|
||||
runTest {
|
||||
assertEquals(
|
||||
authorDao.getAuthorEntities()
|
||||
.first()
|
||||
.map(AuthorEntity::asExternalModel),
|
||||
subject.getAuthors()
|
||||
.first()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineFirstAuthorsRepository_sync_pulls_from_network() =
|
||||
runTest {
|
||||
subject.syncWith(synchronizer)
|
||||
|
||||
val networkAuthors = network.getAuthors()
|
||||
.map(NetworkAuthor::asEntity)
|
||||
|
||||
val dbAuthors = authorDao.getAuthorEntities()
|
||||
.first()
|
||||
|
||||
assertEquals(
|
||||
networkAuthors.map(AuthorEntity::id),
|
||||
dbAuthors.map(AuthorEntity::id)
|
||||
)
|
||||
|
||||
// After sync version should be updated
|
||||
assertEquals(
|
||||
network.latestChangeListVersion(CollectionType.Authors),
|
||||
synchronizer.getChangeListVersions().authorVersion
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineFirstAuthorsRepository_incremental_sync_pulls_from_network() =
|
||||
runTest {
|
||||
// Set author version to 5
|
||||
synchronizer.updateChangeListVersions {
|
||||
copy(authorVersion = 5)
|
||||
}
|
||||
|
||||
subject.syncWith(synchronizer)
|
||||
|
||||
val changeList = network.changeListsAfter(
|
||||
CollectionType.Authors,
|
||||
version = 5
|
||||
)
|
||||
val changeListIds = changeList
|
||||
.map(NetworkChangeList::id)
|
||||
.toSet()
|
||||
|
||||
val network = network.getAuthors()
|
||||
.map(NetworkAuthor::asEntity)
|
||||
.filter { it.id in changeListIds }
|
||||
|
||||
val db = authorDao.getAuthorEntities()
|
||||
.first()
|
||||
|
||||
assertEquals(
|
||||
network.map(AuthorEntity::id),
|
||||
db.map(AuthorEntity::id)
|
||||
)
|
||||
|
||||
// After sync version should be updated
|
||||
assertEquals(
|
||||
changeList.last().changeListVersion,
|
||||
synchronizer.getChangeListVersions().authorVersion
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineFirstAuthorsRepository_sync_deletes_items_marked_deleted_on_network() =
|
||||
runTest {
|
||||
val networkAuthors = network.getAuthors()
|
||||
.map(NetworkAuthor::asEntity)
|
||||
.map(AuthorEntity::asExternalModel)
|
||||
|
||||
// Delete half of the items on the network
|
||||
val deletedItems = networkAuthors
|
||||
.map(Author::id)
|
||||
.partition { it.chars().sum() % 2 == 0 }
|
||||
.first
|
||||
.toSet()
|
||||
|
||||
deletedItems.forEach {
|
||||
network.editCollection(
|
||||
collectionType = CollectionType.Authors,
|
||||
id = it,
|
||||
isDelete = true
|
||||
)
|
||||
}
|
||||
|
||||
subject.syncWith(synchronizer)
|
||||
|
||||
val dbAuthors = authorDao.getAuthorEntities()
|
||||
.first()
|
||||
.map(AuthorEntity::asExternalModel)
|
||||
|
||||
// Assert that items marked deleted on the network have been deleted locally
|
||||
assertEquals(
|
||||
networkAuthors.map(Author::id) - deletedItems,
|
||||
dbAuthors.map(Author::id)
|
||||
)
|
||||
|
||||
// After sync version should be updated
|
||||
assertEquals(
|
||||
network.latestChangeListVersion(CollectionType.Authors),
|
||||
synchronizer.getChangeListVersions().authorVersion
|
||||
)
|
||||
}
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
/*
|
||||
* 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.data.testdoubles
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
|
||||
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Test double for [AuthorDao]
|
||||
*/
|
||||
class TestAuthorDao : AuthorDao {
|
||||
|
||||
private var entitiesStateFlow = MutableStateFlow(
|
||||
listOf(
|
||||
AuthorEntity(
|
||||
id = "1",
|
||||
name = "Topic",
|
||||
imageUrl = "imageUrl",
|
||||
twitter = "twitter",
|
||||
mediumPage = "mediumPage",
|
||||
bio = "bio",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
override fun getAuthorEntities(): Flow<List<AuthorEntity>> =
|
||||
entitiesStateFlow
|
||||
|
||||
override fun getAuthorEntity(authorId: String): Flow<AuthorEntity> {
|
||||
throw NotImplementedError("Unused in tests")
|
||||
}
|
||||
|
||||
override suspend fun insertOrIgnoreAuthors(authorEntities: List<AuthorEntity>): List<Long> {
|
||||
entitiesStateFlow.value = authorEntities
|
||||
// Assume no conflicts on insert
|
||||
return authorEntities.map { it.id.toLong() }
|
||||
}
|
||||
|
||||
override suspend fun updateAuthors(entities: List<AuthorEntity>) {
|
||||
throw NotImplementedError("Unused in tests")
|
||||
}
|
||||
|
||||
override suspend fun upsertAuthors(entities: List<AuthorEntity>) {
|
||||
entitiesStateFlow.value = entities
|
||||
}
|
||||
|
||||
override suspend fun deleteAuthors(ids: List<String>) {
|
||||
val idSet = ids.toSet()
|
||||
entitiesStateFlow.update { entities ->
|
||||
entities.filterNot { idSet.contains(it.id) }
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,192 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 12,
|
||||
"identityHash": "f83b94b22ba0a0ce640922a3475e7c3e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "news_resources",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "headerImageUrl",
|
||||
"columnName": "header_image_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "publishDate",
|
||||
"columnName": "publish_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "news_resources_topics",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "newsResourceId",
|
||||
"columnName": "news_resource_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "topicId",
|
||||
"columnName": "topic_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"news_resource_id",
|
||||
"topic_id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_news_resources_topics_news_resource_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"news_resource_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_news_resources_topics_topic_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"topic_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "news_resources",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"news_resource_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "topics",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"topic_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "topics",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shortDescription",
|
||||
"columnName": "shortDescription",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "longDescription",
|
||||
"columnName": "longDescription",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "imageUrl",
|
||||
"columnName": "imageUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f83b94b22ba0a0ce640922a3475e7c3e')"
|
||||
]
|
||||
}
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
/*
|
||||
* 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.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import androidx.room.Upsert
|
||||
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* DAO for [AuthorEntity] access
|
||||
*/
|
||||
@Dao
|
||||
interface AuthorDao {
|
||||
@Query(
|
||||
value = """
|
||||
SELECT * FROM authors
|
||||
WHERE id = :authorId
|
||||
"""
|
||||
)
|
||||
fun getAuthorEntity(authorId: String): Flow<AuthorEntity>
|
||||
|
||||
@Query(value = "SELECT * FROM authors")
|
||||
fun getAuthorEntities(): Flow<List<AuthorEntity>>
|
||||
|
||||
/**
|
||||
* Inserts [authorEntities] into the db if they don't exist, and ignores those that do
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertOrIgnoreAuthors(authorEntities: List<AuthorEntity>): List<Long>
|
||||
|
||||
/**
|
||||
* Updates [entities] in the db that match the primary key, and no-ops if they don't
|
||||
*/
|
||||
@Update
|
||||
suspend fun updateAuthors(entities: List<AuthorEntity>)
|
||||
|
||||
/**
|
||||
* Inserts or updates [entities] in the db under the specified primary keys
|
||||
*/
|
||||
@Upsert
|
||||
suspend fun upsertAuthors(entities: List<AuthorEntity>)
|
||||
|
||||
/**
|
||||
* Deletes rows in the db matching the specified [ids]
|
||||
*/
|
||||
@Query(
|
||||
value = """
|
||||
DELETE FROM authors
|
||||
WHERE id in (:ids)
|
||||
"""
|
||||
)
|
||||
suspend fun deleteAuthors(ids: List<String>)
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
/*
|
||||
* 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.database.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
|
||||
/**
|
||||
* Defines an author for [NewsResourceEntity].
|
||||
* It has a many to many relationship with both entities
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "authors",
|
||||
)
|
||||
data class AuthorEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
@ColumnInfo(name = "image_url")
|
||||
val imageUrl: String,
|
||||
@ColumnInfo(defaultValue = "")
|
||||
val twitter: String,
|
||||
@ColumnInfo(name = "medium_page", defaultValue = "")
|
||||
val mediumPage: String,
|
||||
@ColumnInfo(defaultValue = "")
|
||||
val bio: String,
|
||||
)
|
||||
|
||||
fun AuthorEntity.asExternalModel() = Author(
|
||||
id = id,
|
||||
name = name,
|
||||
imageUrl = imageUrl,
|
||||
twitter = twitter,
|
||||
mediumPage = mediumPage,
|
||||
bio = bio,
|
||||
)
|
@ -1,54 +0,0 @@
|
||||
/*
|
||||
* 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.database.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
|
||||
/**
|
||||
* Cross reference for many to many relationship between [NewsResourceEntity] and [AuthorEntity]
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "news_resources_authors",
|
||||
primaryKeys = ["news_resource_id", "author_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = NewsResourceEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["news_resource_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
ForeignKey(
|
||||
entity = AuthorEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["author_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["news_resource_id"]),
|
||||
Index(value = ["author_id"]),
|
||||
],
|
||||
)
|
||||
data class NewsResourceAuthorCrossRef(
|
||||
@ColumnInfo(name = "news_resource_id")
|
||||
val newsResourceId: String,
|
||||
@ColumnInfo(name = "author_id")
|
||||
val authorId: String,
|
||||
)
|
@ -1,50 +0,0 @@
|
||||
/*
|
||||
* 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.domain
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
|
||||
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
/**
|
||||
* A use case which obtains a list of authors sorted alphabetically by name with their followed
|
||||
* state.
|
||||
*/
|
||||
class GetSortedFollowableAuthorsUseCase @Inject constructor(
|
||||
private val authorsRepository: AuthorsRepository,
|
||||
private val userDataRepository: UserDataRepository
|
||||
) {
|
||||
/**
|
||||
* Returns a list of authors with their associated followed state sorted alphabetically by name.
|
||||
*/
|
||||
operator fun invoke(): Flow<List<FollowableAuthor>> =
|
||||
combine(
|
||||
authorsRepository.getAuthors(),
|
||||
userDataRepository.userData
|
||||
) { authors, userData ->
|
||||
authors.map { author ->
|
||||
FollowableAuthor(
|
||||
author = author,
|
||||
isFollowed = author.id in userData.followedAuthors
|
||||
)
|
||||
}
|
||||
.sortedBy { it.author.name }
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
/*
|
||||
* 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.domain.model
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
|
||||
/**
|
||||
* An [author] with the additional information for whether or not it is followed.
|
||||
*/
|
||||
data class FollowableAuthor(
|
||||
val author: Author,
|
||||
val isFollowed: Boolean
|
||||
)
|
@ -1,97 +0,0 @@
|
||||
/*
|
||||
* 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.domain
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
|
||||
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
|
||||
import kotlin.test.assertEquals
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class GetSortedFollowableAuthorsUseCaseTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val authorsRepository = TestAuthorsRepository()
|
||||
private val userDataRepository = TestUserDataRepository()
|
||||
|
||||
val useCase = GetSortedFollowableAuthorsUseCase(
|
||||
authorsRepository = authorsRepository,
|
||||
userDataRepository = userDataRepository
|
||||
)
|
||||
|
||||
@Test
|
||||
fun whenFollowedAuthorsSupplied_sortedFollowableAuthorsAreReturned() = runTest {
|
||||
|
||||
// Specify some authors which the user is following.
|
||||
userDataRepository.setFollowedAuthorIds(setOf(sampleAuthor1.id))
|
||||
|
||||
// Obtain the stream of authors, specifying their followed state.
|
||||
val followableAuthors = useCase()
|
||||
|
||||
// Supply some authors.
|
||||
authorsRepository.sendAuthors(sampleAuthors)
|
||||
|
||||
// Check that the authors have been sorted, and that the followed state is correct.
|
||||
assertEquals(
|
||||
followableAuthors.first(),
|
||||
listOf(
|
||||
FollowableAuthor(sampleAuthor2, false),
|
||||
FollowableAuthor(sampleAuthor1, true),
|
||||
FollowableAuthor(sampleAuthor3, false)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val sampleAuthor1 =
|
||||
Author(
|
||||
id = "Author1",
|
||||
name = "Mandy",
|
||||
imageUrl = "",
|
||||
twitter = "",
|
||||
mediumPage = "",
|
||||
bio = "",
|
||||
)
|
||||
|
||||
private val sampleAuthor2 =
|
||||
Author(
|
||||
id = "Author2",
|
||||
name = "Andy",
|
||||
imageUrl = "",
|
||||
twitter = "",
|
||||
mediumPage = "",
|
||||
bio = "",
|
||||
)
|
||||
|
||||
private val sampleAuthor3 =
|
||||
Author(
|
||||
id = "Author2",
|
||||
name = "Sandy",
|
||||
imageUrl = "",
|
||||
twitter = "",
|
||||
mediumPage = "",
|
||||
bio = "",
|
||||
)
|
||||
|
||||
private val sampleAuthors = listOf(sampleAuthor1, sampleAuthor2, sampleAuthor3)
|
@ -1,50 +0,0 @@
|
||||
/*
|
||||
* 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.model.data
|
||||
|
||||
/* ktlint-disable max-line-length */
|
||||
|
||||
/**
|
||||
* External data layer representation of an NiA Author
|
||||
*/
|
||||
data class Author(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val imageUrl: String,
|
||||
val twitter: String,
|
||||
val mediumPage: 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."
|
||||
)
|
||||
)
|
@ -1,33 +0,0 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Network representation of [Author]
|
||||
*/
|
||||
@Serializable
|
||||
data class NetworkAuthor(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val imageUrl: String,
|
||||
val twitter: String,
|
||||
val mediumPage: String,
|
||||
val bio: String,
|
||||
)
|
@ -1,48 +0,0 @@
|
||||
/*
|
||||
* 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.testing.repository
|
||||
|
||||
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class TestAuthorsRepository : AuthorsRepository {
|
||||
/**
|
||||
* The backing hot flow for the list of author ids for testing.
|
||||
*/
|
||||
private val authorsFlow: MutableSharedFlow<List<Author>> =
|
||||
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
override fun getAuthors(): Flow<List<Author>> = authorsFlow
|
||||
|
||||
override fun getAuthor(id: String): Flow<Author> {
|
||||
return authorsFlow.map { authors -> authors.find { it.id == id }!! }
|
||||
}
|
||||
|
||||
override suspend fun syncWith(synchronizer: Synchronizer) = true
|
||||
|
||||
/**
|
||||
* A test-only API to allow controlling the list of authors from tests.
|
||||
*/
|
||||
fun sendAuthors(authors: List<Author>) {
|
||||
authorsFlow.tryEmit(authors)
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
/build
|
@ -1,3 +0,0 @@
|
||||
# :feature:author module
|
||||
|
||||
![Dependency graph](../../docs/images/graphs/dep_graph_feature_author.png)
|
@ -1,28 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
plugins {
|
||||
id("nowinandroid.android.feature")
|
||||
id("nowinandroid.android.library.compose")
|
||||
id("nowinandroid.android.library.jacoco")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.google.samples.apps.nowinandroid.feature.author"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
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
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
@ -1,273 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 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.feature.author
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
|
||||
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
|
||||
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
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.DevicePreviews
|
||||
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
|
||||
import com.google.samples.apps.nowinandroid.core.ui.newsResourceCardItems
|
||||
|
||||
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||
@Composable
|
||||
internal fun AuthorRoute(
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AuthorViewModel = hiltViewModel(),
|
||||
) {
|
||||
val authorUiState: AuthorUiState by viewModel.authorUiState.collectAsStateWithLifecycle()
|
||||
val newsUiState: NewsUiState by viewModel.newsUiState.collectAsStateWithLifecycle()
|
||||
|
||||
AuthorScreen(
|
||||
authorUiState = authorUiState,
|
||||
newsUiState = newsUiState,
|
||||
modifier = modifier,
|
||||
onBackClick = onBackClick,
|
||||
onFollowClick = viewModel::followAuthorToggle,
|
||||
onBookmarkChanged = viewModel::bookmarkNews,
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@Composable
|
||||
internal fun AuthorScreen(
|
||||
authorUiState: AuthorUiState,
|
||||
newsUiState: NewsUiState,
|
||||
onBackClick: () -> Unit,
|
||||
onFollowClick: (Boolean) -> Unit,
|
||||
onBookmarkChanged: (String, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollableState = rememberLazyListState()
|
||||
TrackScrollJank(scrollableState = scrollableState, stateName = "author:column")
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
state = scrollableState
|
||||
) {
|
||||
item {
|
||||
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
|
||||
}
|
||||
when (authorUiState) {
|
||||
AuthorUiState.Loading -> {
|
||||
item {
|
||||
NiaLoadingWheel(
|
||||
modifier = modifier,
|
||||
contentDesc = stringResource(id = R.string.author_loading),
|
||||
)
|
||||
}
|
||||
}
|
||||
AuthorUiState.Error -> {
|
||||
TODO()
|
||||
}
|
||||
is AuthorUiState.Success -> {
|
||||
item {
|
||||
AuthorToolbar(
|
||||
onBackClick = onBackClick,
|
||||
onFollowClick = onFollowClick,
|
||||
uiState = authorUiState.followableAuthor,
|
||||
)
|
||||
}
|
||||
authorBody(
|
||||
author = authorUiState.followableAuthor.author,
|
||||
news = newsUiState,
|
||||
onBookmarkChanged = onBookmarkChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.authorBody(
|
||||
author: Author,
|
||||
news: NewsUiState,
|
||||
onBookmarkChanged: (String, Boolean) -> Unit
|
||||
) {
|
||||
item {
|
||||
AuthorHeader(author)
|
||||
}
|
||||
|
||||
authorCards(news, onBookmarkChanged)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorHeader(author: Author) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 12.dp)
|
||||
.size(216.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
model = author.imageUrl,
|
||||
contentDescription = "Author profile picture",
|
||||
)
|
||||
Text(author.name, style = MaterialTheme.typography.displayMedium)
|
||||
if (author.bio.isNotEmpty()) {
|
||||
Text(
|
||||
text = author.bio,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.authorCards(
|
||||
news: NewsUiState,
|
||||
onBookmarkChanged: (String, Boolean) -> Unit
|
||||
) {
|
||||
when (news) {
|
||||
is NewsUiState.Success -> {
|
||||
newsResourceCardItems(
|
||||
items = news.news,
|
||||
newsResourceMapper = { it.newsResource },
|
||||
isBookmarkedMapper = { it.isSaved },
|
||||
onToggleBookmark = { onBookmarkChanged(it.newsResource.id, !it.isSaved) },
|
||||
itemModifier = Modifier.padding(24.dp)
|
||||
)
|
||||
}
|
||||
is NewsUiState.Loading -> item {
|
||||
NiaLoadingWheel(contentDesc = "Loading news") // TODO
|
||||
}
|
||||
else -> item {
|
||||
Text("Error") // TODO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorToolbar(
|
||||
uiState: FollowableAuthor,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClick: () -> Unit = {},
|
||||
onFollowClick: (Boolean) -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
IconButton(onClick = { onBackClick() }) {
|
||||
Icon(
|
||||
imageVector = NiaIcons.ArrowBack,
|
||||
contentDescription = stringResource(
|
||||
id = com.google.samples.apps.nowinandroid.core.ui.R.string.back
|
||||
)
|
||||
)
|
||||
}
|
||||
val selected = uiState.isFollowed
|
||||
NiaFilterChip(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
selected = selected,
|
||||
onSelectedChange = onFollowClick,
|
||||
) {
|
||||
if (selected) {
|
||||
Text(stringResource(id = R.string.author_following))
|
||||
} else {
|
||||
Text(stringResource(id = R.string.author_not_following))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DevicePreviews
|
||||
@Composable
|
||||
fun AuthorScreenPopulated() {
|
||||
NiaTheme {
|
||||
NiaBackground {
|
||||
AuthorScreen(
|
||||
authorUiState = AuthorUiState.Success(FollowableAuthor(previewAuthors[0], false)),
|
||||
newsUiState = NewsUiState.Success(
|
||||
previewNewsResources.mapIndexed { index, newsResource ->
|
||||
SaveableNewsResource(
|
||||
newsResource = newsResource,
|
||||
isSaved = index % 2 == 0,
|
||||
)
|
||||
}
|
||||
),
|
||||
onBackClick = {},
|
||||
onFollowClick = {},
|
||||
onBookmarkChanged = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DevicePreviews
|
||||
@Composable
|
||||
fun AuthorScreenLoading() {
|
||||
NiaTheme {
|
||||
NiaBackground {
|
||||
AuthorScreen(
|
||||
authorUiState = AuthorUiState.Loading,
|
||||
newsUiState = NewsUiState.Loading,
|
||||
onBackClick = {},
|
||||
onFollowClick = {},
|
||||
onBookmarkChanged = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,154 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 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.feature.author
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
|
||||
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
|
||||
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
|
||||
import com.google.samples.apps.nowinandroid.core.domain.GetSaveableNewsResourcesUseCase
|
||||
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
|
||||
import com.google.samples.apps.nowinandroid.core.domain.model.SaveableNewsResource
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import com.google.samples.apps.nowinandroid.core.result.Result
|
||||
import com.google.samples.apps.nowinandroid.core.result.asResult
|
||||
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorArgs
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class AuthorViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
stringDecoder: StringDecoder,
|
||||
private val userDataRepository: UserDataRepository,
|
||||
authorsRepository: AuthorsRepository,
|
||||
getSaveableNewsResources: GetSaveableNewsResourcesUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val authorArgs: AuthorArgs = AuthorArgs(savedStateHandle, stringDecoder)
|
||||
|
||||
val authorUiState: StateFlow<AuthorUiState> = authorUiState(
|
||||
authorId = authorArgs.authorId,
|
||||
userDataRepository = userDataRepository,
|
||||
authorsRepository = authorsRepository
|
||||
)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = AuthorUiState.Loading
|
||||
)
|
||||
|
||||
val newsUiState: StateFlow<NewsUiState> =
|
||||
getSaveableNewsResources.newsUiState(authorId = authorArgs.authorId)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = NewsUiState.Loading
|
||||
)
|
||||
|
||||
fun followAuthorToggle(followed: Boolean) {
|
||||
viewModelScope.launch {
|
||||
userDataRepository.toggleFollowedAuthorId(authorArgs.authorId, followed)
|
||||
}
|
||||
}
|
||||
|
||||
fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {
|
||||
viewModelScope.launch {
|
||||
userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authorUiState(
|
||||
authorId: String,
|
||||
userDataRepository: UserDataRepository,
|
||||
authorsRepository: AuthorsRepository,
|
||||
): Flow<AuthorUiState> {
|
||||
// Observe the followed authors, as they could change over time.
|
||||
val followedAuthorIds: Flow<Set<String>> =
|
||||
userDataRepository.userData
|
||||
.map { it.followedAuthors }
|
||||
|
||||
// Observe author information
|
||||
val author: Flow<Author> = authorsRepository.getAuthor(
|
||||
id = authorId
|
||||
)
|
||||
|
||||
return combine(
|
||||
followedAuthorIds,
|
||||
author,
|
||||
::Pair
|
||||
)
|
||||
.asResult()
|
||||
.map { followedAuthorToAuthorResult ->
|
||||
when (followedAuthorToAuthorResult) {
|
||||
is Result.Success -> {
|
||||
val (followedAuthors, author) = followedAuthorToAuthorResult.data
|
||||
val followed = followedAuthors.contains(authorId)
|
||||
AuthorUiState.Success(
|
||||
followableAuthor = FollowableAuthor(
|
||||
author = author,
|
||||
isFollowed = followed
|
||||
)
|
||||
)
|
||||
}
|
||||
is Result.Loading -> {
|
||||
AuthorUiState.Loading
|
||||
}
|
||||
is Result.Error -> {
|
||||
AuthorUiState.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun GetSaveableNewsResourcesUseCase.newsUiState(
|
||||
authorId: String
|
||||
): Flow<NewsUiState> {
|
||||
// Observe news
|
||||
return this(
|
||||
filterAuthorIds = setOf(element = authorId)
|
||||
).asResult()
|
||||
.map { newsResult ->
|
||||
when (newsResult) {
|
||||
is Result.Success -> NewsUiState.Success(newsResult.data)
|
||||
is Result.Loading -> NewsUiState.Loading
|
||||
is Result.Error -> NewsUiState.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AuthorUiState {
|
||||
data class Success(val followableAuthor: FollowableAuthor) : AuthorUiState
|
||||
object Error : AuthorUiState
|
||||
object Loading : AuthorUiState
|
||||
}
|
||||
|
||||
sealed interface NewsUiState {
|
||||
data class Success(val news: List<SaveableNewsResource>) : NewsUiState
|
||||
object Error : NewsUiState
|
||||
object Loading : NewsUiState
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
/*
|
||||
* 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.feature.author.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder
|
||||
import com.google.samples.apps.nowinandroid.feature.author.AuthorRoute
|
||||
|
||||
@VisibleForTesting
|
||||
internal const val authorIdArg = "authorId"
|
||||
|
||||
internal class AuthorArgs(val authorId: String) {
|
||||
constructor(savedStateHandle: SavedStateHandle, stringDecoder: StringDecoder) :
|
||||
this(stringDecoder.decodeString(checkNotNull(savedStateHandle[authorIdArg])))
|
||||
}
|
||||
|
||||
fun NavController.navigateToAuthor(authorId: String) {
|
||||
val encodedString = Uri.encode(authorId)
|
||||
this.navigate("author_route/$encodedString")
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.authorScreen(
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
composable(
|
||||
route = "author_route/{$authorIdArg}",
|
||||
arguments = listOf(
|
||||
navArgument(authorIdArg) { type = NavType.StringType }
|
||||
)
|
||||
) {
|
||||
AuthorRoute(onBackClick = onBackClick)
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
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
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<resources>
|
||||
<string name="author">Author</string>
|
||||
<string name="author_loading">Loading author</string>
|
||||
<string name="author_following">FOLLOWING</string>
|
||||
<string name="author_not_following">NOT FOLLOWING</string>
|
||||
</resources>
|
@ -1,249 +0,0 @@
|
||||
/*
|
||||
* 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.feature.foryou
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
|
||||
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
|
||||
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableAuthor
|
||||
import com.google.samples.apps.nowinandroid.core.model.data.Author
|
||||
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
|
||||
|
||||
@Composable
|
||||
fun AuthorsCarousel(
|
||||
authors: List<FollowableAuthor>,
|
||||
onAuthorClick: (String, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val tag = "forYou:authors"
|
||||
TrackScrollJank(scrollableState = lazyListState, stateName = tag)
|
||||
|
||||
LazyRow(
|
||||
modifier = modifier.testTag(tag),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
state = lazyListState
|
||||
) {
|
||||
items(items = authors, key = { item -> item.author.id }) { followableAuthor ->
|
||||
AuthorItem(
|
||||
author = followableAuthor.author,
|
||||
following = followableAuthor.isFollowed,
|
||||
onAuthorClick = { following ->
|
||||
onAuthorClick(followableAuthor.author.id, following)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorItem(
|
||||
author: Author,
|
||||
following: Boolean,
|
||||
onAuthorClick: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val followDescription = if (following) {
|
||||
stringResource(R.string.following)
|
||||
} else {
|
||||
stringResource(R.string.not_following)
|
||||
}
|
||||
val followActionLabel = if (following) {
|
||||
stringResource(R.string.unfollow)
|
||||
} else {
|
||||
stringResource(R.string.follow)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.toggleable(
|
||||
value = following,
|
||||
role = Role.Button,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false),
|
||||
onValueChange = { newFollowing -> onAuthorClick(newFollowing) },
|
||||
)
|
||||
.padding(8.dp)
|
||||
.sizeIn(maxWidth = 48.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
// Add information for A11y services, explaining what each state means and
|
||||
// what will happen when the user interacts with the author item.
|
||||
stateDescription = "$followDescription ${author.name}"
|
||||
onClick(label = followActionLabel, action = null)
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val authorImageModifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
if (author.imageUrl.isEmpty()) {
|
||||
Icon(
|
||||
modifier = authorImageModifier
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(4.dp),
|
||||
imageVector = NiaIcons.Person,
|
||||
contentDescription = null // decorative image
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
modifier = authorImageModifier,
|
||||
model = author.imageUrl,
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
val backgroundColor =
|
||||
if (following)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surface
|
||||
Icon(
|
||||
imageVector = if (following) NiaIcons.Check else NiaIcons.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(18.dp)
|
||||
.drawBehind {
|
||||
drawCircle(
|
||||
color = backgroundColor,
|
||||
radius = 12.dp.toPx()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = author.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AuthorCarouselPreview() {
|
||||
NiaTheme {
|
||||
Surface {
|
||||
AuthorsCarousel(
|
||||
authors = listOf(
|
||||
FollowableAuthor(
|
||||
Author(
|
||||
id = "1",
|
||||
name = "Android Dev",
|
||||
imageUrl = "",
|
||||
twitter = "",
|
||||
mediumPage = "",
|
||||
bio = "",
|
||||
),
|
||||
false
|
||||
),
|
||||
FollowableAuthor(
|
||||
author = Author(
|
||||
id = "2",
|
||||
name = "Android Dev2",
|
||||
imageUrl = "",
|
||||
twitter = "",
|
||||
mediumPage = "",
|
||||
bio = "",
|
||||
),
|
||||
isFollowed = true
|
||||
),
|
||||
FollowableAuthor(
|
||||
Author(
|
||||
id = "3",
|
||||
name = "Android Dev3",
|
||||
imageUrl = "",
|
||||
twitter = "",
|
||||
mediumPage = "",
|
||||
bio = "",
|
||||
),
|
||||
false
|
||||
)
|
||||
),
|
||||
onAuthorClick = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AuthorItemPreview() {
|
||||
NiaTheme {
|
||||
Surface {
|
||||
AuthorItem(
|
||||
author = Author(
|
||||
id = "0",
|
||||
name = "Android Dev",
|
||||
imageUrl = "",
|
||||
twitter = "",
|
||||
mediumPage = "",
|
||||
bio = "",
|
||||
),
|
||||
following = true,
|
||||
onAuthorClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue