diff --git a/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt b/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt index d4d7b499b..851c20b28 100644 --- a/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt +++ b/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt @@ -518,24 +518,34 @@ fun NiaCatalog() { item { Text("Tags", Modifier.padding(top = 16.dp)) } item { FlowRow(mainAxisSpacing = 16.dp) { + var expandedTopicId by remember { mutableStateOf(null) } + var firstFollowed by remember { mutableStateOf(false) } NiaTopicTag( + expanded = expandedTopicId == "Topic 1", followed = firstFollowed, + onDropMenuToggle = { show -> + expandedTopicId = if (show) "Topic 1" else null + }, onFollowClick = { firstFollowed = true }, onUnfollowClick = { firstFollowed = false }, onBrowseClick = {}, - text = { Text(text = "Topic".uppercase()) }, + text = { Text(text = "Topic 1".uppercase()) }, followText = { Text(text = "Follow") }, unFollowText = { Text(text = "Unfollow") }, browseText = { Text(text = "Browse topic") } ) var secondFollowed by remember { mutableStateOf(true) } NiaTopicTag( + expanded = expandedTopicId == "Topic 2", followed = secondFollowed, + onDropMenuToggle = { show -> + expandedTopicId = if (show) "Topic 2" else null + }, onFollowClick = { secondFollowed = true }, onUnfollowClick = { secondFollowed = false }, onBrowseClick = {}, - text = { Text(text = "Topic".uppercase()) }, + text = { Text(text = "Topic 2".uppercase()) }, followText = { Text(text = "Follow") }, unFollowText = { Text(text = "Unfollow") }, browseText = { Text(text = "Browse topic") } diff --git a/core-database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/10.json b/core-database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/10.json new file mode 100644 index 000000000..d8aef53ed --- /dev/null +++ b/core-database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/10.json @@ -0,0 +1,455 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "d90e438da4b3b9cf552df536431dacb3", + "entities": [ + { + "tableName": "authors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', `bio` 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": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "mediumPage", + "columnName": "medium_page", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "episodes_authors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episode_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "author_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "episode_id", + "author_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_episodes_authors_episode_id", + "unique": false, + "columnNames": [ + "episode_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_authors_episode_id` ON `${TABLE_NAME}` (`episode_id`)" + }, + { + "name": "index_episodes_authors_author_id", + "unique": false, + "columnNames": [ + "author_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_authors_author_id` ON `${TABLE_NAME}` (`author_id`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episode_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "authors", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "author_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishDate", + "columnName": "publish_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alternateVideo", + "columnName": "alternate_video", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "alternateAudio", + "columnName": "alternate_audio", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "news_resources_authors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "news_resource_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "author_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "news_resource_id", + "author_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_news_resources_authors_news_resource_id", + "unique": false, + "columnNames": [ + "news_resource_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_authors_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)" + }, + { + "name": "index_news_resources_authors_author_id", + "unique": false, + "columnNames": [ + "author_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_authors_author_id` ON `${TABLE_NAME}` (`author_id`)" + } + ], + "foreignKeys": [ + { + "table": "news_resources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "news_resource_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "authors", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "author_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "news_resources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `episode_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`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "episodeId", + "columnName": "episode_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": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_news_resources_episode_id", + "unique": false, + "columnNames": [ + "episode_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_episode_id` ON `${TABLE_NAME}` (`episode_id`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episode_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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": { + "columnNames": [ + "news_resource_id", + "topic_id" + ], + "autoGenerate": false + }, + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "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, 'd90e438da4b3b9cf552df536431dacb3')" + ] + } +} \ No newline at end of file diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt index ebc7a075a..e367c4bca 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt @@ -44,7 +44,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC NewsResourceTopicCrossRef::class, TopicEntity::class, ], - version = 9, + version = 10, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class), @@ -54,6 +54,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC AutoMigration(from = 6, to = 7), AutoMigration(from = 7, to = 8), AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10), ], exportSchema = true, ) diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt index e74135f12..8d15a1f0b 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt @@ -19,6 +19,7 @@ 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 import androidx.room.PrimaryKey import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType @@ -37,6 +38,9 @@ import kotlinx.datetime.Instant childColumns = ["episode_id"], onDelete = ForeignKey.CASCADE ), + ], + indices = [ + Index(value = ["episode_id"]) ] ) data class NewsResourceEntity( diff --git a/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt index d45bdb226..6a2475931 100644 --- a/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt +++ b/core-designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt @@ -21,10 +21,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource @@ -32,7 +28,9 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R @Composable fun NiaTopicTag( + expanded: Boolean = false, followed: Boolean, + onDropMenuToggle: (show: Boolean) -> Unit = {}, onFollowClick: () -> Unit, onUnfollowClick: () -> Unit, onBrowseClick: () -> Unit, @@ -43,7 +41,7 @@ fun NiaTopicTag( unFollowText: @Composable () -> Unit = { Text(stringResource(R.string.unfollow)) }, browseText: @Composable () -> Unit = { Text(stringResource(R.string.browse_topic)) } ) { - var expanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { val containerColor = if (followed) { MaterialTheme.colorScheme.primaryContainer @@ -51,7 +49,7 @@ fun NiaTopicTag( MaterialTheme.colorScheme.surfaceVariant } NiaTextButton( - onClick = { expanded = true }, + onClick = { onDropMenuToggle(true) }, enabled = enabled, small = true, colors = NiaButtonDefaults.textButtonColors( @@ -69,7 +67,7 @@ fun NiaTopicTag( ) NiaDropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false }, + onDismissRequest = { onDropMenuToggle(false) }, items = if (followed) listOf(UNFOLLOW, BROWSE) else listOf(FOLLOW, BROWSE), onItemClick = { item -> when (item) { diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index 117b2ae47..0b765bbc1 100644 --- a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -268,13 +268,21 @@ fun NewsResourceTopics( topics: List, modifier: Modifier = Modifier ) { + // Store the ID of the Topic which has its "following" menu expanded, if any. + // To avoid UI confusion, only one topic can have an expanded menu at a time. + var expandedTopicId by remember { mutableStateOf(null) } + Row( modifier = modifier.horizontalScroll(rememberScrollState()), // causes narrow chips horizontalArrangement = Arrangement.spacedBy(4.dp), ) { for (topic in topics) { NiaTopicTag( + expanded = expandedTopicId == topic.id, followed = true, // ToDo: Check if topic is followed + onDropMenuToggle = { show -> + expandedTopicId = if (show) topic.id else null + }, onFollowClick = { }, // ToDo onUnfollowClick = { }, // ToDo onBrowseClick = { }, // ToDo diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf6755daf..6880d31ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ androidDesugarJdkLibs = "1.1.5" androidGradlePlugin = "7.2.2" androidxActivity = "1.5.1" androidxAppCompat = "1.5.0" -androidxCompose = "1.2.0-rc03" +androidxCompose = "1.2.1" androidxComposeCompiler = "1.2.0" androidxComposeMaterial3 = "1.0.0-alpha13" androidxCore = "1.8.0" @@ -34,7 +34,7 @@ junit4 = "4.13.2" kotlin = "1.7.0" kotlinxCoroutines = "1.6.3" kotlinxDatetime = "0.4.0" -kotlinxSerializationJson = "1.3.3" +kotlinxSerializationJson = "1.4.0" ksp = "1.7.0-1.0.6" ktlint = "0.43.0" lint = "30.2.2"