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 <werdna.jac@gmail.com>
This commit is contained in:
Yasan
2024-03-10 11:08:26 +01:00
committed by GitHub
parent 2e43b69a0c
commit 60ca3a90fe
9 changed files with 363 additions and 1 deletions

View File

@@ -115,6 +115,8 @@
<bool name="config_default_smartspace_show_date">true</bool>
<bool name="config_default_smartspace_show_time">false</bool>
<bool name="config_default_perform_wide_search">true</bool>
<bool name="config_default_live_information_enabled">true</bool>
<bool name="config_default_live_information_show_announcements">true</bool>
<bool name="config_default_enable_dot_pagination">true</bool>
<bool name="config_default_enable_material_u_popup">true</bool>

View File

@@ -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<Announcement>,
) {
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,
)
}

View File

@@ -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<LiveInformation>(it) },
save = { Json.encodeToString(LiveInformation.serializer(), it) },
)
}
@Composable
fun liveInformationManager() = LiveInformationManager.getInstance(LocalContext.current)

View File

@@ -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<ResponseBody> = liveInformationService.getLiveInformation()
if (response.isSuccessful) {
val responseBody = response.body()?.string() ?: return@withContext null
val liveInformation = Json.decodeFromString<LiveInformation>(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
}
}

View File

@@ -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<ResponseBody>
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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,
)

View File

@@ -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<Announcement>,
) {
val announcementsImmutable: ImmutableList<Announcement>
get() = announcements.toImmutableList()
companion object {
val default = LiveInformation(
announcements = emptyList(),
)
}
}

View File

@@ -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!",
)
}
}