diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5fa2baeb..a520e192 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { defaultConfig { applicationId = "dev.typetype.android" - versionCode = 11108 - versionName = "1.11.0-beta.8" + versionCode = 11109 + versionName = "1.11.0-beta.9" } signingConfigs { diff --git a/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt b/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt index f7ec96c0..a3f27cf9 100644 --- a/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt +++ b/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt @@ -29,6 +29,7 @@ import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute import dev.typetype.android.core.ui.navigation.RssFeedsRoute import dev.typetype.android.core.ui.navigation.PushNotificationsRoute +import dev.typetype.android.core.ui.navigation.RecommenderSettingsRoute import dev.typetype.android.core.ui.navigation.SettingsRoute import dev.typetype.android.core.ui.navigation.StorageSettingsRoute import dev.typetype.android.feature.settings.SettingsRoute as SettingsMenu @@ -67,6 +68,7 @@ internal fun AdaptiveSettingsHost( onOpenRssFeeds = { navController.selectSettings(RssFeedsRoute) }, onOpenAppearance = { navController.selectSettings(AppearanceRoute) }, onOpenContent = { navController.selectSettings(ContentSettingsRoute) }, + onOpenRecommendations = { navController.selectSettings(RecommenderSettingsRoute) }, onOpenPlayer = { navController.selectSettings(PlayerSettingsRoute) }, onOpenPushNotifications = { navController.selectSettings(PushNotificationsRoute) }, onOpenStorage = { navController.selectSettings(StorageSettingsRoute) }, @@ -105,6 +107,7 @@ private fun NavDestination.settingsTitle(): Int? = when { hasRoute() -> R.string.settings_import_title hasRoute() -> R.string.rss_settings_title hasRoute() -> R.string.settings_content_title + hasRoute() -> R.string.algo_title hasRoute() -> R.string.settings_player_title hasRoute() -> R.string.settings_storage_title hasRoute() -> R.string.settings_privacy_title diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index 649060e8..d905eadb 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -239,9 +239,6 @@ fun AppNavHost( composable { ContentSettingsRouteScreen( onNavigateBack = { navController.popBackStack() }, - onOpenRecommender = { - navController.navigate(dev.typetype.android.core.ui.navigation.RecommenderSettingsRoute) - }, ) } composable { diff --git a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt index 0cb0a969..045df587 100644 --- a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt +++ b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt @@ -14,6 +14,7 @@ import dev.typetype.android.core.ui.navigation.LicensesRoute import dev.typetype.android.core.ui.navigation.PlayerSettingsRoute import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute +import dev.typetype.android.core.ui.navigation.RecommenderSettingsRoute import dev.typetype.android.core.ui.navigation.RssFeedsRoute import dev.typetype.android.core.ui.navigation.SettingsRoute import dev.typetype.android.core.ui.navigation.StorageSettingsRoute @@ -41,6 +42,7 @@ internal fun NavGraphBuilder.settingsDestinations( onOpenRssFeeds = { navController.navigate(RssFeedsRoute) }, onOpenAppearance = { navController.navigate(AppearanceRoute) }, onOpenContent = { navController.navigate(ContentSettingsRoute) }, + onOpenRecommendations = { navController.navigate(RecommenderSettingsRoute) }, onOpenPlayer = { navController.navigate(PlayerSettingsRoute) }, onOpenPushNotifications = { navController.navigate(dev.typetype.android.core.ui.navigation.PushNotificationsRoute) }, onOpenStorage = { navController.navigate(StorageSettingsRoute) }, diff --git a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt index f0be4014..427dcb72 100644 --- a/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt +++ b/core/data/src/main/java/dev/typetype/android/data/network/TypeTypeRecommenderApi.kt @@ -40,6 +40,12 @@ interface TypeTypeRecommenderApi { @GET("recommendations/data") suspend fun recommenderData(): Response + @GET("recommendations/last-home") + suspend fun recommenderLastHome(): Response + + @GET("recommendations/feedback") + suspend fun recommenderFeedback(): Response + @DELETE("recommendations/feedback/{id}") suspend fun deleteRecommenderFeedback(@Path("id") id: String): Response } diff --git a/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderCatalogJson.kt b/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderCatalogJson.kt index e39b4e7f..b647702e 100644 --- a/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderCatalogJson.kt +++ b/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderCatalogJson.kt @@ -4,24 +4,17 @@ import dev.typetype.android.domain.recommendations.AlgorithmCatalog import dev.typetype.android.domain.recommendations.AlgorithmInfo import dev.typetype.android.domain.recommendations.AlgorithmParameter import dev.typetype.android.domain.recommendations.AlgorithmTraits -import dev.typetype.android.domain.recommendations.DataFamily -import dev.typetype.android.domain.recommendations.InterestGroup import dev.typetype.android.domain.recommendations.RecommenderPreferences -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive internal fun JsonObject.toCatalog(): AlgorithmCatalog = AlgorithmCatalog( defaultId = string("default_algorithm"), - algorithms = array("algorithms").mapNotNull { (it as? JsonObject)?.toAlgorithm() }, + algorithms = objects("algorithms").mapNotNull { it.toAlgorithm() }, ) private fun JsonObject.toAlgorithm(): AlgorithmInfo? { @@ -40,7 +33,10 @@ private fun JsonObject.toAlgorithm(): AlgorithmInfo? { discovery = traits?.int("discovery") ?: 0, variety = traits?.int("variety") ?: 0, ), - parameters = array("parameters").mapNotNull { (it as? JsonObject)?.toParameter() }, + parameters = objects("parameters").mapNotNull { it.toParameter() }, + uses = strings("uses"), + sources = strings("sources"), + sourceUrl = string("source_url")?.takeIf { it.startsWith("https://") }, ) } @@ -49,7 +45,7 @@ private fun JsonObject.toParameter(): AlgorithmParameter? { val group = string("group").orEmpty() val label = string("label") ?: id val help = string("help") - val featured = this["featured"]?.jsonPrimitive?.booleanOrNull == true + val featured = bool("featured") == true return when (string("type")) { "number" -> AlgorithmParameter.Number( id = id, @@ -69,14 +65,14 @@ private fun JsonObject.toParameter(): AlgorithmParameter? { label = label, help = help, featured = featured, - default = this["default"]?.jsonPrimitive?.booleanOrNull == true, + default = bool("default") == true, ) else -> null } } internal fun JsonObject.toPreferences(): RecommenderPreferences = RecommenderPreferences( - enabled = this["enabled"]?.jsonPrimitive?.booleanOrNull == true, + enabled = bool("enabled") == true, algorithmId = string("algorithmId"), parameters = obj("parameters").orEmpty().mapNotNull { (key, value) -> val primitive = value as? JsonPrimitive ?: return@mapNotNull null @@ -85,8 +81,14 @@ internal fun JsonObject.toPreferences(): RecommenderPreferences = RecommenderPre }.toMap(), pinnedGroups = strings("pinnedGroups"), mutedGroups = strings("mutedGroups"), + ignoredSignals = strings("ignoredSignals"), + sessionResetAt = long("sessionResetAt"), customInterests = strings("customInterests"), unusedData = strings("unusedData"), + excludedItems = strings("excludedItems"), + retentionDays = obj("retentionDays").orEmpty().mapNotNull { (key, value) -> + (value as? JsonPrimitive)?.intOrNull?.let { key to it } + }.toMap(), ) internal fun JsonObject.withPreferences(preferences: RecommenderPreferences): JsonObject = @@ -94,57 +96,20 @@ internal fun JsonObject.withPreferences(preferences: RecommenderPreferences): Js toMutableMap().apply { put("enabled", JsonPrimitive(preferences.enabled)) put("algorithmId", preferences.algorithmId?.let(::JsonPrimitive) ?: JsonNull) - put( - "parameters", - JsonObject( - preferences.parameters.mapValues { (_, value) -> - when (value) { - is Boolean -> JsonPrimitive(value) - is Number -> JsonPrimitive(value) - else -> JsonPrimitive(value.toString()) - } - }, - ), - ) + put("parameters", JsonObject(preferences.parameters.mapValues { (_, value) -> value.toJsonValue() })) put("pinnedGroups", preferences.pinnedGroups.toJsonArray()) put("mutedGroups", preferences.mutedGroups.toJsonArray()) + put("ignoredSignals", preferences.ignoredSignals.toJsonArray()) + put("sessionResetAt", preferences.sessionResetAt?.let(::JsonPrimitive) ?: JsonNull) put("customInterests", preferences.customInterests.toJsonArray()) put("unusedData", preferences.unusedData.toJsonArray()) + put("excludedItems", preferences.excludedItems.toJsonArray()) + put("retentionDays", JsonObject(preferences.retentionDays.mapValues { (_, days) -> JsonPrimitive(days) })) }, ) -internal fun JsonObject.toInterests(): List = array("groups").mapNotNull { element -> - val group = element as? JsonObject ?: return@mapNotNull null - InterestGroup( - id = group.string("id") ?: return@mapNotNull null, - label = group.string("label").orEmpty(), - weight = group.double("weight") ?: 0.0, - pinned = group["pinned"]?.jsonPrimitive?.booleanOrNull == true, - muted = group["muted"]?.jsonPrimitive?.booleanOrNull == true, - ) -} - -internal fun JsonArray.toDataFamilies(): List = mapNotNull { element -> - val family = element as? JsonObject ?: return@mapNotNull null - DataFamily( - id = family.string("id") ?: return@mapNotNull null, - count = family.int("count") ?: 0, - used = family["use"]?.jsonPrimitive?.booleanOrNull != false, - ) +private fun Any.toJsonValue(): JsonPrimitive = when (this) { + is Boolean -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + else -> JsonPrimitive(toString()) } - -private fun List.toJsonArray(): JsonArray = JsonArray(map(::JsonPrimitive)) - -private fun JsonObject.string(key: String): String? = - (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content - -private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull - -private fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull - -private fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject - -private fun JsonObject.array(key: String): List = (this[key] as? JsonArray).orEmpty() - -private fun JsonObject.strings(key: String): List = - array(key).mapNotNull { (it as? JsonPrimitive)?.takeIf { primitive -> primitive.isString }?.content } diff --git a/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderInsightsJson.kt b/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderInsightsJson.kt new file mode 100644 index 00000000..10b989f7 --- /dev/null +++ b/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderInsightsJson.kt @@ -0,0 +1,119 @@ +package dev.typetype.android.data.recommendations + +import dev.typetype.android.domain.recommendations.DataCategory +import dev.typetype.android.domain.recommendations.DataItem +import dev.typetype.android.domain.recommendations.FeedbackEntry +import dev.typetype.android.domain.recommendations.InterestExample +import dev.typetype.android.domain.recommendations.InterestGroup +import dev.typetype.android.domain.recommendations.PageTrace +import dev.typetype.android.domain.recommendations.ProfileChannel +import dev.typetype.android.domain.recommendations.RecommenderProfile +import dev.typetype.android.domain.recommendations.SessionBoost +import dev.typetype.android.domain.recommendations.TraceRow +import dev.typetype.android.domain.recommendations.TraceScore +import dev.typetype.android.domain.recommendations.TraceSource +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull + +internal fun JsonObject.toProfile(): RecommenderProfile = RecommenderProfile( + groups = objects("groups").mapNotNull { it.toInterestGroup() }, + channels = objects("channels").mapNotNull { channel -> + ProfileChannel( + id = channel.string("id") ?: return@mapNotNull null, + name = channel.string("name").orEmpty(), + weight = channel.double("weight") ?: 0.0, + basedOn = channel.array("from").size, + avatarUrl = channel.string("avatarUrl"), + ) + }, + sessionBoosts = objects("session_boosts").mapNotNull { boost -> + SessionBoost( + ref = boost.string("ref") ?: return@mapNotNull null, + label = boost.string("label").orEmpty(), + expiresAt = boost.long("expires_at") ?: 0L, + ) + }, +) + +private fun JsonObject.toInterestGroup(): InterestGroup? = InterestGroup( + id = string("id") ?: return null, + label = string("label").orEmpty(), + weight = double("weight") ?: 0.0, + basedOn = array("from").size, + pinned = bool("pinned") == true, + muted = bool("muted") == true, + examples = objects("examples").mapNotNull { example -> + InterestExample( + url = example.string("url") ?: return@mapNotNull null, + title = example.string("title"), + thumbnailUrl = example.string("thumbnailUrl"), + ) + }, +) + +internal fun JsonArray.toDataCategories(): List = filterIsInstance().mapNotNull { category -> + DataCategory( + id = category.string("id") ?: return@mapNotNull null, + count = category.int("count") ?: 0, + used = category.bool("use") != false, + retentionDays = category.int("retentionDays"), + collect = category.bool("collect"), + items = category.objects("items").mapNotNull { it.toDataItem() }, + ) +} + +private fun JsonObject.toDataItem(): DataItem? = DataItem( + id = string("id") ?: return null, + title = string("title").orEmpty(), + channel = string("channel"), + at = long("at") ?: 0L, + watchedPercent = int("watchedPercent"), + excluded = bool("excluded") == true, + thumbnailUrl = string("thumbnailUrl"), + viewings = int("viewings") ?: 1, +) + +internal fun JsonObject.toPageTrace(): PageTrace = PageTrace( + algorithm = string("algorithm").orEmpty(), + builtAt = long("builtAt") ?: 0L, + sources = objects("sources").map { TraceSource(it.string("kind").orEmpty(), it.int("received") ?: 0) }, + candidates = int("candidatesReceived") ?: 0, + dropped = obj("dropped").orEmpty().mapNotNull { (why, count) -> + (count as? JsonPrimitive)?.intOrNull?.let { why to it } + }.toMap(), + shown = int("shown") ?: 0, + rows = objects("rows").map { row -> + TraceRow(row.string("id").orEmpty(), row.string("title").orEmpty(), row.string("why").orEmpty(), row.int("videos") ?: 0) + }, + scores = objects("scores").map { score -> + TraceScore( + url = score.string("url").orEmpty(), + title = score.string("title").orEmpty(), + channel = score.string("channel").orEmpty(), + row = score.string("row").orEmpty(), + total = score.double("total") ?: 0.0, + factors = score.obj("factors").orEmpty().mapNotNull { (factor, value) -> + (value as? JsonPrimitive)?.doubleOrNull?.let { factor to it } + }.toMap(), + ) + }, +) + +internal fun JsonArray.toFeedbackEntries(): List = filterIsInstance().mapNotNull { entry -> + FeedbackEntry( + id = entry.string("id")?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null, + kind = entry.string("kind").orEmpty(), + verdict = entry.string("verdict").orEmpty(), + comment = entry.string("comment"), + reason = entry.string("reason"), + title = entry.string("title"), + channelName = entry.string("channelName"), + videoUrl = entry.string("videoUrl"), + thumbnailUrl = entry.string("thumbnailUrl"), + algorithmId = entry.string("algorithmId").orEmpty(), + createdAt = entry.long("createdAt") ?: 0L, + ) +} diff --git a/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderJsonFields.kt b/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderJsonFields.kt new file mode 100644 index 00000000..3787e2e7 --- /dev/null +++ b/core/data/src/main/java/dev/typetype/android/data/recommendations/RecommenderJsonFields.kt @@ -0,0 +1,32 @@ +package dev.typetype.android.data.recommendations + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull + +internal fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + +internal fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + +internal fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.let { it.longOrNull ?: it.doubleOrNull?.toLong() } + +internal fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimitive)?.doubleOrNull + +internal fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull + +internal fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject + +internal fun JsonObject.array(key: String): List = (this[key] as? JsonArray).orEmpty() + +internal fun JsonObject.objects(key: String): List = array(key).filterIsInstance() + +internal fun JsonObject.strings(key: String): List = + array(key).mapNotNull { (it as? JsonPrimitive)?.takeIf { primitive -> primitive.isString }?.content } + +internal fun List.toJsonArray(): JsonArray = JsonArray(map(::JsonPrimitive)) diff --git a/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderRepository.kt b/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderRepository.kt index 054dc7a7..44bbb356 100644 --- a/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderRepository.kt +++ b/core/data/src/main/java/dev/typetype/android/data/recommendations/RemoteRecommenderRepository.kt @@ -6,9 +6,11 @@ import dev.typetype.android.data.network.TypeTypeApi import dev.typetype.android.data.network.TypeTypeApiHolder import dev.typetype.android.data.network.requireSuccessfulResponse import dev.typetype.android.domain.recommendations.AlgorithmCatalog -import dev.typetype.android.domain.recommendations.DataFamily -import dev.typetype.android.domain.recommendations.InterestGroup +import dev.typetype.android.domain.recommendations.DataCategory +import dev.typetype.android.domain.recommendations.FeedbackEntry +import dev.typetype.android.domain.recommendations.PageTrace import dev.typetype.android.domain.recommendations.RecommenderPreferences +import dev.typetype.android.domain.recommendations.RecommenderProfile import dev.typetype.android.domain.recommendations.RecommenderRepository import java.util.TimeZone import javax.inject.Inject @@ -34,18 +36,34 @@ class RemoteRecommenderRepository @Inject constructor( call { updateRecommenderSettings(current.withPreferences(preferences)) }.getOrThrow().toPreferences() } - override suspend fun interests(locale: String): Result> = - call { recommenderProfile(locale, TimeZone.getDefault().id) }.map { it.toInterests() } + override suspend fun profile(locale: String): Result = + call { recommenderProfile(locale, TimeZone.getDefault().id) }.map { it.toProfile() } - override suspend fun dataFamilies(): Result> = - call { recommenderData() }.map { it.toDataFamilies() } + override suspend fun data(): Result> = + call { recommenderData() }.map { it.toDataCategories() } - private suspend fun call(request: suspend TypeTypeApi.() -> Response): Result = suspendRunCatching { + override suspend fun lastHome(): Result = + callOrNull(NOT_FOUND) { recommenderLastHome() }.map { it?.toPageTrace() } + + override suspend fun feedback(): Result> = + call { recommenderFeedback() }.map { it.toFeedbackEntries() } + + private suspend fun call(request: suspend TypeTypeApi.() -> Response): Result = + callOrNull(null, request).map { requireNotNull(it) { "Empty recommender response" } } + + private suspend fun callOrNull( + absentCode: Int?, + request: suspend TypeTypeApi.() -> Response, + ): Result = suspendRunCatching { val scope = activeAccountScope.require() val response = withContext(Dispatchers.IO) { apiHolder.require(scope).request() } - response.requireSuccessfulResponse() - val body = requireNotNull(response.body()) { "Empty recommender response" } activeAccountScope.verify(scope) - body + if (absentCode != null && response.code() == absentCode) return@suspendRunCatching null + response.requireSuccessfulResponse() + response.body() + } + + private companion object { + const val NOT_FOUND = 404 } } diff --git a/core/data/src/test/java/dev/typetype/android/data/recommendations/RecommenderJsonTest.kt b/core/data/src/test/java/dev/typetype/android/data/recommendations/RecommenderJsonTest.kt new file mode 100644 index 00000000..675f5636 --- /dev/null +++ b/core/data/src/test/java/dev/typetype/android/data/recommendations/RecommenderJsonTest.kt @@ -0,0 +1,103 @@ +package dev.typetype.android.data.recommendations + +import dev.typetype.android.domain.recommendations.RecommenderPreferences +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecommenderJsonTest { + private fun obj(text: String) = Json.parseToJsonElement(text) as JsonObject + private fun array(text: String) = Json.parseToJsonElement(text) as JsonArray + + @Test + fun preferencesRoundTripKeepsEveryFieldAndUnknownOnes() { + val server = obj( + """{"enabled":true,"algorithmId":"signals","parameters":{"w":0.5,"shorts":false}, + "ignoredSignals":["channel:a"],"sessionResetAt":1700,"excludedItems":["v1"], + "retentionDays":{"history":30},"future":"keep"}""", + ) + val parsed = server.toPreferences() + assertEquals(listOf("channel:a"), parsed.ignoredSignals) + assertEquals(1700L, parsed.sessionResetAt) + assertEquals(mapOf("history" to 30), parsed.retentionDays) + assertEquals(0.5, parsed.parameters["w"]) + assertEquals(false, parsed.parameters["shorts"]) + + val saved = server.withPreferences(parsed.copy(retentionDays = emptyMap(), sessionResetAt = null)) + assertEquals("keep", saved["future"]?.jsonPrimitive?.content) + assertEquals(RecommenderPreferences().retentionDays, saved.toPreferences().retentionDays) + assertNull(saved.toPreferences().sessionResetAt) + assertEquals(listOf("v1"), saved.toPreferences().excludedItems) + } + + @Test + fun catalogReadsTransparencyFieldsAndRejectsUnsafeSourceLinks() { + val catalog = obj( + """{"default_algorithm":"a","algorithms":[ + {"id":"a","name":"A","uses":["history"],"sources":["trending"],"source_url":"https://example.org/a"}, + {"id":"b","name":"B","source_url":"javascript:alert(1)"}]}""", + ).toCatalog() + assertEquals(listOf("history"), catalog.algorithms[0].uses) + assertEquals("https://example.org/a", catalog.algorithms[0].sourceUrl) + assertNull(catalog.algorithms[1].sourceUrl) + } + + @Test + fun profileCountsSourcesAndKeepsExamples() { + val profile = obj( + """{"groups":[{"id":"group:1","label":"Space","weight":2.5,"from":["a","b"],"pinned":true, + "examples":[{"url":"u","title":"T","thumbnailUrl":"https://i/t.jpg"}]}], + "channels":[{"id":"c","name":"Chan","weight":1,"from":["x"],"avatarUrl":"https://i/a.jpg"}], + "session_boosts":[{"ref":"r","label":"L","strength":1,"expires_at":99}]}""", + ).toProfile() + val group = profile.groups.single() + assertEquals(2, group.basedOn) + assertTrue(group.pinned) + assertTrue(group.pinnable) + assertFalse(group.custom) + assertEquals("T", group.examples.single().title) + assertEquals(1, profile.channels.single().basedOn) + assertEquals(99L, profile.sessionBoosts.single().expiresAt) + } + + @Test + fun dataCategoriesKeepCollectAndItems() { + val data = array( + """[{"id":"history","count":3,"use":false,"retentionDays":90,"collect":true, + "items":[{"id":"v","title":"Video","at":5,"watchedPercent":40,"excluded":true,"viewings":2}]}, + {"id":"blocked","count":0}]""", + ).toDataCategories() + assertFalse(data[0].used) + assertEquals(90, data[0].retentionDays) + assertEquals(true, data[0].collect) + assertEquals(2, data[0].items.single().viewings) + assertTrue(data[1].used) + assertNull(data[1].collect) + } + + @Test + fun traceAndFeedbackAreRead() { + val trace = obj( + """{"algorithm":"signals","builtAt":10,"sources":[{"kind":"related","detail":"d","received":4}], + "candidatesReceived":40,"dropped":{"seen":3},"shown":20, + "rows":[{"id":"r","title":"Row","why":"because","videos":5}], + "scores":[{"url":"u","title":"t","channel":"c","row":"r","total":1.5,"factors":{"fresh":0.5}}]}""", + ).toPageTrace() + assertEquals(40, trace.candidates) + assertEquals(mapOf("seen" to 3), trace.dropped) + assertEquals(0.5, trace.scores.single().factors["fresh"]) + + val feedback = array( + """[{"id":"f1","kind":"video","verdict":"not_for_me","reason":"seen","title":"T","algorithmId":"a","createdAt":7}, + {"id":"","kind":"page","verdict":"good"}]""", + ).toFeedbackEntries() + assertEquals(1, feedback.size) + assertEquals("seen", feedback.single().reason) + } +} diff --git a/core/model/src/main/java/dev/typetype/android/domain/recommendations/RecommenderInsights.kt b/core/model/src/main/java/dev/typetype/android/domain/recommendations/RecommenderInsights.kt new file mode 100644 index 00000000..b8641a9b --- /dev/null +++ b/core/model/src/main/java/dev/typetype/android/domain/recommendations/RecommenderInsights.kt @@ -0,0 +1,90 @@ +package dev.typetype.android.domain.recommendations + +data class InterestExample(val url: String, val title: String?, val thumbnailUrl: String?) + +data class InterestGroup( + val id: String, + val label: String, + val weight: Double, + val basedOn: Int, + val pinned: Boolean, + val muted: Boolean, + val examples: List = emptyList(), +) { + val custom: Boolean get() = id.startsWith("custom:") + val pinnable: Boolean get() = id.startsWith("group:") +} + +data class ProfileChannel( + val id: String, + val name: String, + val weight: Double, + val basedOn: Int, + val avatarUrl: String?, +) + +data class SessionBoost(val ref: String, val label: String, val expiresAt: Long) + +data class RecommenderProfile( + val groups: List = emptyList(), + val channels: List = emptyList(), + val sessionBoosts: List = emptyList(), +) + +data class DataItem( + val id: String, + val title: String, + val channel: String?, + val at: Long, + val watchedPercent: Int?, + val excluded: Boolean, + val thumbnailUrl: String?, + val viewings: Int, +) + +data class DataCategory( + val id: String, + val count: Int, + val used: Boolean, + val retentionDays: Int?, + val collect: Boolean?, + val items: List = emptyList(), +) + +data class TraceSource(val kind: String, val received: Int) + +data class TraceRow(val id: String, val title: String, val why: String, val videos: Int) + +data class TraceScore( + val url: String, + val title: String, + val channel: String, + val row: String, + val total: Double, + val factors: Map, +) + +data class PageTrace( + val algorithm: String, + val builtAt: Long, + val sources: List, + val candidates: Int, + val dropped: Map, + val shown: Int, + val rows: List, + val scores: List, +) + +data class FeedbackEntry( + val id: String, + val kind: String, + val verdict: String, + val comment: String?, + val reason: String?, + val title: String?, + val channelName: String?, + val videoUrl: String?, + val thumbnailUrl: String?, + val algorithmId: String, + val createdAt: Long, +) diff --git a/core/model/src/main/java/dev/typetype/android/domain/recommendations/RecommenderModels.kt b/core/model/src/main/java/dev/typetype/android/domain/recommendations/RecommenderModels.kt index a56b19d0..8608b698 100644 --- a/core/model/src/main/java/dev/typetype/android/domain/recommendations/RecommenderModels.kt +++ b/core/model/src/main/java/dev/typetype/android/domain/recommendations/RecommenderModels.kt @@ -41,6 +41,9 @@ data class AlgorithmInfo( val goodToKnow: String, val traits: AlgorithmTraits, val parameters: List, + val uses: List = emptyList(), + val sources: List = emptyList(), + val sourceUrl: String? = null, ) data class AlgorithmCatalog(val defaultId: String?, val algorithms: List) @@ -51,24 +54,20 @@ data class RecommenderPreferences( val parameters: Map = emptyMap(), val pinnedGroups: List = emptyList(), val mutedGroups: List = emptyList(), + val ignoredSignals: List = emptyList(), + val sessionResetAt: Long? = null, val customInterests: List = emptyList(), val unusedData: List = emptyList(), + val excludedItems: List = emptyList(), + val retentionDays: Map = emptyMap(), ) -data class InterestGroup( - val id: String, - val label: String, - val weight: Double, - val pinned: Boolean, - val muted: Boolean, -) - -data class DataFamily(val id: String, val count: Int, val used: Boolean) - interface RecommenderRepository { suspend fun catalog(locale: String): Result suspend fun preferences(): Result suspend fun save(preferences: RecommenderPreferences): Result - suspend fun interests(locale: String): Result> - suspend fun dataFamilies(): Result> + suspend fun profile(locale: String): Result + suspend fun data(): Result> + suspend fun lastHome(): Result + suspend fun feedback(): Result> } diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index 1c82d6e4..43d3b659 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -244,10 +244,6 @@ Remove the Shorts surface Hide subscription live streams Remove live and upcoming streams from subscriptions - Recommendations - Use this instance’s recommender - Choose whether its recommendation service can shape your home feed - Experimental service; enabling it may change your home feed Content settings Couldn\'t update content settings Red @@ -936,14 +932,9 @@ Recommended Recommendations Choose the algorithm, its settings, your interests and the data it reads - The recommendation settings could not be loaded. Retry Your change could not be saved. Try again. - Personalized home page - Build the home page with the algorithm chosen below instead of the standard page - Algorithm Each algorithm suits a different way of using the home page. You can switch at any time: your data and each algorithm\'s settings are kept. - For you if What you will see Good to know Personal @@ -977,7 +968,6 @@ Pin Hide Data it may read - Turn a family off to keep the algorithm from reading it. Nothing is deleted. Video history Shorts history Favorites @@ -990,9 +980,254 @@ %1$d subjects added, that is the maximum. Remove one to add another. - %1$d item - %1$d items + %1$s item + %1$s items + Adjust + + %1$d day ago + %1$d days ago + + %1$d h ago + just now + All settings + All settings of %1$s + Interactions + Search history + Shown on the home page + Watch later + Most watched channels + Home page algorithm + Code and documentation of this algorithm. + Source code + Collect + Record new videos and Shorts. + videos + Composition of the home page + By %1$s. + Take into account + Default: %1$s + + %1$d blocked + %1$d blocked + + + %1$d too many from one channel + %1$d too many from one channel + + + %1$d from excluded data + %1$d from excluded data + + + %1$d hidden interest + %1$d hidden interests + + + %1$d not for you + %1$d not for you + + + %1$d almost nobody watched + %1$d almost nobody watched + + + %1$d already watched + %1$d already watched + + + %1$d Short + %1$d Shorts + + No items. + Exclude + excluded from the home page + Experimental + Home page by algorithm + Off. You keep the standard home page until you turn it on. + On. Turn it off to get the standard home page back. + Build my home page with an algorithm I choose + Not good + Nothing here yet + Good + Only what you choose to send is kept, with the algorithm and settings you had at that moment. It leaves with your export and you can take each opinion back. + Opinions given + Not for me + Take back + Forget + Forget recent videos + They stop steering the home page; your history is not deleted. + Choose + %1$s is now building your home page + Current algorithm + Data it reads + More details + Fewer details + Which algorithm suits me? + Compare them side by side. There is no wrong choice. + The creators you watch the most. Select one to see what it is based on, or to stop it counting. + The algorithm decides which videos appear on your home page and in what order. Nothing else changes: search, subscriptions and history stay the same. + Video and Shorts history share this setting. When it is off, no new watches are recorded; existing entries stay until their retention periods end. + How many videos of the home page come from each source. The shares follow your settings: change one and the chart updates. + The topics the algorithm deduced from what you watch, and their weight in recommendations. A hidden or ignored topic counts for nothing. + Every home page starts from many candidate videos. Some are dropped (already seen, blocked, too many from one channel…), and the best of the rest are shown. + Videos watched a short while ago that still steer the home page. Forget removes their effect without erasing your history. + Older items stay in your account but no longer count for the home page. + Everything stored for your account, by category. In blue, what the current algorithm reads. In grey, what is kept without being used for the home page. + Off, the items stay recorded but no algorithm reads them to build the home page. + Don\'t take into account + not taken into account + Include + + Show the other one + Show the %1$d others + + added by you + Latest %1$d of %2$s + Read back + The algorithm does not read older items. Nothing is deleted. + kept, not used for the home page + How the last home page was built + Last home page + No home page built by an algorithm yet. Open the home page first. + %1$s found, %2$s shown + %1$s min + About: %1$s + hidden from the home page + no longer counts + This instance does not offer the algorithm page. + not collected + not used by %1$s + Off + On + Nothing to show here yet. Your home page by algorithm is on its way. + Turn the home page by algorithm on to see what it is made of. + pinned + Nothing deduced yet. Watch a few videos and come back. + That would not load. It may be the connection, it may be the algorithm. + Nothing recent here + Recently watched + Every setting goes back to its default value. + Restore %1$s defaults? + Restore + %1$d d + No limit + + %1$d month + %1$d months + + 1 year + Why each row is on the home page. + Home page rows + Each video shown, its score and what made it. + Algorithm + Home page algorithm and its main settings. + Details + Every setting and how the last home page was built. + Data used + Review collected data and choose what the algorithm may use. + My opinions + The opinions you gave on the home page. Nothing else is recorded. + Algorithm guide + Which algorithm suits you? There is no right answer, only different ways of watching. + Overview + What your home is made of. Each part leads to where it is set. + What it keeps + Review and correct what the algorithm infers from your data. + interests + Share of interests + + %1$d other + %1$d others + + Show + subscriptions feed + trending + Choose how the home page is built and which data it uses. + How it works + Unpin + The algorithm is having a nap. Meanwhile your home page stays the usual one. + Use %1$s for the home page + used by %1$s + Use for the home page + Let the algorithm read these items. + Data used + None + %1$d%% watched + Cancel + Collapsed + Expanded + Loading… + Explore and adjust + On. %1$s builds your home page. + Sign in to an account on this instance to choose how your home page is built. + This instance has the recommender, but its admin has not offered the home page by algorithm yet. An admin can turn it on in the web admin console, under Recommender. + + %1$d setting, with its default value. + %1$d settings, with their default values. + + + based on %1$d item + based on %1$d items + + + %1$d changed + %1$d changed + + + counts for %1$d more min + counts for %1$d more min + + + %1$d video set aside + %1$d videos set aside + + + %1$d video found + %1$d videos found + + + %1$d row + %1$d rows + + + %1$d best score + %1$d best scores + + + %1$d setting + %1$d settings + + + %1$d video shown + %1$d videos shown + + + In %1$d row. + In %1$d rows. + + + catalogue of %1$d channel + catalogue of %1$d channels + + + videos related to %1$d watched video + videos related to %1$d watched videos + + + %1$d search + %1$d searches + + + %1$d video + %1$d videos + + + watched %1$d time + watched %1$d times + + Actions for %1$s + Remove %1$s Your home page can learn what you like This instance offers personalized recommendations. Turn them on to get rows built from what you watch and follow, and change the algorithm anytime in Settings. Turn on diff --git a/feature/settings/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt b/feature/settings/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt index ee96f1f0..9eefa667 100644 --- a/feature/settings/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt +++ b/feature/settings/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt @@ -93,33 +93,6 @@ class ContentSettingsScreenTest { composeRule.onNodeWithText("Hide subscription live streams").assertIsDisplayed() } - @Test - fun recommenderControlAppearsOnlyWhenTheInstanceAdvertisesIt() { - val action = AtomicReference() - show( - ContentSettingsState( - isLoading = false, - recommenderAvailable = true, - recommenderExperimental = true, - ), - ) { action.set(it) } - - scrollTo("Use this instance’s recommender") - composeRule.onNodeWithText("Use this instance’s recommender").assertIsDisplayed() - composeRule.onNodeWithText("Experimental service; enabling it may change your home feed") - .assertIsDisplayed() - composeRule.onNodeWithText("Use this instance’s recommender").performClick() - - assertEquals(ContentSettingsAction.SetRecommenderEnabled(true), action.get()) - } - - @Test - fun recommenderControlIsHiddenWhenTheInstanceDoesNotAdvertiseIt() { - show(ContentSettingsState(isLoading = false)) - - composeRule.onNodeWithText("Use this instance’s recommender").assertDoesNotExist() - } - private fun show( state: ContentSettingsState, onAction: (ContentSettingsAction) -> Unit = {}, diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt index eca47eda..e04a321a 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt @@ -14,6 +14,7 @@ fun SettingsRoute( onOpenAccounts: () -> Unit, onOpenAppearance: () -> Unit, onOpenContent: () -> Unit, + onOpenRecommendations: () -> Unit, onOpenPlayer: () -> Unit, onOpenPushNotifications: () -> Unit, onOpenStorage: () -> Unit, @@ -37,6 +38,8 @@ fun SettingsRoute( rssAvailable = state.accountFeaturesAvailable && state.rssAvailable, onOpenAppearance = onOpenAppearance, onOpenContent = onOpenContent, + onOpenRecommendations = onOpenRecommendations, + recommendationsAvailable = state.accountFeaturesAvailable && state.recommenderInstalled, onOpenPlayer = onOpenPlayer, onOpenPushNotifications = onOpenPushNotifications, onOpenStorage = onOpenStorage, diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt index f70a9960..01fcf986 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.UploadFile @@ -60,6 +61,8 @@ fun SettingsScreen( onOpenAccounts: () -> Unit = {}, onOpenAppearance: () -> Unit, onOpenContent: () -> Unit = {}, + onOpenRecommendations: () -> Unit = {}, + recommendationsAvailable: Boolean = false, onOpenPlayer: () -> Unit = {}, onOpenPushNotifications: () -> Unit = {}, onOpenStorage: () -> Unit = {}, @@ -88,6 +91,9 @@ fun SettingsScreen( } add(SettingsEntry(R.string.settings_appearance_title, R.string.settings_appearance_subtitle, Icons.Filled.Palette, onOpenAppearance)) add(SettingsEntry(R.string.settings_content_title, R.string.settings_content_subtitle, Icons.Filled.Tune, onOpenContent)) + if (recommendationsAvailable) { + add(SettingsEntry(R.string.algo_title, R.string.algo_entry_subtitle, Icons.Filled.AutoAwesome, onOpenRecommendations)) + } add(SettingsEntry(R.string.settings_player_title, R.string.settings_player_subtitle, Icons.Filled.PlayCircle, onOpenPlayer)) add(SettingsEntry(R.string.settings_push_title, R.string.settings_push_subtitle, Icons.Filled.Notifications, onOpenPushNotifications)) add(SettingsEntry(R.string.settings_storage_title, R.string.settings_storage_subtitle, Icons.Filled.Storage, onOpenStorage)) diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsViewModel.kt index af3b1deb..4b408b11 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/SettingsViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch data class SettingsState( val accountFeaturesAvailable: Boolean = false, val rssAvailable: Boolean = false, + val recommenderInstalled: Boolean = false, ) @HiltViewModel @@ -43,6 +44,7 @@ class SettingsViewModel @Inject constructor( mutableState.update { it.copy( rssAvailable = server?.rss?.enabled == true, + recommenderInstalled = server?.recommender?.installed == true, ) } } diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt index fec65bb9..5abb6298 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt @@ -5,9 +5,6 @@ import dev.typetype.android.domain.usersettings.UserSettings sealed interface ContentSettingsAction { data object Retry : ContentSettingsAction data object DismissFailure : ContentSettingsAction - data object RetryRecommender : ContentSettingsAction - data object DismissRecommenderFailure : ContentSettingsAction - data class SetRecommenderEnabled(val enabled: Boolean) : ContentSettingsAction sealed interface Update : ContentSettingsAction diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt index 03b18895..a08f8570 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt @@ -38,7 +38,6 @@ import dev.typetype.android.feature.settings.SettingsDetailTopBar @Composable fun ContentSettingsRoute( onNavigateBack: () -> Unit, - onOpenRecommender: () -> Unit = {}, viewModel: ContentSettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -46,7 +45,6 @@ fun ContentSettingsRoute( state = state, onAction = viewModel::onAction, onNavigateBack = onNavigateBack, - onOpenRecommender = onOpenRecommender, ) } @@ -55,7 +53,6 @@ fun ContentSettingsScreen( state: ContentSettingsState, onAction: (ContentSettingsAction) -> Unit, onNavigateBack: () -> Unit, - onOpenRecommender: () -> Unit = {}, ) { val controlsEnabled = !state.isLoading && !state.isUpdating Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { @@ -173,45 +170,6 @@ fun ContentSettingsScreen( ) } } - if (state.recommenderAvailable) { - item { - SettingsSectionHeader( - stringResource(R.string.settings_content_recommender), - ) - } - item { - SwitchRow( - title = stringResource(R.string.settings_content_recommender_enabled), - subtitle = stringResource( - if (state.recommenderExperimental) { - R.string.settings_content_recommender_experimental - } else { - R.string.settings_content_recommender_subtitle - }, - ), - checked = state.recommenderEnabled, - onCheckedChange = { - onAction(ContentSettingsAction.SetRecommenderEnabled(it)) - }, - enabled = !state.isRecommenderLoading && !state.isRecommenderUpdating, - ) - } - item { - RecommenderEntryRow(onClick = onOpenRecommender) - } - state.recommenderErrorMessage?.let { message -> - item { - ContentSettingsFailureCard( - message = message, - requestId = state.recommenderErrorRequestId, - onRetry = { onAction(ContentSettingsAction.RetryRecommender) }, - onDismiss = { - onAction(ContentSettingsAction.DismissRecommenderFailure) - }, - ) - } - } - } } } } diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt index 754fb1e0..9e0fe6ca 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt @@ -11,13 +11,6 @@ data class ContentSettingsState( val hideShorts: Boolean = false, val hideSubscriptionLiveStreams: Boolean = false, val supportsHideSubscriptionLiveStreams: Boolean = false, - val recommenderAvailable: Boolean = false, - val recommenderEnabled: Boolean = false, - val recommenderExperimental: Boolean = false, - val isRecommenderLoading: Boolean = false, - val isRecommenderUpdating: Boolean = false, - val recommenderErrorMessage: String? = null, - val recommenderErrorRequestId: String? = null, val deArrowEnabled: Boolean = false, val deArrowTitleMode: String = "dearrow", val deArrowThumbnailMode: String = "dearrow_or_random", diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt index 8309c9a2..26da36f0 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt @@ -5,24 +5,15 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.typetype.android.R import dev.typetype.android.core.ui.error.UserErrorMapper -import dev.typetype.android.data.account.AccountScope import dev.typetype.android.data.account.ActiveAccountScope import dev.typetype.android.data.preferences.StartupLandingStore -import dev.typetype.android.domain.account.AccountRepository -import dev.typetype.android.domain.recommendations.RecommenderSettingsRepository -import dev.typetype.android.domain.server.ServerCapabilitiesRepository -import dev.typetype.android.domain.server.ServerRepository import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import javax.inject.Inject -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,10 +24,6 @@ class ContentSettingsViewModel @Inject constructor( private val errorMapper: UserErrorMapper, private val activeAccountScope: ActiveAccountScope, private val startupLandingStore: StartupLandingStore, - private val accountRepository: AccountRepository, - private val serverRepository: ServerRepository, - private val capabilitiesRepository: ServerCapabilitiesRepository, - private val recommenderSettingsRepository: RecommenderSettingsRepository, ) : ViewModel() { private val feedback = MutableStateFlow(ContentSettingsFeedback()) @@ -52,139 +39,18 @@ class ContentSettingsViewModel @Inject constructor( init { refresh() - observeRecommender() } fun onAction(action: ContentSettingsAction) { when (action) { ContentSettingsAction.Retry -> refresh() - ContentSettingsAction.RetryRecommender -> refreshRecommender() ContentSettingsAction.DismissFailure -> feedback.update { it.copy(errorMessage = null, errorRequestId = null) } - ContentSettingsAction.DismissRecommenderFailure -> feedback.update { - it.copy(recommenderErrorMessage = null, recommenderErrorRequestId = null) - } - is ContentSettingsAction.SetRecommenderEnabled -> updateRecommender(action.enabled) is ContentSettingsAction.Update -> update(action) } } - private fun observeRecommender() { - viewModelScope.launch { - activeAccountScope.observe().distinctUntilChanged().collectLatest { scope -> - feedback.update { it.withoutRecommender(scope != null) } - loadRecommender(scope) - } - } - } - - private fun refreshRecommender() { - viewModelScope.launch { - val scope = activeAccountScope.observe().first() - feedback.update { it.withoutRecommender(scope != null) } - loadRecommender(scope) - } - } - - private suspend fun loadRecommender(scope: AccountScope?) { - if (scope == null) return - try { - val account = accountRepository.observeAccounts().first().firstOrNull { - it.serverId == scope.serverId && it.id == scope.accountId - } - if (account == null || account.isGuest) { - clearRecommender(scope) - return - } - val cachedServer = serverRepository.getServer(scope.serverId) - if (cachedServer == null) { - clearRecommender(scope) - return - } - val server = capabilitiesRepository.refresh(scope.serverId).getOrNull() ?: cachedServer - if (!scopeIsCurrent(scope)) return - if (!server.recommender.available) { - feedback.update { it.withoutRecommender() } - return - } - feedback.update { - it.copy( - recommenderAvailable = true, - recommenderExperimental = server.recommender.experimental, - ) - } - val result = recommenderSettingsRepository.enabled() - if (!scopeIsCurrent(scope)) return - result.fold( - onSuccess = { enabled -> - feedback.update { - it.copy( - recommenderEnabled = enabled, - isRecommenderLoading = false, - recommenderErrorMessage = null, - recommenderErrorRequestId = null, - ) - } - }, - onFailure = ::showRecommenderFailure, - ) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - if (scopeIsCurrent(scope)) showRecommenderFailure(failure) - } - } - - private suspend fun clearRecommender(scope: AccountScope) { - if (scopeIsCurrent(scope)) feedback.update { it.withoutRecommender() } - } - - private suspend fun scopeIsCurrent(expected: AccountScope): Boolean = - activeAccountScope.observe().first() == expected - - private fun updateRecommender(enabled: Boolean) { - val previousEnabled = feedback.value.recommenderEnabled - if (!feedback.value.recommenderAvailable || feedback.value.isRecommenderLoading || - feedback.value.isRecommenderUpdating || previousEnabled == enabled - ) return - viewModelScope.launch { - val scope = activeAccountScope.observe().first() ?: return@launch - if (!feedback.value.recommenderAvailable) return@launch - feedback.update { - it.copy( - recommenderEnabled = enabled, - isRecommenderUpdating = true, - recommenderErrorMessage = null, - recommenderErrorRequestId = null, - ) - } - val result = recommenderSettingsRepository.setEnabled(enabled) - if (!scopeIsCurrent(scope)) return@launch - result.fold( - onSuccess = { saved -> - feedback.update { - it.copy(recommenderEnabled = saved, isRecommenderUpdating = false) - } - }, - onFailure = { failure -> showRecommenderFailure(failure, previousEnabled) }, - ) - } - } - - private fun showRecommenderFailure(failure: Throwable, enabled: Boolean? = null) { - val details = errorMapper.details(failure, R.string.settings_content_update_failed) - feedback.update { - it.copy( - recommenderEnabled = enabled ?: it.recommenderEnabled, - isRecommenderLoading = false, - isRecommenderUpdating = false, - recommenderErrorMessage = details.message, - recommenderErrorRequestId = details.requestId, - ) - } - } - private fun refresh() { viewModelScope.launch { feedback.update { it.copy(isLoading = true, errorMessage = null, errorRequestId = null) } @@ -232,27 +98,10 @@ class ContentSettingsViewModel @Inject constructor( private data class ContentSettingsFeedback( val isLoading: Boolean = true, val isUpdating: Boolean = false, - val recommenderAvailable: Boolean = false, - val recommenderEnabled: Boolean = false, - val recommenderExperimental: Boolean = false, - val isRecommenderLoading: Boolean = false, - val isRecommenderUpdating: Boolean = false, - val recommenderErrorMessage: String? = null, - val recommenderErrorRequestId: String? = null, val errorMessage: String? = null, val errorRequestId: String? = null, ) -private fun ContentSettingsFeedback.withoutRecommender(loading: Boolean = false) = copy( - recommenderAvailable = false, - recommenderEnabled = false, - recommenderExperimental = false, - isRecommenderLoading = loading, - isRecommenderUpdating = false, - recommenderErrorMessage = null, - recommenderErrorRequestId = null, -) - private fun UserSettings.toContentSettingsState(feedback: ContentSettingsFeedback) = ContentSettingsState( isLoading = feedback.isLoading, @@ -265,13 +114,6 @@ private fun UserSettings.toContentSettingsState(feedback: ContentSettingsFeedbac hideShorts = hideShorts, hideSubscriptionLiveStreams = hideSubscriptionLiveStreams, supportsHideSubscriptionLiveStreams = supportsHideSubscriptionLiveStreams, - recommenderAvailable = feedback.recommenderAvailable, - recommenderEnabled = feedback.recommenderEnabled, - recommenderExperimental = feedback.recommenderExperimental, - isRecommenderLoading = feedback.isRecommenderLoading, - isRecommenderUpdating = feedback.isRecommenderUpdating, - recommenderErrorMessage = feedback.recommenderErrorMessage, - recommenderErrorRequestId = feedback.recommenderErrorRequestId, deArrowEnabled = deArrowEnabled, deArrowTitleMode = deArrowTitleMode, deArrowThumbnailMode = deArrowThumbnailMode, diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/RecommenderEntryRow.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/RecommenderEntryRow.kt deleted file mode 100644 index 8453eff2..00000000 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/content/RecommenderEntryRow.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.typetype.android.feature.settings.content - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp -import dev.typetype.android.R - -@Composable -internal fun RecommenderEntryRow(onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(role = Role.Button, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.algo_title), style = MaterialTheme.typography.bodyLarge) - Text( - stringResource(R.string.algo_entry_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) - } -} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderAlgorithmCard.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderAlgorithmCard.kt deleted file mode 100644 index d8a92e04..00000000 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderAlgorithmCard.kt +++ /dev/null @@ -1,89 +0,0 @@ -package dev.typetype.android.feature.settings.recommender - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.selectable -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp -import dev.typetype.android.R -import dev.typetype.android.domain.recommendations.AlgorithmInfo - -@Composable -internal fun AlgorithmCard(algorithm: AlgorithmInfo, selected: Boolean, onSelect: () -> Unit) { - Card( - modifier = Modifier - .fillMaxWidth() - .selectable(selected = selected, onClick = onSelect, role = Role.RadioButton), - colors = CardDefaults.cardColors( - containerColor = if (selected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = selected, onClick = null) - Text( - algorithm.name, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 8.dp), - ) - } - Text(algorithm.description, style = MaterialTheme.typography.bodyMedium) - if (selected) { - GuideLine(R.string.algo_guide_for_you_if, algorithm.forYouIf) - GuideLine(R.string.algo_guide_you_see, algorithm.youWillSee) - GuideLine(R.string.algo_guide_good_to_know, algorithm.goodToKnow) - TraitMeter(R.string.algo_trait_personal, algorithm.traits.personal) - TraitMeter(R.string.algo_trait_discovery, algorithm.traits.discovery) - TraitMeter(R.string.algo_trait_variety, algorithm.traits.variety) - } - } - } -} - -@Composable -private fun GuideLine(labelRes: Int, text: String) { - if (text.isBlank()) return - Column { - Text(stringResource(labelRes), style = MaterialTheme.typography.labelLarge) - Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun TraitMeter(labelRes: Int, level: Int) { - val clamped = level.coerceIn(0, MAX_LEVEL) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text(stringResource(labelRes), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(96.dp)) - LinearProgressIndicator( - progress = { clamped / MAX_LEVEL.toFloat() }, - modifier = Modifier.weight(1f), - ) - Text(stringResource(LEVELS[clamped]), style = MaterialTheme.typography.labelMedium) - } -} - -private const val MAX_LEVEL = 3 -private val LEVELS = listOf( - R.string.algo_level_none, - R.string.algo_level_low, - R.string.algo_level_medium, - R.string.algo_level_high, -) diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderAlgorithmSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderAlgorithmSection.kt new file mode 100644 index 00000000..5f31c6b8 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderAlgorithmSection.kt @@ -0,0 +1,132 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.NotificationsNone +import androidx.compose.material.icons.outlined.Radar +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.recommendations.AlgorithmInfo + +internal fun LazyListScope.algorithmSection(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + val current = state.selectedAlgorithm ?: return + item(key = "choice") { + RecommenderGroup( + label = stringResource(R.string.algo_choice_label), + icon = Icons.AutoMirrored.Outlined.ViewList, + help = stringResource(R.string.algo_help_choice), + ) { + state.algorithms.forEach { algorithm -> + AlgorithmChoiceRow(algorithm, algorithm.id == current.id) { + onAction(RecommenderSettingsAction.ChooseAlgorithm(algorithm.id)) + } + } + RecommenderRow( + label = stringResource(R.string.algo_guide_link), + description = stringResource(R.string.algo_guide_link_description), + icon = Icons.Outlined.Explore, + onClick = { onAction(RecommenderSettingsAction.Open(RecommenderSection.Guide)) }, + divider = false, + ) { Chevron() } + } + } + item(key = "featured-${current.id}") { + RecommenderGroup(label = stringResource(R.string.algo_settings_of, current.name), icon = Icons.Outlined.Tune) { + current.parameters.filter { it.featured }.forEach { parameter -> + ParameterRow(parameter, state.valueOf(parameter)) { value -> + onAction(RecommenderSettingsAction.SetParameter(parameter.id, value)) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + RecommenderRow( + label = stringResource(R.string.algo_all_settings), + description = pluralStringResource(R.plurals.algo_all_settings_description, current.parameters.size, current.parameters.size), + onClick = { onAction(RecommenderSettingsAction.Open(RecommenderSection.Details)) }, + divider = false, + ) { Chevron() } + } + } + item(key = "transparency") { + val uriHandler = LocalUriHandler.current + val uses = current.uses.map { dataLabel(it) }.joinToString(", ") + RecommenderGroup(label = stringResource(R.string.algo_transparency_label), icon = Icons.Outlined.Code) { + RecommenderRow( + label = stringResource(R.string.algo_uses_label), + description = uses.ifEmpty { stringResource(R.string.algo_uses_nothing) }, + icon = Icons.Outlined.Storage, + divider = current.sourceUrl != null, + ) + current.sourceUrl?.let { url -> + RecommenderRow( + label = stringResource(R.string.algo_code_label), + description = stringResource(R.string.algo_code_description), + icon = Icons.Outlined.Code, + onClick = { uriHandler.openUri(url) }, + divider = false, + ) { Chevron() } + } + } + } +} + +@Composable +private fun AlgorithmChoiceRow(algorithm: AlgorithmInfo, selected: Boolean, onSelect: () -> Unit) { + val tint by animateColorAsState( + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + label = "algorithm-icon", + ) + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .selectable(selected = selected, role = Role.RadioButton, onClick = onSelect) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(algorithmIcon(algorithm), contentDescription = null, tint = tint, modifier = Modifier.size(22.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(algorithm.name, style = MaterialTheme.typography.bodyLarge) + Text(algorithm.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + RadioButton(selected = selected, onClick = null) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } +} + +internal fun algorithmIcon(algorithm: AlgorithmInfo): ImageVector = when { + "history" in algorithm.uses -> Icons.Outlined.Radar + "trending" in algorithm.sources -> Icons.Outlined.Explore + "subscriptions" in algorithm.uses -> Icons.Outlined.NotificationsNone + else -> Icons.AutoMirrored.Outlined.ViewList +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderBlocks.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderBlocks.kt new file mode 100644 index 00000000..22e94c7d --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderBlocks.kt @@ -0,0 +1,173 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.typetype.android.R + +@Composable +internal fun RecommenderGroup( + label: String, + icon: ImageVector? = null, + help: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + var helpShown by rememberSaveable(label) { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(min = 40.dp)) { + if (icon != null) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + } + Text( + label, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(start = if (icon != null) 8.dp else 0.dp).weight(1f, fill = false), + ) + if (help != null) InfoButton(label, helpShown) { helpShown = !helpShown } + } + AnimatedVisibility(visible = helpShown && help != null) { + Text(help.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 4.dp), content = content) + } + } +} + +@Composable +internal fun RecommenderRow( + label: String, + description: String? = null, + icon: ImageVector? = null, + help: String? = null, + onClick: (() -> Unit)? = null, + divider: Boolean = true, + trailing: @Composable RowScope.() -> Unit = {}, +) { + var helpShown by rememberSaveable(label) { mutableStateOf(false) } + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (icon != null) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f, fill = false)) + if (help != null) InfoButton(label, helpShown) { helpShown = !helpShown } + } + if (!description.isNullOrBlank()) { + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + AnimatedVisibility(visible = helpShown && help != null) { + Text(help.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + trailing() + } + if (divider) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } +} + +@Composable +internal fun Chevron() { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@Composable +private fun InfoButton(label: String, expanded: Boolean, onToggle: () -> Unit) { + IconButton(onClick = onToggle, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Outlined.Info, + contentDescription = stringResource(R.string.algo_more_info, label), + tint = if (expanded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } +} + +@Composable +internal fun TextAction(text: String, onClick: () -> Unit, enabled: Boolean = true, danger: Boolean = false) { + TextButton(onClick = onClick, enabled = enabled) { + Text(text, color = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary) + } +} + +@Composable +internal fun RecommenderNote(text: String) { + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), + ) +} + +@Composable +internal fun RecommenderNotice(text: String, action: String? = null, onAction: () -> Unit = {}) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (action != null) OutlinedButton(onClick = onAction) { Text(action) } + } +} + +@Composable +internal fun LoadFailed(onRetry: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(4.dp)) { + Text(stringResource(R.string.algo_profile_unavailable), style = MaterialTheme.typography.bodyMedium) + OutlinedButton(onClick = onRetry) { Text(stringResource(R.string.algo_retry)) } + } +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDataDetail.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDataDetail.kt new file mode 100644 index 00000000..7ed1c1f0 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDataDetail.kt @@ -0,0 +1,143 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +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.alpha +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.SkeletonImage +import dev.typetype.android.core.ui.components.TypeTypeSwitch +import dev.typetype.android.domain.recommendations.DataCategory +import dev.typetype.android.domain.recommendations.DataItem + +@OptIn(ExperimentalLayoutApi::class) +internal fun LazyListScope.dataDetail( + category: DataCategory, + collectHistory: Boolean, + onAction: (RecommenderSettingsAction) -> Unit, +) { + val excludable = category.id in EXCLUDABLE + item(key = "detail-${category.id}") { + RecommenderGroup(label = dataLabel(category.id), icon = Icons.Outlined.Tune) { + if (category.collect != null) { + RecommenderRow( + label = stringResource(R.string.algo_collect), + description = stringResource(R.string.algo_collect_description), + icon = Icons.Outlined.Inbox, + help = stringResource(R.string.algo_help_collect), + ) { + TypeTypeSwitch(checked = collectHistory, onCheckedChange = { onAction(RecommenderSettingsAction.SetCollect(it)) }) + } + } + RecommenderRow( + label = stringResource(R.string.algo_use_for_home), + description = stringResource(R.string.algo_use_for_home_description), + icon = Icons.Outlined.Home, + help = stringResource(R.string.algo_help_use), + divider = excludable, + ) { + TypeTypeSwitch(checked = category.used, onCheckedChange = { onAction(RecommenderSettingsAction.ToggleUse(category.id)) }) + } + if (excludable) { + RecommenderRow( + label = stringResource(R.string.algo_keep_for), + description = stringResource(R.string.algo_keep_for_description), + icon = Icons.Outlined.Timer, + help = stringResource(R.string.algo_help_retention), + divider = false, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp)) { + RETENTION.forEach { days -> + FilterChip( + selected = category.retentionDays == days, + onClick = { onAction(RecommenderSettingsAction.SetRetention(category.id, days)) }, + label = { Text(retentionLabel(days)) }, + ) + } + } + } + } + } + item(key = "items-${category.id}") { + RecommenderGroup( + label = stringResource(R.string.algo_items_label, category.items.size, formatNumber(category.count.toDouble())), + icon = Icons.AutoMirrored.Outlined.ViewList, + ) { + if (category.items.isEmpty()) RecommenderNote(stringResource(R.string.algo_empty)) + category.items.forEachIndexed { index, item -> + DataItemRow(item, excludable) { onAction(RecommenderSettingsAction.ToggleExcluded(item.id)) } + if (index < category.items.lastIndex) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + } + } +} + +@Composable +private fun DataItemRow(item: DataItem, excludable: Boolean, onToggle: () -> Unit) { + val fade by animateFloatAsState(if (item.excluded) 0.4f else 1f, label = "excluded") + val details = listOfNotNull( + item.channel, + ago(item.at).ifEmpty { null }, + if (item.viewings > 1) pluralStringResource(R.plurals.algo_viewings, item.viewings, item.viewings) else null, + item.watchedPercent?.let { stringResource(R.string.algo_watched_share, it) }, + if (item.excluded) stringResource(R.string.algo_excluded) else null, + ).joinToString(" · ") + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (!item.thumbnailUrl.isNullOrBlank()) { + SkeletonImage( + imageUrl = item.thumbnailUrl.orEmpty(), + contentDescription = null, + modifier = Modifier.width(96.dp).aspectRatio(16f / 9f).alpha(fade), + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + item.title, + style = MaterialTheme.typography.bodyMedium.copy(textDecoration = if (item.excluded) TextDecoration.LineThrough else null), + color = if (item.excluded) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (details.isNotEmpty()) { + Text(details, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + if (excludable) { + TextAction(stringResource(if (item.excluded) R.string.algo_include else R.string.algo_exclude), onToggle) + } + } +} + +private val RETENTION: List = listOf(7, 30, 90, 180, 365, null) +private val EXCLUDABLE = setOf("history", "shorts_history", "favorites") diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDataSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDataSection.kt new file mode 100644 index 00000000..323863e7 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDataSection.kt @@ -0,0 +1,114 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.TypeTypeSwitch +import dev.typetype.android.domain.recommendations.DataCategory + +internal fun LazyListScope.dataSection(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + val categories = when (val data = state.data) { + Loadable.Failed -> { + item { LoadFailed { onAction(RecommenderSettingsAction.Retry) } } + return + } + is Loadable.Loaded -> data.value + else -> { + item { RecommenderNote(stringResource(R.string.algo_loading)) } + return + } + } + val open = categories.firstOrNull { it.id == state.openCategory } + if (open != null) { + dataDetail(open, state.collectHistory, onAction) + return + } + item(key = "data") { + RecommenderGroup( + label = stringResource(R.string.algo_data_label), + icon = Icons.Outlined.Storage, + help = stringResource(R.string.algo_help_stored_data), + ) { + categories.forEachIndexed { index, category -> + CategoryRow(category, state, divider = index < categories.lastIndex, onAction = onAction) + } + } + } +} + +@Composable +private fun CategoryRow( + category: DataCategory, + state: RecommenderSettingsState, + divider: Boolean, + onAction: (RecommenderSettingsAction) -> Unit, +) { + val current = state.selectedAlgorithm + val name = current?.name.orEmpty() + val read = current?.uses?.contains(category.id) == true + val status = when { + category.collect == false -> stringResource(R.string.algo_not_collected) + category.used && read -> stringResource(R.string.algo_used_by, name) + category.used -> stringResource(R.string.algo_not_needed_by, name) + else -> stringResource(R.string.algo_kept_not_used) + } + val label = dataLabel(category.id) + Column { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = 64.dp) + .clickable(role = Role.Button) { onAction(RecommenderSettingsAction.OpenCategory(category.id)) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(dataIcon(category.id), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.bodyLarge) + Text( + "${itemCount(category.count)} · $status", + style = MaterialTheme.typography.bodySmall, + color = if (category.used && read) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + UseSwitch(label, category.used) { onAction(RecommenderSettingsAction.ToggleUse(category.id)) } + } + if (divider) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } +} + +@Composable +internal fun UseSwitch(label: String, checked: Boolean, onToggle: () -> Unit) { + val description = stringResource(R.string.algo_use_category, label) + TypeTypeSwitch( + checked = checked, + onCheckedChange = { onToggle() }, + modifier = Modifier.padding(start = 8.dp).semantics { contentDescription = description }, + ) +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDetailsSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDetailsSection.kt new file mode 100644 index 00000000..2886e46d --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDetailsSection.kt @@ -0,0 +1,164 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import dev.typetype.android.R + +internal fun LazyListScope.detailsSection( + state: RecommenderSettingsState, + open: Set, + onToggle: (String) -> Unit, + onAction: (RecommenderSettingsAction) -> Unit, +) { + val current = state.selectedAlgorithm ?: return + item(key = "details-${current.id}") { + RecommenderGroup(label = stringResource(R.string.algo_all_settings_of, current.name), icon = Icons.Outlined.Tune) { + val groups = current.parameters.groupBy { it.group }.entries.toList() + groups.forEachIndexed { index, (group, parameters) -> + val changed = parameters.count { !it.isDefault(state.valueOf(it)) } + val text = groupText(group) + RecommenderFold( + icon = text.icon, + label = stringResource(text.title), + description = stringResource(text.help), + summary = if (changed > 0) { + pluralStringResource(R.plurals.algo_changed_count, changed, changed) + } else { + pluralStringResource(R.plurals.algo_settings_count, parameters.size, parameters.size) + }, + highlightSummary = changed > 0, + open = group in open, + onToggle = { onToggle(group) }, + divider = index < groups.lastIndex, + ) { + parameters.forEach { parameter -> + ParameterRow( + parameter = parameter, + value = state.valueOf(parameter), + showDefault = true, + onRestore = { onAction(RecommenderSettingsAction.RestoreParameter(parameter.id)) }, + ) { value -> onAction(RecommenderSettingsAction.SetParameter(parameter.id, value)) } + } + } + } + } + } + item(key = "last-home") { + RecommenderGroup( + label = stringResource(R.string.algo_last_home_label), + icon = Icons.Outlined.Home, + help = stringResource(R.string.algo_help_last_home), + ) { + LastHome(state, open = LAST_HOME in open, onToggle = { onToggle(LAST_HOME) }, onRetry = { onAction(RecommenderSettingsAction.Retry) }) + } + } + item(key = "reset") { + var confirming by rememberSaveable { mutableStateOf(false) } + RecommenderGroup(label = stringResource(R.string.algo_reset_all, current.name), icon = Icons.Outlined.RestartAlt) { + RecommenderRow(label = stringResource(R.string.algo_reset_description), divider = false) { + TextAction(stringResource(R.string.algo_restore), { confirming = true }) + } + } + if (confirming) { + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text(stringResource(R.string.algo_reset_question, current.name)) }, + text = { Text(stringResource(R.string.algo_reset_description)) }, + confirmButton = { + TextButton(onClick = { + onAction(RecommenderSettingsAction.ResetParameters) + confirming = false + }) { Text(stringResource(R.string.algo_restore)) } + }, + dismissButton = { TextButton(onClick = { confirming = false }) { Text(stringResource(R.string.algo_cancel)) } }, + ) + } + } +} + +@Composable +internal fun RecommenderFold( + icon: ImageVector, + label: String, + description: String, + summary: String, + open: Boolean, + onToggle: () -> Unit, + highlightSummary: Boolean = false, + divider: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + val turn by animateFloatAsState(if (open) 180f else 0f, label = "fold") + val expandedText = stringResource(if (open) R.string.algo_expanded else R.string.algo_collapsed) + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .clickable(role = Role.Button, onClick = onToggle) + .semantics { stateDescription = expandedText } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + icon, + contentDescription = null, + tint = if (open) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.bodyLarge) + Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + summary, + style = MaterialTheme.typography.labelMedium, + color = if (highlightSummary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.Outlined.ExpandMore, contentDescription = null, modifier = Modifier.rotate(turn)) + } + AnimatedVisibility(visible = open) { + Column(modifier = Modifier.padding(start = 32.dp, bottom = 8.dp), content = content) + } + if (divider) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } +} + +private const val LAST_HOME = "last-home" diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDonut.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDonut.kt new file mode 100644 index 00000000..00b84082 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderDonut.kt @@ -0,0 +1,169 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +internal data class DonutPart(val id: String, val label: String, val value: Double, val neutral: Boolean = false) + +@Composable +internal fun Donut( + parts: List, + centerValue: Int, + centerLabel: String, + unit: (@Composable (Int) -> String)? = null, + onSelect: ((String) -> Unit)? = null, +) { + val shades = donutShades() + val neutral = MaterialTheme.colorScheme.outlineVariant + val colors = parts.mapIndexed { index, part -> if (part.neutral) neutral else shades[index % shades.size] } + val total = parts.sumOf { it.value.coerceAtLeast(0.0) } + val shares = parts.map { if (total > 0) it.value.coerceAtLeast(0.0) / total else 0.0 } + var active by remember { mutableStateOf(null) } + val drawn = remember { Animatable(0f) } + LaunchedEffect(Unit) { drawn.animateTo(1f, tween(DRAW_MS, easing = FastOutSlowInEasing)) } + val track = MaterialTheme.colorScheme.surfaceContainerHighest + val focusedIndex = parts.indexOfFirst { it.id == active } + Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 12.dp)) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) { + Canvas(modifier = Modifier.size(RING_SIZE)) { + val stroke = RING_WIDTH.toPx() + if (total <= 0) drawCircle(track, radius = (size.minDimension - stroke) / 2, style = Stroke(stroke)) + val gap = if (shares.count { it > 0 } > 1) GAP_DEGREES else 0f + var start = -90f + shares.forEachIndexed { index, share -> + val sweep = (share * 360f).toFloat() + if (sweep > 0f) { + val dimmed = active != null && index != focusedIndex + drawArc( + color = colors[index].copy(alpha = if (dimmed) 0.35f else 1f), + startAngle = start + gap / 2, + sweepAngle = ((sweep - gap).coerceAtLeast(0.5f)) * drawn.value, + useCenter = false, + topLeft = Offset(stroke / 2, stroke / 2), + size = Size(size.width - stroke, size.height - stroke), + style = Stroke(if (index == focusedIndex) stroke * 1.25f else stroke, cap = StrokeCap.Butt), + ) + } + start += sweep + } + } + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 40.dp)) { + val focused = parts.getOrNull(focusedIndex) + Text( + if (focused != null) formatPercent(shares[focusedIndex]) else formatNumber((centerValue * drawn.value).roundToInt().toDouble()), + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + ) + Text( + focused?.label ?: centerLabel, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Column { + parts.forEachIndexed { index, part -> + if (shares[index] <= 0.0) return@forEachIndexed + LegendRow( + part = part, + share = shares[index], + color = colors[index], + dimmed = active != null && active != part.id, + unit = unit, + onClick = { + if (onSelect != null) onSelect(part.id) else active = part.id.takeUnless { it == active } + }, + navigates = onSelect != null, + ) + } + } + } +} + +@Composable +private fun LegendRow( + part: DonutPart, + share: Double, + color: Color, + dimmed: Boolean, + unit: (@Composable (Int) -> String)?, + onClick: () -> Unit, + navigates: Boolean, +) { + val alpha by animateFloatAsState(if (dimmed) 0.45f else 1f, label = "legend") + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .alpha(alpha) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(10.dp).background(color, CircleShape)) + Text(part.label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (unit != null) { + Text(unit(part.value.toInt()), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(formatPercent(share), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (navigates) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } +} + +@Composable +private fun donutShades(): List { + val primary = MaterialTheme.colorScheme.primary + val surface = MaterialTheme.colorScheme.surface + return SHADE_MIX.map { lerp(primary, surface, it) } +} + +private val SHADE_MIX = listOf(0f, 0.25f, 0.45f, 0.62f, 0.12f, 0.35f, 0.55f) +private val RING_SIZE = 200.dp +private val RING_WIDTH = 22.dp +private const val GAP_DEGREES = 2.5f +private const val DRAW_MS = 700 diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderEditor.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderEditor.kt new file mode 100644 index 00000000..f859bc2e --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderEditor.kt @@ -0,0 +1,36 @@ +package dev.typetype.android.feature.settings.recommender + +import dev.typetype.android.domain.recommendations.RecommenderPreferences +import dev.typetype.android.domain.recommendations.RecommenderRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class RecommenderEditor( + private val scope: CoroutineScope, + private val state: MutableStateFlow, + private val repository: RecommenderRepository, + private val onSaved: () -> Unit, +) { + private var saveJob: Job? = null + + fun edit(transform: (RecommenderPreferences) -> RecommenderPreferences?) { + val updated = transform(state.value.preferences) ?: return + state.update { it.copy(preferences = updated, isSaving = true, saveFailed = false) } + saveJob?.cancel() + saveJob = scope.launch { + repository.save(updated).fold( + onSuccess = { saved -> + state.update { it.copy(preferences = saved, isSaving = false) } + onSaved() + }, + onFailure = { + val kept = repository.preferences().getOrNull() + state.update { it.copy(preferences = kept ?: it.preferences, isSaving = false, saveFailed = true) } + }, + ) + } + } +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderEdits.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderEdits.kt new file mode 100644 index 00000000..cf7deff5 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderEdits.kt @@ -0,0 +1,38 @@ +package dev.typetype.android.feature.settings.recommender + +import dev.typetype.android.domain.recommendations.RecommenderPreferences + +internal fun RecommenderPreferences.choose(algorithmId: String): RecommenderPreferences = + copy(algorithmId = algorithmId, parameters = emptyMap()) + +internal fun RecommenderPreferences.togglePinned(id: String): RecommenderPreferences = + copy(pinnedGroups = pinnedGroups.toggle(id), mutedGroups = mutedGroups - id) + +internal fun RecommenderPreferences.toggleMuted(id: String): RecommenderPreferences = + copy(mutedGroups = mutedGroups.toggle(id), pinnedGroups = pinnedGroups - id) + +internal fun RecommenderPreferences.toggleIgnored(id: String): RecommenderPreferences = + copy(ignoredSignals = ignoredSignals.toggle(id)) + +internal fun RecommenderPreferences.toggleUse(categoryId: String): RecommenderPreferences = + copy(unusedData = unusedData.toggle(categoryId)) + +internal fun RecommenderPreferences.toggleExcluded(itemId: String): RecommenderPreferences = + copy(excludedItems = excludedItems.toggle(itemId)) + +internal fun RecommenderPreferences.withRetention(categoryId: String, days: Int?): RecommenderPreferences = + copy(retentionDays = if (days == null) retentionDays - categoryId else retentionDays + (categoryId to days)) + +internal fun RecommenderPreferences.addInterest(text: String): RecommenderPreferences? { + val subject = text.trim().replace(WHITESPACE, " ").take(MAX_INTEREST_LENGTH) + if (subject.isEmpty() || customInterests.size >= MAX_CUSTOM_INTERESTS) return null + if (customInterests.any { it.equals(subject, ignoreCase = true) }) return null + return copy(customInterests = customInterests + subject) +} + +internal fun RecommenderPreferences.removeInterest(text: String): RecommenderPreferences = + copy(customInterests = customInterests.filterNot { it.equals(text, ignoreCase = true) }) + +private fun List.toggle(value: String): List = if (value in this) this - value else this + value + +private val WHITESPACE = Regex("\\s+") diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderFeedbackSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderFeedbackSection.kt new file mode 100644 index 00000000..b3867b30 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderFeedbackSection.kt @@ -0,0 +1,102 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Forum +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.SkeletonImage +import dev.typetype.android.domain.recommendations.FeedbackEntry +import java.text.DateFormat +import java.util.Date + +internal fun LazyListScope.feedbackSection(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + item { RecommenderNote(stringResource(R.string.algo_feedback_intro)) } + item(key = "feedback") { + RecommenderGroup(label = stringResource(R.string.algo_feedback_label), icon = Icons.Outlined.Forum) { + when (val loaded = state.feedback) { + Loadable.Failed -> LoadFailed { onAction(RecommenderSettingsAction.Retry) } + is Loadable.Loaded -> Column(modifier = Modifier.animateContentSize()) { + if (loaded.value.isEmpty()) RecommenderNote(stringResource(R.string.algo_feedback_empty)) + loaded.value.forEachIndexed { index, entry -> + FeedbackRow(entry, state) { onAction(RecommenderSettingsAction.RemoveFeedback(entry.id)) } + if (index < loaded.value.lastIndex) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + } + else -> RecommenderNote(stringResource(R.string.algo_loading)) + } + } + } +} + +@Composable +private fun FeedbackRow(entry: FeedbackEntry, state: RecommenderSettingsState, onRemove: () -> Unit) { + val verdict = stringResource( + when { + entry.kind == "video" -> R.string.algo_feedback_not_for_me + entry.verdict == "good" -> R.string.algo_feedback_good + else -> R.string.algo_feedback_bad + }, + ) + val subject = if (entry.kind == "video") { + entry.title ?: entry.videoUrl + } else { + entry.comment + } + val details = listOfNotNull( + entry.channelName.takeIf { entry.kind == "video" }, + entry.reason?.let { feedbackReason(it) }, + state.algorithmName(entry.algorithmId).takeIf { it.isNotBlank() }, + entry.createdAt.takeIf { it > 0 }?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) }, + ).joinToString(" · ") + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (!entry.thumbnailUrl.isNullOrBlank()) { + SkeletonImage( + imageUrl = entry.thumbnailUrl.orEmpty(), + contentDescription = null, + modifier = Modifier.width(88.dp).aspectRatio(16f / 9f), + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(verdict, style = MaterialTheme.typography.bodyLarge) + if (!subject.isNullOrBlank()) { + Text(subject, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + Text(details, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + TextAction(stringResource(R.string.algo_feedback_remove), onRemove) + } +} + +@Composable +private fun feedbackReason(reason: String): String? = when (reason) { + "seen" -> stringResource(R.string.home_algo_reason_seen) + "channel" -> stringResource(R.string.home_algo_reason_channel) + "topic" -> stringResource(R.string.home_algo_reason_topic) + "other" -> stringResource(R.string.home_algo_reason_other) + "repetitive" -> stringResource(R.string.home_algo_page_repetitive) + "unrelated" -> stringResource(R.string.home_algo_page_unrelated) + "old" -> stringResource(R.string.home_algo_page_old) + "missing" -> stringResource(R.string.home_algo_page_missing) + else -> null +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderGuideSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderGuideSection.kt new file mode 100644 index 00000000..a2b3e05f --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderGuideSection.kt @@ -0,0 +1,151 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +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.Row +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.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Lightbulb +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.recommendations.AlgorithmInfo + +internal fun LazyListScope.guideSection(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + item { RecommenderNote(stringResource(R.string.algo_guide_intro)) } + items(state.algorithms, key = { "guide-${it.id}" }) { algorithm -> + GuideCard(algorithm, current = algorithm.id == state.selectedAlgorithm?.id) { + onAction(RecommenderSettingsAction.ChooseAlgorithm(algorithm.id, announce = true)) + } + } +} + +@Composable +private fun GuideCard(algorithm: AlgorithmInfo, current: Boolean, onChoose: () -> Unit) { + var details by rememberSaveable(algorithm.id) { mutableStateOf(false) } + val border by animateColorAsState( + if (current) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + label = "guide-border", + ) + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(if (current) 2.dp else 1.dp, border), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon( + algorithmIcon(algorithm), + contentDescription = null, + tint = if (current) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + algorithm.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.weight(1f), + ) + if (current) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Text(stringResource(R.string.algo_guide_current), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + } + } else { + OutlinedButton(onClick = onChoose) { Text(stringResource(R.string.algo_guide_choose)) } + } + } + if (algorithm.forYouIf.isNotBlank()) Text(algorithm.forYouIf, style = MaterialTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TraitMeter(R.string.algo_trait_personal, algorithm.traits.personal, Modifier.weight(1f)) + TraitMeter(R.string.algo_trait_discovery, algorithm.traits.discovery, Modifier.weight(1f)) + TraitMeter(R.string.algo_trait_variety, algorithm.traits.variety, Modifier.weight(1f)) + } + val turn by animateFloatAsState(if (details) 180f else 0f, label = "guide-chevron") + TextButton(onClick = { details = !details }, contentPadding = PaddingValues(end = 8.dp)) { + Text(stringResource(if (details) R.string.algo_guide_less else R.string.algo_guide_details)) + Icon(Icons.Outlined.ExpandMore, contentDescription = null, modifier = Modifier.rotate(turn)) + } + AnimatedVisibility(visible = details) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + GuideEntry(Icons.Outlined.Visibility, R.string.algo_guide_you_see, algorithm.youWillSee) + GuideEntry(Icons.Outlined.Lightbulb, R.string.algo_guide_good_to_know, algorithm.goodToKnow) + val reads = algorithm.uses.map { dataLabel(it) }.joinToString(", ") + GuideEntry(Icons.Outlined.Storage, R.string.algo_guide_data, reads.ifEmpty { stringResource(R.string.algo_uses_nothing) }) + } + } + } + } +} + +@Composable +private fun GuideEntry(icon: ImageVector, term: Int, text: String) { + if (text.isBlank()) return + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(term), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(text, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +private fun TraitMeter(label: Int, level: Int, modifier: Modifier) { + val clamped = level.coerceIn(0, LEVEL_LABELS.lastIndex) + val levelText = stringResource(LEVEL_LABELS[clamped]) + var entered by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { entered = true } + Column(modifier = modifier.semantics { stateDescription = levelText }, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(label), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + (1..LEVEL_LABELS.lastIndex).forEach { step -> + val color by animateColorAsState( + if (entered && step <= clamped) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest, + animationSpec = tween(durationMillis = 400, delayMillis = step * 90), + label = "trait", + ) + Box(Modifier.width(20.dp).height(6.dp).background(color, RoundedCornerShape(3.dp))) + } + } + Text(levelText, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderHub.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderHub.kt new file mode 100644 index 00000000..b62d0d26 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderHub.kt @@ -0,0 +1,96 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.TypeTypeSwitch + +internal fun LazyListScope.recommenderHub(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + item(key = "intro") { RecommenderNote(stringResource(R.string.algo_subtitle)) } + item(key = "experimental") { ExperimentalCard(state, onAction) } + item(key = "sections") { + RecommenderGroup(label = stringResource(R.string.algo_sections_label)) { + RecommenderSection.entries.forEachIndexed { index, section -> + RecommenderRow( + label = stringResource(section.titleRes), + description = stringResource(section.descriptionRes), + icon = section.icon, + onClick = { onAction(RecommenderSettingsAction.Open(section)) }, + divider = index < RecommenderSection.entries.lastIndex, + ) { Chevron() } + } + } + } +} + +@Composable +private fun ExperimentalCard(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + val enabled = state.preferences.enabled + Surface( + shape = MaterialTheme.shapes.large, + color = lerp(MaterialTheme.colorScheme.surfaceContainerLow, MaterialTheme.colorScheme.primary, if (enabled) 0.12f else 0f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = if (enabled) 0.8f else 0.35f)), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .toggleable(value = enabled, role = Role.Switch) { onAction(RecommenderSettingsAction.SetEnabled(it)) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon(Icons.Outlined.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)), + ) { + Text( + stringResource(R.string.algo_experimental_badge).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + Text( + stringResource(R.string.algo_experimental_label), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + ) + Text(stringResource(R.string.algo_experimental_toggle), style = MaterialTheme.typography.bodyMedium) + AnimatedContent(targetState = enabled, label = "experimental-hint") { on -> + val hint = if (on) { + state.selectedAlgorithm?.let { stringResource(R.string.algo_experimental_on_with, it.name) } + ?: stringResource(R.string.algo_experimental_on_hint) + } else { + stringResource(R.string.algo_experimental_off_hint) + } + Text(hint, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + TypeTypeSwitch(checked = enabled, onCheckedChange = null) + } + } +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInsights.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInsights.kt new file mode 100644 index 00000000..23c88e04 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInsights.kt @@ -0,0 +1,123 @@ +package dev.typetype.android.feature.settings.recommender + +import dev.typetype.android.domain.feed.HomeFeedRepository +import dev.typetype.android.domain.recommendations.DataCategory +import dev.typetype.android.domain.recommendations.RecommenderFeedbackRepository +import dev.typetype.android.domain.recommendations.RecommenderRepository +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal enum class Insight { Home, Profile, Data, LastHome, Feedback } + +internal class RecommenderInsights( + private val scope: CoroutineScope, + private val state: MutableStateFlow, + private val repository: RecommenderRepository, + private val feedRepository: HomeFeedRepository, + private val feedbackRepository: RecommenderFeedbackRepository, +) { + private val jobs = mutableMapOf() + + fun ensure(section: RecommenderSection?) { + needs(section).forEach { insight -> + val current = read(insight) + if (current is Loadable.Idle || current is Loadable.Failed) load(insight, silent = false) + } + } + + fun refresh(section: RecommenderSection?) { + val needed = needs(section) + Insight.entries.forEach { insight -> + when { + insight == Insight.Feedback -> Unit + insight in needed -> load(insight, silent = read(insight) is Loadable.Loaded<*>) + else -> { + jobs.remove(insight)?.cancel() + clear(insight) + } + } + } + } + + fun updateData(transform: (List) -> List) = state.update { current -> + val categories = current.data.value ?: return@update current + current.copy(data = Loadable.Loaded(transform(categories))) + } + + fun removeFeedback(id: String) { + state.update { current -> + val entries = current.feedback.value ?: return@update current + current.copy(feedback = Loadable.Loaded(entries.filterNot { it.id == id })) + } + scope.launch { + feedbackRepository.withdraw(id).onFailure { load(Insight.Feedback, silent = true) } + } + } + + private fun needs(section: RecommenderSection?): List { + val enabled = state.value.preferences.enabled + return when (section) { + RecommenderSection.Overview -> if (enabled) listOf(Insight.Home, Insight.Profile) else emptyList() + RecommenderSection.Profile -> if (enabled) listOf(Insight.Profile) else emptyList() + RecommenderSection.Data -> listOf(Insight.Data) + RecommenderSection.Details -> listOf(Insight.LastHome) + RecommenderSection.Feedback -> listOf(Insight.Feedback) + else -> emptyList() + } + } + + private fun load(insight: Insight, silent: Boolean) { + val locale = Locale.getDefault().toLanguageTag() + when (insight) { + Insight.Home -> launch(insight, silent, { s, v -> s.copy(home = v) }) { + feedRepository.loadHomeRecommendations(limit = HOME_PAGE_SIZE).map { HomeComposition(it.algorithm, it.rows) } + } + Insight.Profile -> launch(insight, silent, { s, v -> s.copy(profile = v) }) { repository.profile(locale) } + Insight.Data -> launch(insight, silent, { s, v -> s.copy(data = v) }) { repository.data() } + Insight.LastHome -> launch(insight, silent, { s, v -> s.copy(lastHome = v) }) { repository.lastHome() } + Insight.Feedback -> launch(insight, silent, { s, v -> s.copy(feedback = v) }) { repository.feedback() } + } + } + + private fun launch( + insight: Insight, + silent: Boolean, + put: (RecommenderSettingsState, Loadable) -> RecommenderSettingsState, + fetch: suspend () -> Result, + ) { + jobs.remove(insight)?.cancel() + if (!silent) state.update { put(it, Loadable.Loading) } + jobs[insight] = scope.launch { + val result = fetch().fold(onSuccess = { Loadable.Loaded(it) }, onFailure = { Loadable.Failed }) + state.update { put(it, result) } + } + } + + private fun read(insight: Insight): Loadable<*> = with(state.value) { + when (insight) { + Insight.Home -> home + Insight.Profile -> profile + Insight.Data -> data + Insight.LastHome -> lastHome + Insight.Feedback -> feedback + } + } + + private fun clear(insight: Insight) = state.update { + when (insight) { + Insight.Home -> it.copy(home = Loadable.Idle) + Insight.Profile -> it.copy(profile = Loadable.Idle) + Insight.Data -> it.copy(data = Loadable.Idle) + Insight.LastHome -> it.copy(lastHome = Loadable.Idle) + Insight.Feedback -> it.copy(feedback = Loadable.Idle) + } + } + + private companion object { + const val HOME_PAGE_SIZE = 30 + } +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInterestCard.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInterestCard.kt new file mode 100644 index 00000000..e08d7c63 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInterestCard.kt @@ -0,0 +1,204 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +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.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.ChannelAvatar +import dev.typetype.android.core.ui.components.SkeletonImage +import dev.typetype.android.domain.recommendations.InterestGroup +import dev.typetype.android.domain.recommendations.ProfileChannel + +@Composable +internal fun InterestCard( + group: InterestGroup, + state: RecommenderSettingsState, + highlighted: Boolean, + onAction: (RecommenderSettingsAction) -> Unit, +) { + val preferences = state.preferences + val ignored = group.id in preferences.ignoredSignals + val pinned = group.pinned || group.id in preferences.pinnedGroups + val muted = group.muted || group.id in preferences.mutedGroups + val fade by animateFloatAsState(if (muted || ignored) 0.5f else 1f, label = "interest") + val border by animateColorAsState( + if (highlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerLow, + label = "interest-border", + ) + val status = listOfNotNull( + if (group.custom) stringResource(R.string.algo_interest_yours) else basedOn(group.basedOn), + if (pinned && !group.custom) stringResource(R.string.algo_pinned) else null, + if (muted) stringResource(R.string.algo_muted) else null, + if (ignored) stringResource(R.string.algo_ignored) else null, + ).joinToString(" · ") + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(2.dp, border), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(start = 14.dp, top = 6.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f).alpha(fade), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(group.label, style = MaterialTheme.typography.bodyLarge) + Text(status, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + InterestMenu(group, pinned, muted, ignored, onAction) + } + val examples = group.examples.filter { !it.thumbnailUrl.isNullOrBlank() }.take(MAX_EXAMPLES) + if (examples.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.alpha(fade)) { + examples.forEach { example -> + SkeletonImage( + imageUrl = example.thumbnailUrl.orEmpty(), + contentDescription = example.title, + modifier = Modifier.width(72.dp).aspectRatio(16f / 9f), + ) + } + } + } + } + } +} + +@Composable +private fun InterestMenu( + group: InterestGroup, + pinned: Boolean, + muted: Boolean, + ignored: Boolean, + onAction: (RecommenderSettingsAction) -> Unit, +) { + if (group.custom) { + IconButton(onClick = { onAction(RecommenderSettingsAction.RemoveInterest(group.label)) }) { + Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.algo_interest_remove_named, group.label)) + } + return + } + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.algo_interest_actions, group.label)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + if (group.pinnable) { + MenuItem(if (pinned) R.string.algo_unpin else R.string.algo_pin, Icons.Outlined.PushPin) { + open = false + onAction(RecommenderSettingsAction.TogglePinned(group.id)) + } + } + MenuItem(if (muted) R.string.algo_show else R.string.algo_hide, if (muted) Icons.Outlined.Visibility else Icons.Outlined.VisibilityOff) { + open = false + onAction(RecommenderSettingsAction.ToggleMuted(group.id)) + } + MenuItem(if (ignored) R.string.algo_count_again else R.string.algo_ignore, Icons.Outlined.Block) { + open = false + onAction(RecommenderSettingsAction.ToggleIgnored(group.id)) + } + } + } +} + +@Composable +private fun MenuItem(label: Int, icon: ImageVector, onClick: () -> Unit) { + DropdownMenuItem( + text = { Text(stringResource(label)) }, + leadingIcon = { Icon(icon, contentDescription = null) }, + onClick = onClick, + ) +} + +@Composable +internal fun ChannelChip(channel: ProfileChannel, ignored: Boolean, onClick: () -> Unit) { + val fade by animateFloatAsState(if (ignored) 0.4f else 1f, label = "channel") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .width(76.dp) + .alpha(fade) + .clickable(role = Role.Button, onClick = onClick) + .padding(vertical = 4.dp), + ) { + ChannelAvatar(avatarUrl = channel.avatarUrl.orEmpty(), name = channel.name, size = 56.dp) + Text( + channel.name, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChannelSheet(channel: ProfileChannel, ignored: Boolean, onToggle: () -> Unit, onDismiss: () -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(start = 24.dp, end = 24.dp, bottom = 24.dp), + ) { + ChannelAvatar(avatarUrl = channel.avatarUrl.orEmpty(), name = channel.name, size = 72.dp) + Text(channel.name, style = MaterialTheme.typography.titleMedium) + Text(basedOn(channel.basedOn), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedButton( + onClick = { + onToggle() + onDismiss() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(if (ignored) R.string.algo_count_again else R.string.algo_ignore), + color = if (ignored) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + } + } + } +} + +private const val MAX_EXAMPLES = 3 diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInterestsSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInterestsSection.kt deleted file mode 100644 index b97fb531..00000000 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderInterestsSection.kt +++ /dev/null @@ -1,130 +0,0 @@ -package dev.typetype.android.feature.settings.recommender - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.InputChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.typetype.android.R -import dev.typetype.android.core.ui.components.SettingsSectionHeader -import dev.typetype.android.core.ui.components.SwitchRow - -@OptIn(ExperimentalLayoutApi::class) -internal fun LazyListScope.recommenderInterests( - state: RecommenderSettingsState, - onAction: (RecommenderSettingsAction) -> Unit, -) { - val preferences = state.preferences - item { SettingsSectionHeader(stringResource(R.string.algo_interests_label)) } - item(key = "custom-interests") { - var draft by rememberSaveable { mutableStateOf("") } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.algo_interest_add_hint), style = MaterialTheme.typography.bodySmall) - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - preferences.customInterests.forEach { interest -> - InputChip( - selected = true, - onClick = { onAction(RecommenderSettingsAction.RemoveInterest(interest)) }, - label = { Text(interest) }, - trailingIcon = { - Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.algo_interest_remove)) - }, - ) - } - } - if (state.canAddInterest) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = draft, - onValueChange = { draft = it.take(MAX_INTEREST_LENGTH) }, - placeholder = { Text(stringResource(R.string.algo_interest_add_placeholder)) }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - Button( - onClick = { - onAction(RecommenderSettingsAction.AddInterest(draft)) - draft = "" - }, - enabled = draft.isNotBlank(), - ) { Text(stringResource(R.string.algo_interest_add)) } - } - } else { - Text( - pluralStringResource(R.plurals.algo_interest_full, MAX_CUSTOM_INTERESTS, MAX_CUSTOM_INTERESTS), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - val groups = state.interests.sortedByDescending { it.weight }.take(VISIBLE_GROUPS) - items(groups, key = { "interest-${it.id}" }) { group -> - val pinned = group.id in preferences.pinnedGroups - val muted = group.id in preferences.mutedGroups - Column(modifier = Modifier.fillMaxWidth()) { - Text(group.label, style = MaterialTheme.typography.bodyLarge) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = pinned, - onClick = { onAction(RecommenderSettingsAction.TogglePinned(group.id)) }, - label = { Text(stringResource(R.string.algo_pin)) }, - ) - FilterChip( - selected = muted, - onClick = { onAction(RecommenderSettingsAction.ToggleMuted(group.id)) }, - label = { Text(stringResource(R.string.algo_hide)) }, - ) - } - } - } -} - -internal fun LazyListScope.recommenderData( - state: RecommenderSettingsState, - onAction: (RecommenderSettingsAction) -> Unit, -) { - if (state.families.isEmpty()) return - item { SettingsSectionHeader(stringResource(R.string.algo_data_label)) } - item { Text(stringResource(R.string.algo_data_intro), style = MaterialTheme.typography.bodySmall) } - items(state.families, key = { "family-${it.id}" }) { family -> - SwitchRow( - title = stringResource(familyLabel(family.id)), - subtitle = pluralStringResource(R.plurals.algo_data_count, family.count, family.count), - checked = family.used, - onCheckedChange = { onAction(RecommenderSettingsAction.SetFamilyUsed(family.id, it)) }, - ) - } -} - -private fun familyLabel(id: String): Int = when (id) { - "history" -> R.string.algo_cat_history - "shorts_history" -> R.string.algo_cat_shorts_history - "favorites" -> R.string.algo_cat_favorites - "subscriptions" -> R.string.algo_cat_subscriptions - "blocked" -> R.string.algo_cat_blocked - "not_interested" -> R.string.algo_cat_not_interested - else -> R.string.algo_cat_other -} - -private const val VISIBLE_GROUPS = 15 diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderLabels.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderLabels.kt new file mode 100644 index 00000000..7bc8b417 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderLabels.kt @@ -0,0 +1,139 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Casino +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.HourglassEmpty +import androidx.compose.material.icons.outlined.Inbox +import androidx.compose.material.icons.outlined.Movie +import androidx.compose.material.icons.outlined.NotificationsNone +import androidx.compose.material.icons.outlined.Scale +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.ThumbDown +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.Tv +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import dev.typetype.android.R +import dev.typetype.android.domain.recommendations.AlgorithmParameter +import java.text.NumberFormat +import kotlin.math.roundToInt + +@Composable +internal fun dataLabel(id: String): String = when (id) { + "history" -> stringResource(R.string.algo_cat_history) + "shorts_history" -> stringResource(R.string.algo_cat_shorts_history) + "events" -> stringResource(R.string.algo_cat_events) + "search" -> stringResource(R.string.algo_cat_search) + "favorites" -> stringResource(R.string.algo_cat_favorites) + "watch_later" -> stringResource(R.string.algo_cat_watch_later) + "subscriptions" -> stringResource(R.string.algo_cat_subscriptions) + "blocked" -> stringResource(R.string.algo_cat_blocked) + "not_interested" -> stringResource(R.string.algo_cat_not_interested) + "shown" -> stringResource(R.string.algo_cat_shown) + else -> id +} + +internal fun dataIcon(id: String): ImageVector = when (id) { + "history" -> Icons.Outlined.Tv + "shorts_history" -> Icons.Outlined.Movie + "favorites" -> Icons.Outlined.FavoriteBorder + "subscriptions" -> Icons.Outlined.NotificationsNone + "blocked" -> Icons.Outlined.Block + "not_interested" -> Icons.Outlined.ThumbDown + else -> Icons.Outlined.Storage +} + +@Composable +internal fun reasonLabel(kind: String): String = stringResource( + when (kind) { + "watched", "same_channel" -> R.string.algo_part_recent + "continue" -> R.string.algo_part_continue + "watch_again" -> R.string.algo_part_watch_again + "subscriptions", "new_from_subscriptions" -> R.string.algo_part_subscriptions + "topic" -> R.string.algo_part_topics + "discovery" -> R.string.algo_part_discovery + "trending" -> R.string.algo_part_trending + else -> R.string.algo_part_recommended + }, +) + +@Composable +internal fun dropLabel(why: String, count: Int): String = when (why) { + "seen" -> pluralStringResource(R.plurals.algo_drop_seen, count, count) + "blocked" -> pluralStringResource(R.plurals.algo_drop_blocked, count, count) + "channel_cap" -> pluralStringResource(R.plurals.algo_drop_channel_cap, count, count) + "muted" -> pluralStringResource(R.plurals.algo_drop_muted, count, count) + "excluded" -> pluralStringResource(R.plurals.algo_drop_excluded, count, count) + "not_interested" -> pluralStringResource(R.plurals.algo_drop_not_interested, count, count) + "short" -> pluralStringResource(R.plurals.algo_drop_short, count, count) + "obscure" -> pluralStringResource(R.plurals.algo_drop_obscure, count, count) + else -> "$count $why" +} + +internal data class GroupText(val title: Int, val help: Int, val icon: ImageVector) + +internal fun groupText(group: String): GroupText = when (group) { + "sources" -> GroupText(R.string.algo_group_sources, R.string.algo_group_sources_help, Icons.Outlined.Inbox) + "weights" -> GroupText(R.string.algo_group_weights, R.string.algo_group_weights_help, Icons.Outlined.Scale) + "decay" -> GroupText(R.string.algo_group_decay, R.string.algo_group_decay_help, Icons.Outlined.HourglassEmpty) + "rows" -> GroupText(R.string.algo_group_rows, R.string.algo_group_rows_help, Icons.AutoMirrored.Outlined.ViewList) + "exploration" -> GroupText(R.string.algo_group_exploration, R.string.algo_group_exploration_help, Icons.Outlined.Explore) + "randomness" -> GroupText(R.string.algo_group_randomness, R.string.algo_group_randomness_help, Icons.Outlined.Casino) + else -> GroupText(R.string.algo_group_other, R.string.algo_group_other_help, Icons.Outlined.Tune) +} + +@Composable +internal fun ago(at: Long, now: Long = System.currentTimeMillis()): String { + if (at <= 0L) return "" + val hours = ((now - at).coerceAtLeast(0L) / HOUR_MS.toDouble()).roundToInt() + return when { + hours < 1 -> stringResource(R.string.algo_ago_now) + hours < 48 -> stringResource(R.string.algo_ago_hours, hours) + else -> (hours / 24.0).roundToInt().let { days -> pluralStringResource(R.plurals.algo_ago_days, days, days) } + } +} + +@Composable +internal fun retentionLabel(days: Int?): String = when { + days == null -> stringResource(R.string.algo_retention_forever) + days == 365 -> stringResource(R.string.algo_retention_year) + days >= 180 -> (days / 30.0).roundToInt().let { months -> pluralStringResource(R.plurals.algo_retention_months, months, months) } + else -> stringResource(R.string.algo_retention_days_short, days) +} + +@Composable +internal fun itemCount(count: Int): String = pluralStringResource(R.plurals.algo_data_count, count, formatNumber(count.toDouble())) + +@Composable +internal fun basedOn(count: Int): String = pluralStringResource(R.plurals.algo_based_on, count, count) + +@Composable +internal fun formatParameter(parameter: AlgorithmParameter, value: Any): String = when { + value is Boolean -> stringResource(if (value) R.string.algo_on else R.string.algo_off) + parameter is AlgorithmParameter.Number && parameter.unit == "%" -> + "${((value as? Number)?.toDouble() ?: 0.0).times(100).roundToInt()} %" + parameter is AlgorithmParameter.Number && parameter.unit == "min" -> + stringResource(R.string.algo_minutes, formatNumber((value as? Number)?.toDouble() ?: 0.0)) + else -> formatNumber((value as? Number)?.toDouble() ?: 0.0) +} + +internal fun formatNumber(value: Double): String = + NumberFormat.getNumberInstance().apply { maximumFractionDigits = 2 }.format(value) + +internal fun formatPercent(share: Double): String = + NumberFormat.getPercentInstance().apply { maximumFractionDigits = 0 }.format(share) + +internal val LEVEL_LABELS = listOf( + R.string.algo_level_none, + R.string.algo_level_low, + R.string.algo_level_medium, + R.string.algo_level_high, +) + +private const val HOUR_MS = 3_600_000L diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderLastHome.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderLastHome.kt new file mode 100644 index 00000000..15f2931e --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderLastHome.kt @@ -0,0 +1,117 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.FormatListNumbered +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.recommendations.PageTrace + +@Composable +internal fun LastHome(state: RecommenderSettingsState, open: Boolean, onToggle: () -> Unit, onRetry: () -> Unit) { + val trace = when (val loaded = state.lastHome) { + Loadable.Failed -> return LoadFailed(onRetry) + is Loadable.Loaded -> loaded.value + else -> return RecommenderNote(stringResource(R.string.algo_loading)) + } + if (trace == null) return RecommenderNote(stringResource(R.string.algo_last_home_none)) + val built = listOf(ago(trace.builtAt), stringResource(R.string.algo_computed_by, state.algorithmName(trace.algorithm))) + .filter { it.isNotEmpty() } + .joinToString(" · ") + RecommenderFold( + icon = Icons.Outlined.Home, + label = stringResource(R.string.algo_last_home_fold), + description = built, + summary = stringResource(R.string.algo_last_home_summary, formatNumber(trace.candidates.toDouble()), formatNumber(trace.shown.toDouble())), + open = open, + onToggle = onToggle, + divider = false, + ) { TraceDetails(trace) } +} + +@Composable +private fun TraceDetails(trace: PageTrace) { + var rowsOpen by rememberSaveable { mutableStateOf(false) } + var scoresOpen by rememberSaveable { mutableStateOf(false) } + val dropped = trace.dropped.values.sum() + RecommenderRow( + label = pluralStringResource(R.plurals.algo_found, trace.candidates, trace.candidates), + description = traceSources(trace), + icon = Icons.Outlined.Search, + ) + RecommenderRow( + label = pluralStringResource(R.plurals.algo_dropped, dropped, dropped), + description = trace.dropped.map { (why, count) -> dropLabel(why, count) }.joinToString(" · "), + icon = Icons.Outlined.FilterAlt, + ) + RecommenderRow( + label = pluralStringResource(R.plurals.algo_shown, trace.shown, trace.shown), + description = pluralStringResource(R.plurals.algo_shown_rows, trace.rows.size, trace.rows.size), + icon = Icons.Outlined.Visibility, + ) + RecommenderRow( + label = pluralStringResource(R.plurals.algo_rows_count, trace.rows.size, trace.rows.size), + description = stringResource(R.string.algo_rows_description), + icon = Icons.AutoMirrored.Outlined.ViewList, + ) { TextAction(stringResource(if (rowsOpen) R.string.algo_hide else R.string.algo_show), { rowsOpen = !rowsOpen }) } + AnimatedVisibility(visible = rowsOpen) { + Column(modifier = Modifier.padding(start = 32.dp)) { + trace.rows.forEach { row -> + RecommenderRow( + label = row.title, + description = listOf(row.why, pluralStringResource(R.plurals.algo_videos, row.videos, row.videos)) + .filter { it.isNotBlank() } + .joinToString(" · "), + ) + } + } + } + RecommenderRow( + label = pluralStringResource(R.plurals.algo_scores_label, trace.scores.size, trace.scores.size), + description = stringResource(R.string.algo_scores_description), + icon = Icons.Outlined.FormatListNumbered, + divider = scoresOpen, + ) { TextAction(stringResource(if (scoresOpen) R.string.algo_hide else R.string.algo_show), { scoresOpen = !scoresOpen }) } + AnimatedVisibility(visible = scoresOpen) { + Column(modifier = Modifier.padding(start = 32.dp)) { + trace.scores.forEach { score -> + val factors = score.factors.filterValues { it != 0.0 }.map { (factor, value) -> "$factor ${formatNumber(value)}" } + RecommenderRow( + label = score.title.ifBlank { score.url }, + description = (listOf(score.channel, score.row) + factors).filter { it.isNotBlank() }.joinToString(" · "), + ) { + Text(formatNumber(score.total), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } +} + +@Composable +private fun traceSources(trace: PageTrace): String { + fun count(kind: String) = trace.sources.count { it.kind == kind } + return listOfNotNull( + if (count("subscription_feed") > 0) stringResource(R.string.algo_source_subscriptions) else null, + count("related").takeIf { it > 0 }?.let { pluralStringResource(R.plurals.algo_source_related, it, it) }, + count("channel_videos").takeIf { it > 0 }?.let { pluralStringResource(R.plurals.algo_source_channels, it, it) }, + count("search").takeIf { it > 0 }?.let { pluralStringResource(R.plurals.algo_source_search, it, it) }, + if (count("trending") > 0) stringResource(R.string.algo_source_trending) else null, + ).joinToString(", ") +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderOverviewSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderOverviewSection.kt new file mode 100644 index 00000000..fabc1429 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderOverviewSection.kt @@ -0,0 +1,104 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.PieChart +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import dev.typetype.android.R + +internal fun LazyListScope.overviewSection(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + if (!state.preferences.enabled) { + item { RecommenderNote(stringResource(R.string.algo_overview_off)) } + return + } + when (val home = state.home) { + Loadable.Failed -> item { LoadFailed { onAction(RecommenderSettingsAction.Retry) } } + is Loadable.Loaded -> if (home.value.algorithm == null) { + item { RecommenderNote(stringResource(R.string.algo_overview_fallback)) } + return + } + else -> { + item { RecommenderNote(stringResource(R.string.algo_loading)) } + return + } + } + val rows = state.home.value?.rows.orEmpty() + item(key = "composition") { + val labels = rows.map { reasonLabel(it.reason) to it.videoUrls.size } + val parts = labels.groupBy({ it.first }, { it.second }).map { (label, counts) -> DonutPart(label, label, counts.sum().toDouble()) } + RecommenderGroup( + label = stringResource(R.string.algo_composition_label), + icon = Icons.Outlined.PieChart, + help = stringResource(R.string.algo_help_composition), + ) { + state.selectedAlgorithm?.let { + Text(it.name, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Donut( + parts = parts, + centerValue = rows.sumOf { it.videoUrls.size }, + centerLabel = stringResource(R.string.algo_composition_center), + unit = { count -> pluralStringResource(R.plurals.algo_videos, count, count) }, + ) + TextAction(stringResource(R.string.algo_adjust), { onAction(RecommenderSettingsAction.Open(RecommenderSection.Algorithm)) }) + } + } + item(key = "share") { InterestShare(state, onAction) } + item(key = "rows") { + RecommenderGroup( + label = stringResource(R.string.algo_rows_label), + icon = Icons.AutoMirrored.Outlined.ViewList, + help = stringResource(R.string.algo_rows_description), + ) { + rows.forEachIndexed { index, row -> + RecommenderRow(label = row.title, description = reasonLabel(row.reason), divider = index < rows.lastIndex) { + Text( + pluralStringResource(R.plurals.algo_videos, row.videoUrls.size, row.videoUrls.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@androidx.compose.runtime.Composable +private fun InterestShare(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + val profile = state.profile.value ?: return + val ignored = state.preferences.ignoredSignals.toSet() + val counted = profile.groups.filter { !it.muted && it.id !in state.preferences.mutedGroups && it.id !in ignored } + val heaviest = counted.sortedByDescending { it.weight } + val parts = heaviest.take(SHARE_PARTS).map { DonutPart(it.id, it.label, it.weight) }.toMutableList() + val others = heaviest.drop(SHARE_PARTS).sumOf { it.weight } + val othersLabel = pluralStringResource(R.plurals.algo_share_others, heaviest.size - SHARE_PARTS, heaviest.size - SHARE_PARTS) + if (others > 0) parts += DonutPart(OTHERS, othersLabel, others, neutral = true) + RecommenderGroup( + label = stringResource(R.string.algo_share_label), + icon = Icons.Outlined.Sell, + help = stringResource(R.string.algo_help_interests), + ) { + if (parts.isEmpty()) { + RecommenderNote(stringResource(R.string.algo_profile_empty)) + } else { + Donut( + parts = parts, + centerValue = counted.size, + centerLabel = stringResource(R.string.algo_share_center), + onSelect = { id -> + onAction(if (id == OTHERS) RecommenderSettingsAction.Open(RecommenderSection.Profile) else RecommenderSettingsAction.OpenInterest(id)) + }, + ) + } + TextAction(stringResource(R.string.algo_adjust), { onAction(RecommenderSettingsAction.Open(RecommenderSection.Profile)) }) + } +} + +private const val SHARE_PARTS = 10 +private const val OTHERS = "others" diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderParameterRows.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderParameterRows.kt index 32c257eb..42c1a470 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderParameterRows.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderParameterRows.kt @@ -1,5 +1,6 @@ package dev.typetype.android.feature.settings.recommender +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,82 +14,78 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp import dev.typetype.android.R -import dev.typetype.android.core.ui.components.SwitchRow +import dev.typetype.android.core.ui.components.TypeTypeSwitch import dev.typetype.android.domain.recommendations.AlgorithmParameter import kotlin.math.roundToInt @Composable -internal fun ParameterGroupHeader(group: String) { - val (title, help) = groupLabels(group) - Column(modifier = Modifier.padding(top = 8.dp)) { - Text(stringResource(title), style = MaterialTheme.typography.titleSmall) - Text(stringResource(help), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +internal fun ParameterRow( + parameter: AlgorithmParameter, + value: Any, + showDefault: Boolean = false, + onRestore: (() -> Unit)? = null, + onChange: (Any) -> Unit, +) { + val changed = !parameter.isDefault(value) + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + when (parameter) { + is AlgorithmParameter.Toggle -> Row(verticalAlignment = Alignment.CenterVertically) { + ParameterTitle(parameter, showDefault, Modifier.weight(1f)) + TypeTypeSwitch(checked = value as? Boolean ?: parameter.default, onCheckedChange = { onChange(it) }) + } + is AlgorithmParameter.Number -> NumberParameter(parameter, (value as? Number)?.toDouble() ?: parameter.default, showDefault, onChange) + } + AnimatedVisibility(visible = changed && onRestore != null) { + TextAction(stringResource(R.string.algo_restore), { onRestore?.invoke() }) + } } } @Composable -internal fun ParameterRow(parameter: AlgorithmParameter, value: Any?, onChange: (Any) -> Unit) { - when (parameter) { - is AlgorithmParameter.Toggle -> SwitchRow( - title = parameter.label, - subtitle = parameter.help, - checked = value as? Boolean ?: parameter.default, - onCheckedChange = { onChange(it) }, - ) - is AlgorithmParameter.Number -> NumberParameter(parameter, (value as? Number)?.toDouble() ?: parameter.default, onChange) +private fun ParameterTitle(parameter: AlgorithmParameter, showDefault: Boolean, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(parameter.label, style = MaterialTheme.typography.bodyLarge) + parameter.help?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (showDefault) { + Text( + stringResource(R.string.algo_default, formatParameter(parameter, parameter.defaultValue)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @Composable -private fun NumberParameter(parameter: AlgorithmParameter.Number, current: Double, onChange: (Any) -> Unit) { +private fun NumberParameter(parameter: AlgorithmParameter.Number, current: Double, showDefault: Boolean, onChange: (Any) -> Unit) { var position by remember(parameter.id, current) { mutableFloatStateOf(current.toFloat()) } - val intervals = ((parameter.max - parameter.min) / parameter.step).roundToInt() - val steps = if (intervals in 1..MAX_VISIBLE_STEPS) intervals - 1 else 0 - val shown = formatValue(position.toDouble(), parameter) - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(parameter.label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - Text(shown, style = MaterialTheme.typography.labelLarge) - } - parameter.help?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Slider( - value = position, - onValueChange = { position = snap(it.toDouble(), parameter).toFloat() }, - onValueChangeFinished = { onChange(snap(position.toDouble(), parameter)) }, - valueRange = parameter.min.toFloat()..parameter.max.toFloat(), - steps = steps, - modifier = Modifier.semantics { stateDescription = shown }, - ) + val shown = formatParameter(parameter, position.toDouble()) + Row(verticalAlignment = Alignment.Top) { + ParameterTitle(parameter, showDefault, Modifier.weight(1f)) + Text(shown, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 12.dp)) } + Slider( + value = position, + onValueChange = { position = snap(it.toDouble(), parameter).toFloat() }, + onValueChangeFinished = { onChange(snap(position.toDouble(), parameter)) }, + valueRange = parameter.min.toFloat()..parameter.max.toFloat(), + modifier = Modifier.semantics { stateDescription = shown }, + ) } private fun snap(value: Double, parameter: AlgorithmParameter.Number): Double { val steps = ((value - parameter.min) / parameter.step).roundToInt() - return (parameter.min + steps * parameter.step).coerceIn(parameter.min, parameter.max) -} - -private fun formatValue(value: Double, parameter: AlgorithmParameter.Number): String { - if (parameter.unit == "%" && parameter.max <= 1.0) return "${(value * 100).roundToInt()} %" - val number = if (parameter.step >= 1.0) value.roundToInt().toString() else "%.2f".format(value) - return parameter.unit?.let { "$number $it" } ?: number -} - -private fun groupLabels(group: String): Pair = when (group) { - "rows" -> R.string.algo_group_rows to R.string.algo_group_rows_help - "sources" -> R.string.algo_group_sources to R.string.algo_group_sources_help - "exploration" -> R.string.algo_group_exploration to R.string.algo_group_exploration_help - "randomness" -> R.string.algo_group_randomness to R.string.algo_group_randomness_help - "decay" -> R.string.algo_group_decay to R.string.algo_group_decay_help - "weights" -> R.string.algo_group_weights to R.string.algo_group_weights_help - else -> R.string.algo_group_other to R.string.algo_group_other_help + val snapped = (parameter.min + steps * parameter.step).coerceIn(parameter.min, parameter.max) + return (snapped * PRECISION).roundToInt() / PRECISION } -private const val MAX_VISIBLE_STEPS = 12 +private const val PRECISION = 10_000.0 diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderProfileSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderProfileSection.kt new file mode 100644 index 00000000..b385f248 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderProfileSection.kt @@ -0,0 +1,171 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material.icons.outlined.People +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.recommendations.InterestGroup +import dev.typetype.android.domain.recommendations.ProfileChannel + +internal fun LazyListScope.profileSection( + state: RecommenderSettingsState, + showAll: Boolean, + onShowAll: () -> Unit, + onOpenChannel: (ProfileChannel) -> Unit, + onAction: (RecommenderSettingsAction) -> Unit, +) { + if (!state.preferences.enabled) { + item { RecommenderNote(stringResource(R.string.algo_overview_off)) } + return + } + val profile = when (val loaded = state.profile) { + Loadable.Failed -> { + item { LoadFailed { onAction(RecommenderSettingsAction.Retry) } } + return + } + is Loadable.Loaded -> loaded.value + else -> { + item { RecommenderNote(stringResource(R.string.algo_loading)) } + return + } + } + item(key = "interests") { + RecommenderGroup( + label = stringResource(R.string.algo_interests_label), + icon = Icons.Outlined.Sell, + help = stringResource(R.string.algo_help_interests), + ) { + AddInterest(state, onAction) + if (profile.groups.isEmpty()) RecommenderNote(stringResource(R.string.algo_profile_empty)) + } + } + val shown = shownInterests(state, showAll) + items(shown, key = { "interest-${it.id}" }) { group -> + InterestCard(group, state, highlighted = group.id == state.highlight, onAction = onAction) + } + val hidden = profile.groups.size - shown.size + if (hidden > 0) { + item(key = "interests-more") { TextAction(pluralStringResource(R.plurals.algo_interests_more, hidden, hidden), onShowAll) } + } + if (profile.channels.isNotEmpty()) { + item(key = "channels") { + RecommenderGroup( + label = stringResource(R.string.algo_channels_label), + icon = Icons.Outlined.People, + help = stringResource(R.string.algo_help_channels), + ) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(vertical = 12.dp)) { + items(profile.channels, key = { it.id }) { channel -> + ChannelChip(channel, ignored = channel.id in state.preferences.ignoredSignals) { onOpenChannel(channel) } + } + } + } + } + } + item(key = "recent") { + RecommenderGroup( + label = stringResource(R.string.algo_recent_label), + icon = Icons.Outlined.Schedule, + help = stringResource(R.string.algo_help_recent), + ) { + if (profile.sessionBoosts.isEmpty()) RecommenderNote(stringResource(R.string.algo_recent_empty)) + val now = System.currentTimeMillis() + profile.sessionBoosts.forEach { boost -> + val minutes = ((boost.expiresAt - now) / MINUTE_MS).toInt().coerceAtLeast(0) + RecommenderRow( + label = boost.label, + description = if (minutes > 0) { + pluralStringResource(R.plurals.algo_counts_for, minutes, minutes) + } else { + stringResource(R.string.algo_no_longer_counts) + }, + ) + } + if (profile.sessionBoosts.isNotEmpty()) { + RecommenderRow( + label = stringResource(R.string.algo_forget_recent), + description = stringResource(R.string.algo_forget_recent_description), + divider = false, + ) { + TextAction(stringResource(R.string.algo_forget), { onAction(RecommenderSettingsAction.ForgetRecent) }) + } + } + } + } +} + +internal fun shownInterests(state: RecommenderSettingsState, showAll: Boolean): List { + val ranked = state.profile.value?.groups.orEmpty().sortedByDescending { it.weight } + if (showAll) return ranked + val preferences = state.preferences + return ranked.filterIndexed { index, group -> + index < INTERESTS_SHOWN || group.custom || group.id in preferences.pinnedGroups || + group.id in preferences.mutedGroups || group.id == state.highlight + } +} + +@Composable +private fun AddInterest(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + var draft by rememberSaveable { mutableStateOf("") } + val submit = { + onAction(RecommenderSettingsAction.AddInterest(draft)) + draft = "" + } + Column(modifier = Modifier.padding(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it.take(MAX_INTEREST_LENGTH) }, + enabled = state.canAddInterest, + placeholder = { Text(stringResource(R.string.algo_interest_add_placeholder), maxLines = 1) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (draft.isNotBlank()) submit() }), + trailingIcon = { + IconButton(onClick = submit, enabled = state.canAddInterest && draft.isNotBlank()) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.algo_interest_add)) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + if (state.canAddInterest) { + stringResource(R.string.algo_interest_add_hint) + } else { + pluralStringResource(R.plurals.algo_interest_full, MAX_CUSTOM_INTERESTS, MAX_CUSTOM_INTERESTS) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private const val INTERESTS_SHOWN = 12 +private const val MINUTE_MS = 60_000L diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSection.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSection.kt new file mode 100644 index 00000000..bc442e13 --- /dev/null +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSection.kt @@ -0,0 +1,22 @@ +package dev.typetype.android.feature.settings.recommender + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Build +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Forum +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.PieChart +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.ui.graphics.vector.ImageVector +import dev.typetype.android.R + +enum class RecommenderSection(val titleRes: Int, val descriptionRes: Int, val icon: ImageVector) { + Overview(R.string.algo_section_overview, R.string.algo_section_overview_description, Icons.Outlined.PieChart), + Algorithm(R.string.algo_section_algorithm, R.string.algo_section_algorithm_description, Icons.AutoMirrored.Outlined.ViewList), + Guide(R.string.algo_section_guide, R.string.algo_section_guide_description, Icons.Outlined.Explore), + Data(R.string.algo_section_data, R.string.algo_section_data_description, Icons.Outlined.Storage), + Profile(R.string.algo_section_profile, R.string.algo_section_profile_description, Icons.Outlined.Person), + Details(R.string.algo_section_backstage, R.string.algo_section_backstage_description, Icons.Outlined.Build), + Feedback(R.string.algo_section_feedback, R.string.algo_section_feedback_description, Icons.Outlined.Forum), +} diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsScreen.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsScreen.kt index f83133d9..0b45f8dd 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsScreen.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsScreen.kt @@ -1,27 +1,42 @@ package dev.typetype.android.feature.settings.recommender +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.typetype.android.R -import dev.typetype.android.core.ui.components.SettingsSectionHeader -import dev.typetype.android.core.ui.components.SwitchRow +import dev.typetype.android.core.ui.components.LocalAppSnackbarHost +import dev.typetype.android.domain.recommendations.ProfileChannel import dev.typetype.android.feature.settings.SettingsDetailTopBar @Composable @@ -37,71 +52,110 @@ internal fun RecommenderSettingsScreen( onAction: (RecommenderSettingsAction) -> Unit, onNavigateBack: () -> Unit, ) { - val preferences = state.preferences - val selected = state.selectedAlgorithm + val inside = state.section != null + BackHandler(enabled = inside) { onAction(RecommenderSettingsAction.Back) } + val snackbar = LocalAppSnackbarHost.current + val chosen = state.chosenAlgorithm?.let { stringResource(R.string.algo_guide_chosen, it) } + LaunchedEffect(chosen) { + if (chosen != null) { + onAction(RecommenderSettingsAction.ChosenShown) + snackbar?.showSnackbar(chosen) + } + } + val title = state.section?.let { stringResource(it.titleRes) } ?: stringResource(R.string.algo_title) Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { - SettingsDetailTopBar(title = stringResource(R.string.algo_title), onNavigateBack = onNavigateBack) - if (state.isLoading || state.isSaving) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (state.loadFailed) { - item { - Text(stringResource(R.string.algo_load_failed), color = MaterialTheme.colorScheme.error) - TextButton(onClick = { onAction(RecommenderSettingsAction.Retry) }) { - Text(stringResource(R.string.algo_retry)) - } - } - } - if (state.saveFailed) { - item { Text(stringResource(R.string.algo_save_failed), color = MaterialTheme.colorScheme.error) } - } - item { - SwitchRow( - title = stringResource(R.string.algo_enabled), - subtitle = stringResource(R.string.algo_enabled_subtitle), - checked = preferences.enabled, - onCheckedChange = { onAction(RecommenderSettingsAction.SetEnabled(it)) }, - enabled = !state.isLoading, - ) - } - val algorithms = state.catalog?.algorithms.orEmpty() - if (algorithms.isNotEmpty()) { - item { SettingsSectionHeader(stringResource(R.string.algo_choose)) } - item { Text(stringResource(R.string.algo_guide_intro), style = MaterialTheme.typography.bodyMedium) } - items(algorithms, key = { "algorithm-${it.id}" }) { algorithm -> - AlgorithmCard( - algorithm = algorithm, - selected = algorithm.id == selected?.id, - onSelect = { onAction(RecommenderSettingsAction.ChooseAlgorithm(algorithm.id)) }, - ) - } - } - if (selected != null && selected.parameters.isNotEmpty()) { - item { SettingsSectionHeader(stringResource(R.string.algo_settings_of, selected.name)) } - selected.parameters - .sortedByDescending { it.featured } - .groupBy { it.group } - .forEach { (group, parameters) -> - item(key = "group-$group") { ParameterGroupHeader(group) } - items(parameters, key = { "parameter-${it.id}" }) { parameter -> - ParameterRow(parameter, preferences.parameters[parameter.id]) { value -> - onAction(RecommenderSettingsAction.SetParameter(parameter.id, value)) - } - } - } - item { - TextButton(onClick = { onAction(RecommenderSettingsAction.ResetParameters) }) { - Text(stringResource(R.string.algo_reset_all, selected.name)) - } - } - } - recommenderInterests(state, onAction) - recommenderData(state, onAction) + SettingsDetailTopBar( + title = title, + onNavigateBack = { if (inside) onAction(RecommenderSettingsAction.Back) else onNavigateBack() }, + ) + AnimatedVisibility(visible = state.isSaving) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } + AnimatedVisibility(visible = state.saveFailed) { + Text( + stringResource(R.string.algo_save_failed), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) } + Box(modifier = Modifier.weight(1f)) { RecommenderBody(state, onAction) } } } } + +@Composable +private fun RecommenderBody(state: RecommenderSettingsState, onAction: (RecommenderSettingsAction) -> Unit) { + val retry = { onAction(RecommenderSettingsAction.Retry) } + when (state.availability) { + RecommenderAvailability.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + RecommenderAvailability.Guest -> RecommenderNotice(stringResource(R.string.algo_sign_in)) + RecommenderAvailability.NotInstalled -> RecommenderNotice(stringResource(R.string.algo_not_available)) + RecommenderAvailability.NotOffered -> RecommenderNotice(stringResource(R.string.algo_not_offered), stringResource(R.string.algo_retry), retry) + RecommenderAvailability.Unreachable -> RecommenderNotice(stringResource(R.string.algo_unreachable), stringResource(R.string.algo_retry), retry) + RecommenderAvailability.Ready -> AnimatedContent( + targetState = state.section, + transitionSpec = { + val forward = targetState != null + (slideInHorizontally { if (forward) it / 4 else -it / 4 } + fadeIn()) togetherWith + (slideOutHorizontally { if (forward) -it / 4 else it / 4 } + fadeOut()) + }, + label = "recommender-section", + ) { section -> SectionList(state, section, onAction) } + } +} + +@Composable +private fun SectionList(state: RecommenderSettingsState, section: RecommenderSection?, onAction: (RecommenderSettingsAction) -> Unit) { + val listState = rememberLazyListState() + var showAllInterests by rememberSaveable(section) { mutableStateOf(false) } + var openFolds by rememberSaveable(section) { mutableStateOf(emptyList()) } + var openChannel by rememberSaveable(section) { mutableStateOf(null) } + val highlightIndex = if (section == RecommenderSection.Profile) { + state.highlight?.let { id -> shownInterests(state, showAllInterests).indexOfFirst { it.id == id } }?.takeIf { it >= 0 } + } else { + null + } + LaunchedEffect(highlightIndex) { + highlightIndex?.let { listState.animateScrollToItem(it + PROFILE_HEADER_ITEMS) } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (section != null) item(key = "description") { RecommenderNote(stringResource(section.descriptionRes)) } + when (section) { + null -> recommenderHub(state, onAction) + RecommenderSection.Overview -> overviewSection(state, onAction) + RecommenderSection.Algorithm -> algorithmSection(state, onAction) + RecommenderSection.Guide -> guideSection(state, onAction) + RecommenderSection.Data -> dataSection(state, onAction) + RecommenderSection.Profile -> profileSection( + state = state, + showAll = showAllInterests, + onShowAll = { showAllInterests = true }, + onOpenChannel = { channel: ProfileChannel -> openChannel = channel.id }, + onAction = onAction, + ) + RecommenderSection.Details -> detailsSection( + state = state, + open = openFolds.toSet(), + onToggle = { id -> openFolds = if (id in openFolds) openFolds - id else openFolds + id }, + onAction = onAction, + ) + RecommenderSection.Feedback -> feedbackSection(state, onAction) + } + } + val channel = state.profile.value?.channels?.firstOrNull { it.id == openChannel } + if (channel != null) { + ChannelSheet( + channel = channel, + ignored = channel.id in state.preferences.ignoredSignals, + onToggle = { onAction(RecommenderSettingsAction.ToggleIgnored(channel.id)) }, + onDismiss = { openChannel = null }, + ) + } +} + +private const val PROFILE_HEADER_ITEMS = 2 diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsState.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsState.kt index c8260c59..b9c3b5d3 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsState.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsState.kt @@ -1,43 +1,97 @@ package dev.typetype.android.feature.settings.recommender +import dev.typetype.android.domain.feed.HomeRecommendationRow import dev.typetype.android.domain.recommendations.AlgorithmCatalog import dev.typetype.android.domain.recommendations.AlgorithmInfo -import dev.typetype.android.domain.recommendations.DataFamily -import dev.typetype.android.domain.recommendations.InterestGroup +import dev.typetype.android.domain.recommendations.AlgorithmParameter +import dev.typetype.android.domain.recommendations.DataCategory +import dev.typetype.android.domain.recommendations.FeedbackEntry +import dev.typetype.android.domain.recommendations.PageTrace import dev.typetype.android.domain.recommendations.RecommenderPreferences +import dev.typetype.android.domain.recommendations.RecommenderProfile + +enum class RecommenderAvailability { Loading, Ready, Guest, NotInstalled, NotOffered, Unreachable } + +sealed interface Loadable { + data object Idle : Loadable + data object Loading : Loadable + data object Failed : Loadable + data class Loaded(val value: T) : Loadable +} + +internal val Loadable.value: T? + get() = (this as? Loadable.Loaded)?.value + +data class HomeComposition(val algorithm: String?, val rows: List) data class RecommenderSettingsState( - val isLoading: Boolean = true, - val isSaving: Boolean = false, - val loadFailed: Boolean = false, - val saveFailed: Boolean = false, + val availability: RecommenderAvailability = RecommenderAvailability.Loading, + val section: RecommenderSection? = null, val catalog: AlgorithmCatalog? = null, val preferences: RecommenderPreferences = RecommenderPreferences(), - val interests: List = emptyList(), - val families: List = emptyList(), + val isSaving: Boolean = false, + val saveFailed: Boolean = false, + val home: Loadable = Loadable.Idle, + val profile: Loadable = Loadable.Idle, + val data: Loadable> = Loadable.Idle, + val lastHome: Loadable = Loadable.Idle, + val feedback: Loadable> = Loadable.Idle, + val openCategory: String? = null, + val collectHistory: Boolean = true, + val highlight: String? = null, + val chosenAlgorithm: String? = null, ) { + val algorithms: List + get() = catalog?.algorithms.orEmpty() + val selectedAlgorithm: AlgorithmInfo? get() { - val algorithms = catalog?.algorithms.orEmpty() val id = preferences.algorithmId ?: catalog?.defaultId return algorithms.firstOrNull { it.id == id } ?: algorithms.firstOrNull() } val canAddInterest: Boolean get() = preferences.customInterests.size < MAX_CUSTOM_INTERESTS + + fun valueOf(parameter: AlgorithmParameter): Any = preferences.parameters[parameter.id] ?: parameter.defaultValue + + fun algorithmName(id: String): String = algorithms.firstOrNull { it.id == id }?.name ?: id +} + +internal val AlgorithmParameter.defaultValue: Any + get() = when (this) { + is AlgorithmParameter.Number -> default + is AlgorithmParameter.Toggle -> default + } + +internal fun AlgorithmParameter.isDefault(value: Any): Boolean = when (this) { + is AlgorithmParameter.Number -> (value as? Number)?.toDouble() == default + is AlgorithmParameter.Toggle -> value == default } sealed interface RecommenderSettingsAction { data object Retry : RecommenderSettingsAction + data class Open(val section: RecommenderSection?) : RecommenderSettingsAction + data object Back : RecommenderSettingsAction + data class OpenInterest(val groupId: String) : RecommenderSettingsAction data class SetEnabled(val enabled: Boolean) : RecommenderSettingsAction - data class ChooseAlgorithm(val id: String) : RecommenderSettingsAction + data class ChooseAlgorithm(val id: String, val announce: Boolean = false) : RecommenderSettingsAction + data object ChosenShown : RecommenderSettingsAction data class SetParameter(val id: String, val value: Any) : RecommenderSettingsAction + data class RestoreParameter(val id: String) : RecommenderSettingsAction data object ResetParameters : RecommenderSettingsAction data class TogglePinned(val groupId: String) : RecommenderSettingsAction data class ToggleMuted(val groupId: String) : RecommenderSettingsAction + data class ToggleIgnored(val id: String) : RecommenderSettingsAction + data object ForgetRecent : RecommenderSettingsAction data class AddInterest(val text: String) : RecommenderSettingsAction data class RemoveInterest(val text: String) : RecommenderSettingsAction - data class SetFamilyUsed(val id: String, val used: Boolean) : RecommenderSettingsAction + data class OpenCategory(val id: String) : RecommenderSettingsAction + data class ToggleUse(val categoryId: String) : RecommenderSettingsAction + data class SetRetention(val categoryId: String, val days: Int?) : RecommenderSettingsAction + data class ToggleExcluded(val itemId: String) : RecommenderSettingsAction + data class SetCollect(val collect: Boolean) : RecommenderSettingsAction + data class RemoveFeedback(val id: String) : RecommenderSettingsAction } internal const val MAX_CUSTOM_INTERESTS = 10 diff --git a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsViewModel.kt b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsViewModel.kt index 41bc6057..c7a2140c 100644 --- a/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsViewModel.kt +++ b/feature/settings/src/main/java/dev/typetype/android/feature/settings/recommender/RecommenderSettingsViewModel.kt @@ -3,96 +3,169 @@ package dev.typetype.android.feature.settings.recommender import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dev.typetype.android.domain.recommendations.RecommenderPreferences +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.domain.account.AccountRepository +import dev.typetype.android.domain.feed.HomeFeedRepository +import dev.typetype.android.domain.recommendations.RecommenderFeedbackRepository import dev.typetype.android.domain.recommendations.RecommenderRepository +import dev.typetype.android.domain.server.Server +import dev.typetype.android.domain.server.ServerCapabilitiesRepository +import dev.typetype.android.domain.server.ServerRepository +import dev.typetype.android.domain.usersettings.UserSettingsRepository +import dev.typetype.android.feature.settings.accountFeaturesAvailable import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel class RecommenderSettingsViewModel @Inject constructor( private val repository: RecommenderRepository, + private val capabilitiesRepository: ServerCapabilitiesRepository, + private val userSettingsRepository: UserSettingsRepository, + serverRepository: ServerRepository, + activeAccountScope: ActiveAccountScope, + accountRepository: AccountRepository, + feedRepository: HomeFeedRepository, + feedbackRepository: RecommenderFeedbackRepository, ) : ViewModel() { private val _state = MutableStateFlow(RecommenderSettingsState()) val state = _state.asStateFlow() - private var saveJob: Job? = null + private val insights = RecommenderInsights(viewModelScope, _state, repository, feedRepository, feedbackRepository) + private val editor = RecommenderEditor(viewModelScope, _state, repository) { insights.refresh(_state.value.section) } + private var server: Server? = null + private var loadJob: Job? = null init { - load() + viewModelScope.launch { + combine( + serverRepository.observeCurrentServer(), + activeAccountScope.observe(), + accountRepository.observeAccounts(), + ) { current, scope, accounts -> current to accountFeaturesAvailable(scope, accounts) } + .distinctUntilChanged { old, new -> old.first?.id == new.first?.id && old.second == new.second } + .collectLatest { (current, signedIn) -> + server = current + if (!signedIn || current == null) { + _state.update { it.copy(availability = RecommenderAvailability.Guest) } + } else { + checkAvailability(current) + } + } + } + viewModelScope.launch { + userSettingsRepository.observe().collect { settings -> + _state.update { it.copy(collectHistory = !settings.disableWatchHistory) } + } + } } fun onAction(action: RecommenderSettingsAction) { when (action) { - RecommenderSettingsAction.Retry -> load() - is RecommenderSettingsAction.SetEnabled -> edit { it.copy(enabled = action.enabled) } - is RecommenderSettingsAction.ChooseAlgorithm -> edit { it.copy(algorithmId = action.id, enabled = true) } - is RecommenderSettingsAction.SetParameter -> edit { it.copy(parameters = it.parameters + (action.id to action.value)) } - RecommenderSettingsAction.ResetParameters -> { - val ids = _state.value.selectedAlgorithm?.parameters?.map { it.id }.orEmpty().toSet() - edit { it.copy(parameters = it.parameters - ids) } + RecommenderSettingsAction.Retry -> retry() + is RecommenderSettingsAction.Open -> open(action.section) + RecommenderSettingsAction.Back -> back() + is RecommenderSettingsAction.OpenInterest -> { + _state.update { it.copy(section = RecommenderSection.Profile, highlight = action.groupId) } + insights.ensure(RecommenderSection.Profile) } - is RecommenderSettingsAction.TogglePinned -> edit { - it.copy(pinnedGroups = it.pinnedGroups.toggle(action.groupId), mutedGroups = it.mutedGroups - action.groupId) + is RecommenderSettingsAction.SetEnabled -> editor.edit { it.copy(enabled = action.enabled) } + is RecommenderSettingsAction.ChooseAlgorithm -> { + editor.edit { it.choose(action.id) } + if (action.announce) _state.update { it.copy(chosenAlgorithm = it.algorithmName(action.id)) } } - is RecommenderSettingsAction.ToggleMuted -> edit { - it.copy(mutedGroups = it.mutedGroups.toggle(action.groupId), pinnedGroups = it.pinnedGroups - action.groupId) + RecommenderSettingsAction.ChosenShown -> _state.update { it.copy(chosenAlgorithm = null) } + is RecommenderSettingsAction.SetParameter -> editor.edit { it.copy(parameters = it.parameters + (action.id to action.value)) } + is RecommenderSettingsAction.RestoreParameter -> editor.edit { it.copy(parameters = it.parameters - action.id) } + RecommenderSettingsAction.ResetParameters -> editor.edit { it.copy(parameters = emptyMap()) } + is RecommenderSettingsAction.TogglePinned -> editor.edit { it.togglePinned(action.groupId) } + is RecommenderSettingsAction.ToggleMuted -> editor.edit { it.toggleMuted(action.groupId) } + is RecommenderSettingsAction.ToggleIgnored -> editor.edit { it.toggleIgnored(action.id) } + RecommenderSettingsAction.ForgetRecent -> editor.edit { it.copy(sessionResetAt = System.currentTimeMillis()) } + is RecommenderSettingsAction.AddInterest -> editor.edit { it.addInterest(action.text) } + is RecommenderSettingsAction.RemoveInterest -> editor.edit { it.removeInterest(action.text) } + is RecommenderSettingsAction.OpenCategory -> _state.update { it.copy(openCategory = action.id) } + is RecommenderSettingsAction.ToggleUse -> { + editor.edit { it.toggleUse(action.categoryId) } + insights.updateData { list -> list.map { if (it.id == action.categoryId) it.copy(used = !it.used) else it } } } - is RecommenderSettingsAction.AddInterest -> { - val text = action.text.trim().take(MAX_INTEREST_LENGTH) - if (text.isEmpty() || !_state.value.canAddInterest) return - edit { it.copy(customInterests = (it.customInterests + text).distinct()) } + is RecommenderSettingsAction.SetRetention -> { + editor.edit { it.withRetention(action.categoryId, action.days) } + insights.updateData { list -> list.map { if (it.id == action.categoryId) it.copy(retentionDays = action.days) else it } } } - is RecommenderSettingsAction.RemoveInterest -> edit { it.copy(customInterests = it.customInterests - action.text) } - is RecommenderSettingsAction.SetFamilyUsed -> { - edit { - it.copy(unusedData = if (action.used) it.unusedData - action.id else (it.unusedData + action.id).distinct()) - } - _state.update { state -> - state.copy(families = state.families.map { family -> if (family.id == action.id) family.copy(used = action.used) else family }) + is RecommenderSettingsAction.ToggleExcluded -> { + editor.edit { it.toggleExcluded(action.itemId) } + insights.updateData { list -> + list.map { category -> + category.copy(items = category.items.map { if (it.id == action.itemId) it.copy(excluded = !it.excluded) else it }) + } } } + is RecommenderSettingsAction.SetCollect -> viewModelScope.launch { + userSettingsRepository.update { it.copy(disableWatchHistory = !action.collect) } + } + is RecommenderSettingsAction.RemoveFeedback -> insights.removeFeedback(action.id) + } + } + + private fun open(section: RecommenderSection?) { + _state.update { it.copy(section = section, openCategory = null, highlight = null) } + insights.ensure(section) + } + + private fun back() = _state.update { + when { + it.openCategory != null -> it.copy(openCategory = null) + else -> it.copy(section = null, highlight = null) + } + } + + private fun retry() { + val current = server ?: return + when (_state.value.availability) { + RecommenderAvailability.Unreachable, RecommenderAvailability.NotOffered -> { + viewModelScope.launch { checkAvailability(current) } + } + else -> insights.ensure(_state.value.section) + } + } + + private suspend fun checkAvailability(current: Server) { + _state.update { it.copy(availability = RecommenderAvailability.Loading) } + val fresh = capabilitiesRepository.refresh(current.id).getOrDefault(current) + when { + !fresh.recommender.installed -> _state.update { it.copy(availability = RecommenderAvailability.NotInstalled) } + !fresh.recommender.enabled -> _state.update { it.copy(availability = RecommenderAvailability.NotOffered) } + else -> load() } } private fun load() { - _state.update { it.copy(isLoading = true, loadFailed = false) } - viewModelScope.launch { - val locale = Locale.getDefault().toLanguageTag() - val catalog = async { repository.catalog(locale) } + loadJob?.cancel() + loadJob = viewModelScope.launch { + val catalog = async { repository.catalog(Locale.getDefault().toLanguageTag()) } val preferences = async { repository.preferences() } - val interests = async { repository.interests(locale) } - val families = async { repository.dataFamilies() } val loadedCatalog = catalog.await().getOrNull() val loadedPreferences = preferences.await().getOrNull() + if (loadedCatalog == null || loadedPreferences == null) { + _state.update { it.copy(availability = RecommenderAvailability.Unreachable) } + return@launch + } _state.update { it.copy( - isLoading = false, - loadFailed = loadedCatalog == null || loadedPreferences == null, + availability = RecommenderAvailability.Ready, catalog = loadedCatalog, - preferences = loadedPreferences ?: it.preferences, - interests = interests.await().getOrDefault(emptyList()), - families = families.await().getOrDefault(emptyList()), + preferences = loadedPreferences, ) } - } - } - - private fun edit(transform: (RecommenderPreferences) -> RecommenderPreferences) { - val updated = transform(_state.value.preferences) - _state.update { it.copy(preferences = updated, isSaving = true, saveFailed = false) } - saveJob?.cancel() - saveJob = viewModelScope.launch { - repository.save(updated).fold( - onSuccess = { saved -> _state.update { it.copy(preferences = saved, isSaving = false) } }, - onFailure = { _state.update { it.copy(isSaving = false, saveFailed = true) } }, - ) + insights.ensure(_state.value.section) } } } - -private fun List.toggle(value: String): List = if (value in this) this - value else this + value diff --git a/feature/settings/src/test/java/dev/typetype/android/feature/settings/recommender/RecommenderEditsTest.kt b/feature/settings/src/test/java/dev/typetype/android/feature/settings/recommender/RecommenderEditsTest.kt new file mode 100644 index 00000000..e2be69c4 --- /dev/null +++ b/feature/settings/src/test/java/dev/typetype/android/feature/settings/recommender/RecommenderEditsTest.kt @@ -0,0 +1,52 @@ +package dev.typetype.android.feature.settings.recommender + +import dev.typetype.android.domain.recommendations.RecommenderPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RecommenderEditsTest { + @Test + fun choosingAnAlgorithmStartsFromItsDefaults() { + val preferences = RecommenderPreferences(algorithmId = "a", parameters = mapOf("w" to 0.4)) + val chosen = preferences.choose("b") + assertEquals("b", chosen.algorithmId) + assertEquals(emptyMap(), chosen.parameters) + } + + @Test + fun pinningAndHidingExcludeEachOther() { + val pinned = RecommenderPreferences().togglePinned("group:1") + assertEquals(listOf("group:1"), pinned.pinnedGroups) + val hidden = pinned.toggleMuted("group:1") + assertEquals(listOf("group:1"), hidden.mutedGroups) + assertEquals(emptyList(), hidden.pinnedGroups) + } + + @Test + fun interestsAreTrimmedDeduplicatedAndCapped() { + val added = RecommenderPreferences().addInterest(" Japanese cooking ") + assertEquals(listOf("Japanese cooking"), added?.customInterests) + assertNull(added?.addInterest("japanese COOKING")) + assertNull(RecommenderPreferences().addInterest(" ")) + val full = RecommenderPreferences(customInterests = (1..MAX_CUSTOM_INTERESTS).map { "s$it" }) + assertNull(full.addInterest("more")) + assertEquals(MAX_CUSTOM_INTERESTS - 1, full.removeInterest("S1").customInterests.size) + } + + @Test + fun retentionNullMeansNoLimit() { + val limited = RecommenderPreferences().withRetention("history", 30) + assertEquals(mapOf("history" to 30), limited.retentionDays) + assertEquals(emptyMap(), limited.withRetention("history", null).retentionDays) + } + + @Test + fun dataTogglesFlip() { + val preferences = RecommenderPreferences().toggleUse("history").toggleExcluded("v1").toggleIgnored("c") + assertEquals(listOf("history"), preferences.unusedData) + assertEquals(listOf("v1"), preferences.excludedItems) + assertEquals(listOf("c"), preferences.ignoredSignals) + assertEquals(emptyList(), preferences.toggleUse("history").unusedData) + } +} diff --git a/release-notes/v1.11.0-beta.9.md b/release-notes/v1.11.0-beta.9.md new file mode 100644 index 00000000..751d21f6 --- /dev/null +++ b/release-notes/v1.11.0-beta.9.md @@ -0,0 +1,8 @@ +TypeType Android 1.11.0-beta.9 + +- Recommendations now have their own entry in Settings, with the same sections as the web app: overview, algorithm, guide, data used, what it keeps, details and your opinions. +- See what your home page is made of and the share of each interest, then open the setting behind any part of the chart. +- Review the data the algorithm reads, limit how far back it reads, and exclude single videos. +- Pin, hide or stop counting an interest or a channel, and forget recently watched videos. +- See every setting with its default value, and how the last home page was built. +- When the instance has the recommender but its admin has not offered it yet, Settings now explains it instead of staying silent.