From 60ca3a90fe632eb70c70f6b2c2a9cbf36903bcec Mon Sep 17 00:00:00 2001 From: Yasan Date: Sun, 10 Mar 2024 11:08:26 +0100 Subject: [PATCH] Add live information feature (#3862) * Create live information request * Create LiveInformationManager & store the data using it * Shorten the dev build warning * Add test variable to announcements * Fix logs * Show announcements on the preference dashboard * Shorten the dev build text even more * Improve handling incomplete data * Fix text alignment * Animate announcements & fix ripple effect on ones without a link * Move other warnings to the bottom of the dashboard * Remove unneeded modifier * Revert "Move other warnings to the bottom of the dashboard" This reverts commit 9ecedd3acd0b901b10976256bc96339ef25ffa11. * Allow modifying live information & announcements via resources * Fix spotless issues * Use Kotlinx.serialization for Live Information * Only expose announcements as an immutable list * add close button --------- Co-authored-by: John Andrew Camu --- lawnchair/res/values/config.xml | 2 + .../components/AnnouncementPreference.kt | 194 ++++++++++++++++++ .../data/liveinfo/LiveInformationManager.kt | 50 +++++ .../data/liveinfo/LiveInformationRequest.kt | 42 ++++ .../data/liveinfo/LiveInformationService.kt | 11 + .../data/liveinfo/SyncLiveInformation.kt | 26 +++ .../data/liveinfo/model/Announcement.kt | 11 + .../data/liveinfo/model/LiveInformation.kt | 20 ++ .../destinations/PreferencesDashboard.kt | 8 +- 9 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/components/AnnouncementPreference.kt create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationManager.kt create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationRequest.kt create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationService.kt create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/SyncLiveInformation.kt create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/Announcement.kt create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/LiveInformation.kt diff --git a/lawnchair/res/values/config.xml b/lawnchair/res/values/config.xml index d68ef4a8bf..5068dacf2e 100644 --- a/lawnchair/res/values/config.xml +++ b/lawnchair/res/values/config.xml @@ -115,6 +115,8 @@ true false true + true + true true true diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/AnnouncementPreference.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/AnnouncementPreference.kt new file mode 100644 index 0000000000..934b8c8614 --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/components/AnnouncementPreference.kt @@ -0,0 +1,194 @@ +package app.lawnchair.ui.preferences.components + +import android.content.Intent +import android.net.Uri +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Launch +import androidx.compose.material.icons.rounded.NewReleases +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.lawnchair.preferences2.asState +import app.lawnchair.ui.preferences.components.layout.ExpandAndShrink +import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate +import app.lawnchair.ui.preferences.data.liveinfo.liveInformationManager +import app.lawnchair.ui.preferences.data.liveinfo.model.Announcement +import app.lawnchair.ui.util.addIf +import com.android.launcher3.BuildConfig +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun AnnouncementPreference() { + val liveInformationManager = liveInformationManager() + + val enabled by liveInformationManager.enabled.asState() + val showAnnouncements by liveInformationManager.showAnnouncements.asState() + val liveInformation by liveInformationManager.liveInformation.asState() + + if (enabled && showAnnouncements) { + AnnouncementPreference( + announcements = liveInformation.announcementsImmutable, + ) + } +} + +@Composable +fun AnnouncementPreference( + announcements: ImmutableList, +) { + Column { + announcements.forEach { announcement -> + AnnouncementItem(announcement) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun AnnouncementItem( + announcement: Announcement, +) { + var show by remember { mutableStateOf(true) } + + ExpandAndShrink( + visible = show && announcement.active && + announcement.text.isNotBlank() && + (!announcement.test || BuildConfig.DEBUG), + ) { + AnnouncementItemContent( + text = announcement.text, + url = announcement.url, + onClose = { show = false }, + ) + } +} + +@Composable +private fun AnnouncementItemContent( + text: String, + url: String?, + onClose: () -> Unit, +) { + Surface( + modifier = Modifier + .padding(16.dp, 0.dp, 16.dp, 0.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + AnnouncementPreferenceItemContent(text = text, url = url, onClose = onClose) + } +} + +@Composable +private fun AnnouncementPreferenceItemContent( + text: String, + url: String?, + onClose: (() -> Unit)?, +) { + val context = LocalContext.current + val hasLink = !url.isNullOrBlank() + + PreferenceTemplate( + modifier = Modifier + .fillMaxWidth() + .addIf(hasLink) { + clickable { + val webpage = Uri.parse(url) + val intent = Intent(Intent.ACTION_VIEW, webpage) + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } + } + }, + title = {}, + description = { + Text( + modifier = Modifier.fillMaxWidth(), + text = text, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + }, + startWidget = { + Icon( + imageVector = Icons.Rounded.NewReleases, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + ) + }, + endWidget = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + if (hasLink) { + Icon( + imageVector = Icons.Rounded.Launch, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + if (onClose != null) { + IconButton( + onClick = onClose, + modifier = Modifier.size(16.dp).offset(x = (8).dp, y = (-16).dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + tint = MaterialTheme.colorScheme.surfaceTint, + contentDescription = null, + ) + } + } + } + }, + ) +} + +@Preview +@Composable +private fun InfoPreferenceWithoutLinkPreview() { + AnnouncementPreferenceItemContent( + text = "Very important announcement ", + url = "", + onClose = null, + ) +} + +@Preview +@Composable +private fun InfoPreferenceWithLinkPreview() { + AnnouncementPreferenceItemContent( + text = "Very important announcement with a very important link", + url = "https://lawnchair.app/", + onClose = null, + ) +} diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationManager.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationManager.kt new file mode 100644 index 0000000000..2913db7bfa --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationManager.kt @@ -0,0 +1,50 @@ +package app.lawnchair.ui.preferences.data.liveinfo + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import app.lawnchair.ui.preferences.data.liveinfo.model.LiveInformation +import com.android.launcher3.R +import com.android.launcher3.util.MainThreadInitializedObject +import com.patrykmichalik.opto.core.PreferenceManager +import kotlinx.serialization.json.Json + +class LiveInformationManager private constructor(context: Context) : PreferenceManager { + + companion object { + private val Context.preferencesDataStore by preferencesDataStore( + name = "live-information", + ) + + @JvmField + val INSTANCE = MainThreadInitializedObject(::LiveInformationManager) + + @JvmStatic + fun getInstance(context: Context) = INSTANCE.get(context)!! + } + + override val preferencesDataStore = context.preferencesDataStore + + val enabled = preference( + key = booleanPreferencesKey(name = "enabled"), + defaultValue = context.resources.getBoolean(R.bool.config_default_live_information_enabled), + ) + + val showAnnouncements = preference( + key = booleanPreferencesKey(name = "show_announcements"), + defaultValue = context.resources.getBoolean(R.bool.config_default_live_information_show_announcements), + ) + + val liveInformation = preference( + key = stringPreferencesKey(name = "live_information"), + defaultValue = LiveInformation.default, + parse = { Json.decodeFromString(it) }, + save = { Json.encodeToString(LiveInformation.serializer(), it) }, + ) +} + +@Composable +fun liveInformationManager() = LiveInformationManager.getInstance(LocalContext.current) diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationRequest.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationRequest.kt new file mode 100644 index 0000000000..9bdba5ae0a --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationRequest.kt @@ -0,0 +1,42 @@ +package app.lawnchair.ui.preferences.data.liveinfo + +import android.util.Log +import app.lawnchair.ui.preferences.data.liveinfo.model.LiveInformation +import app.lawnchair.util.kotlinxJson +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.create + +private val retrofit = Retrofit.Builder() + .baseUrl("https://lawnchair.app/") + .addConverterFactory(kotlinxJson.asConverterFactory("application/json".toMediaType())) + .build() + +val liveInformationService: LiveInformationService = retrofit.create() + +suspend fun getLiveInformation(): LiveInformation? = withContext(Dispatchers.IO) { + try { + val response: Response = liveInformationService.getLiveInformation() + + if (response.isSuccessful) { + val responseBody = response.body()?.string() ?: return@withContext null + + val liveInformation = Json.decodeFromString(responseBody) + Log.v("LiveInformation", "getLiveInformation: $liveInformation") + + return@withContext liveInformation + } else { + Log.d("LiveInformation", "getLiveInformation: response code ${response.code()}") + return@withContext null + } + } catch (e: Exception) { + Log.e("LiveInformation", "getLiveInformation: Error during news retrieval: ${e.message}") + return@withContext null + } +} diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationService.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationService.kt new file mode 100644 index 0000000000..4eea50eea1 --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/LiveInformationService.kt @@ -0,0 +1,11 @@ +package app.lawnchair.ui.preferences.data.liveinfo + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET + +interface LiveInformationService { + + @GET("live-information.json") + suspend fun getLiveInformation(): Response +} diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/SyncLiveInformation.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/SyncLiveInformation.kt new file mode 100644 index 0000000000..3fcc58368e --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/SyncLiveInformation.kt @@ -0,0 +1,26 @@ +package app.lawnchair.ui.preferences.data.liveinfo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import app.lawnchair.preferences2.asState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun SyncLiveInformation( + liveInformationManager: LiveInformationManager = liveInformationManager(), +) { + val enabled by liveInformationManager.enabled.asState() + + LaunchedEffect(enabled) { + if (enabled) { + CoroutineScope(Dispatchers.IO).launch { + getLiveInformation()?.let { liveInformation -> + liveInformationManager.liveInformation.set(liveInformation) + } + } + } + } +} diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/Announcement.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/Announcement.kt new file mode 100644 index 0000000000..93abe43754 --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/Announcement.kt @@ -0,0 +1,11 @@ +package app.lawnchair.ui.preferences.data.liveinfo.model + +import kotlinx.serialization.Serializable + +@Serializable +data class Announcement( + val text: String, + val url: String? = null, + val active: Boolean = true, + val test: Boolean = false, +) diff --git a/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/LiveInformation.kt b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/LiveInformation.kt new file mode 100644 index 0000000000..b119399315 --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/data/liveinfo/model/LiveInformation.kt @@ -0,0 +1,20 @@ +package app.lawnchair.ui.preferences.data.liveinfo.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.Serializable + +@Serializable +data class LiveInformation( + private val announcements: List, +) { + + val announcementsImmutable: ImmutableList + get() = announcements.toImmutableList() + + companion object { + val default = LiveInformation( + announcements = emptyList(), + ) + } +} diff --git a/lawnchair/src/app/lawnchair/ui/preferences/destinations/PreferencesDashboard.kt b/lawnchair/src/app/lawnchair/ui/preferences/destinations/PreferencesDashboard.kt index cac7196fab..3ead3f778e 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/destinations/PreferencesDashboard.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/destinations/PreferencesDashboard.kt @@ -36,12 +36,14 @@ import app.lawnchair.preferences.preferenceManager import app.lawnchair.ui.OverflowMenu import app.lawnchair.ui.preferences.LocalNavController import app.lawnchair.ui.preferences.Routes +import app.lawnchair.ui.preferences.components.AnnouncementPreference import app.lawnchair.ui.preferences.components.controls.WarningPreference import app.lawnchair.ui.preferences.components.layout.ClickableIcon import app.lawnchair.ui.preferences.components.layout.PreferenceCategory import app.lawnchair.ui.preferences.components.layout.PreferenceDivider import app.lawnchair.ui.preferences.components.layout.PreferenceLayout import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate +import app.lawnchair.ui.preferences.data.liveinfo.SyncLiveInformation import app.lawnchair.ui.preferences.subRoute import app.lawnchair.util.isDefaultLauncher import app.lawnchair.util.restartLauncher @@ -51,12 +53,16 @@ import com.android.launcher3.R @Composable fun PreferencesDashboard() { val context = LocalContext.current + SyncLiveInformation() + PreferenceLayout( label = stringResource(id = R.string.settings), verticalArrangement = Arrangement.Top, backArrowVisible = false, actions = { PreferencesOverflowMenu() }, ) { + AnnouncementPreference() + if (BuildConfig.DEBUG) PreferencesDebugWarning() if (!context.isDefaultLauncher()) { @@ -189,7 +195,7 @@ fun PreferencesDebugWarning() { ) { WarningPreference( // Don't move to strings.xml, no need to translate this warning - text = "Warning: You are currently using a development build. These builds WILL contain bugs, broken features, and unexpected crashes. Use at your own risk!", + text = "You are currently using a development build. Use at your own risk!", ) } }