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