feat: revamp about screen

This commit is contained in:
infi 2026-05-28 04:06:27 +02:00
parent e9b16f2a28
commit 032e233ac7
3 changed files with 213 additions and 169 deletions

View File

@ -3,43 +3,39 @@ package chat.stoat.screens.about
import android.content.ClipData
import android.os.Build
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.toClipEntry
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -47,6 +43,8 @@ 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 androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
@ -54,80 +52,34 @@ import androidx.navigation.NavController
import chat.stoat.BuildConfig
import chat.stoat.R
import chat.stoat.api.StoatJson
import chat.stoat.api.routes.misc.Root
import chat.stoat.api.routes.misc.getRootRoute
import chat.stoat.composables.generic.PrimaryTabs
import chat.stoat.core.model.data.STOAT_BASE
import chat.stoat.internals.Platform
import chat.stoat.settings.dsl.SettingsPage
import chat.stoat.ui.theme.FragmentMono
import kotlinx.coroutines.launch
import java.net.URI
class AboutViewModel : ViewModel() {
var root by mutableStateOf<Root?>(null)
var selectedTabIndex by mutableIntStateOf(0)
fun getDebugInformation(): Map<String, String> {
return mapOf(
"App Version" to BuildConfig.VERSION_NAME,
"App Type" to BuildConfig.FLAVOUR_ID,
"API Host" to URI(STOAT_BASE).host,
"API Version" to (root?.revolt ?: "Unknown"),
"Runtime SDK" to Build.VERSION.SDK_INT.toString(),
"Model" to "${Build.MANUFACTURER} ${
Build.DEVICE.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
} (${Build.MODEL})"
)
}
var debugInfo by mutableStateOf<Map<String, String>>(emptyMap())
private set
init {
viewModelScope.launch {
root = getRootRoute().copy()
}
}
}
@Composable
fun VersionItem(key: String, value: String, modifier: Modifier = Modifier) {
Row(modifier) {
Text(
text = key,
color = MaterialTheme.colorScheme.onBackground.copy(
alpha = 1.0f
),
style = MaterialTheme.typography.titleMedium.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold
),
modifier = Modifier
.padding(horizontal = 2.5.dp, vertical = 2.5.dp)
)
Text(
text = value,
color = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.9f
),
style = MaterialTheme.typography.titleMedium.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Normal
),
modifier = Modifier
.padding(horizontal = 2.5.dp, vertical = 2.5.dp)
)
}
}
@Composable
fun DebugInfo(viewModel: AboutViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
viewModel.getDebugInformation().forEach { (key, value) ->
VersionItem(key, value)
runCatching { getRootRoute() }.getOrNull()?.let {
debugInfo = mapOf(
"App Version" to BuildConfig.VERSION_NAME,
"App Type" to BuildConfig.FLAVOUR_ID,
"API Host" to URI(STOAT_BASE).host,
"API Version" to it.revolt,
"Runtime SDK" to Build.VERSION.SDK_INT.toString(),
"Model" to "${Build.MANUFACTURER} ${
Build.DEVICE.replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
} (${Build.MODEL})"
)
}
}
}
}
@ -140,109 +92,150 @@ fun AboutScreen(navController: NavController, viewModel: AboutViewModel = viewMo
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.about),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_24dp),
contentDescription = stringResource(id = R.string.back)
)
}
}
var debugInfoOpen by remember { mutableStateOf(false) }
val debugInfoChevronRotation by animateFloatAsState(if (debugInfoOpen) 270f else 90f)
SettingsPage(
navController,
title = {
Text(
text = stringResource(id = R.string.about),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
) { pv ->
Box(Modifier.padding(pv)) {
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
.padding(top = 16.dp)
.fillMaxWidth()
.padding(vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
PrimaryTabs(
tabs = listOf(
stringResource(R.string.about_tab_version),
stringResource(R.string.about_tab_details)
),
currentIndex = viewModel.selectedTabIndex,
onTabSelected = { viewModel.selectedTabIndex = it }
Image(
painter = painterResource(R.drawable.stoat_logo_white),
contentDescription = stringResource(R.string.about_full_name),
colorFilter = ColorFilter.tint(LocalContentColor.current),
modifier = Modifier
.width(250.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.weight(1f),
verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (viewModel.root == null) {
CircularProgressIndicator(
modifier = Modifier
.size(48.dp)
Text(
text = stringResource(R.string.about_full_name),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
Text(
text = BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Normal
),
textAlign = TextAlign.Center
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surfaceContainer),
horizontalAlignment = Alignment.Start
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.clickable { debugInfoOpen = !debugInfoOpen }
.fillMaxWidth()
.padding(24.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(0.7f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(R.string.about_section_debug_information),
style = MaterialTheme.typography.titleMedium
)
} else {
when (viewModel.selectedTabIndex) {
0 -> {
Image(
painter = painterResource(R.drawable.stoat_logo_white),
contentDescription = stringResource(R.string.about_full_name),
colorFilter = ColorFilter.tint(LocalContentColor.current),
modifier = Modifier
.width(250.dp)
)
Text(
text = stringResource(R.string.about_section_debug_information_description),
style = MaterialTheme.typography.bodySmall,
lineHeight = 16.sp,
fontWeight = FontWeight.Normal,
color = LocalContentColor.current.copy(alpha = 0.7f)
)
}
Spacer(Modifier.weight(1f))
Icon(
painter = painterResource(R.drawable.ic_chevron_forward_24dp),
contentDescription = null,
tint = LocalContentColor.current.copy(alpha = 0.5f),
modifier = Modifier.rotate(debugInfoChevronRotation)
)
}
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(visible = debugInfoOpen) {
Column(Modifier.padding(horizontal = 8.dp)) {
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.about_full_name),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start,
modifier = Modifier.padding(horizontal = 16.dp)
) {
viewModel.debugInfo.forEach { (key, value) ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = key,
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Medium,
letterSpacing = 0.05.em,
textAlign = TextAlign.Start
)
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(Modifier.weight(1f))
Text(
text = BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Normal
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = stringResource(R.string.about_brought_to_you_by),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Light
),
color = LocalContentColor.current.copy(
alpha = 0.5f
),
textAlign = TextAlign.Center
)
Text(
text = value,
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Light,
fontFamily = FragmentMono,
textAlign = TextAlign.End
),
color = LocalContentColor.current.copy(alpha = 0.7f)
)
}
}
1 -> {
DebugInfo(viewModel)
TextButton(onClick = {
Spacer(Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipData.newPlainText(
"Stoat Debug Information",
StoatJson.encodeToString(viewModel.getDebugInformation())
StoatJson.encodeToString(viewModel.debugInfo)
).toClipEntry()
)
@ -254,29 +247,67 @@ fun AboutScreen(navController: NavController, viewModel: AboutViewModel = viewMo
).show()
}
}
}) {
Text(text = stringResource(id = R.string.copy))
}
) {
Icon(
painter = painterResource(R.drawable.ic_content_copy_24dp),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(text = stringResource(id = R.string.about_section_debug_information_copy_as_json))
}
Spacer(Modifier.height(8.dp))
}
}
}
}
Column(
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surfaceContainer),
horizontalAlignment = Alignment.Start
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.clickable { navController.navigate("about/oss") }
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally
.padding(24.dp)
) {
ElevatedButton(
onClick = { navController.navigate("about/oss") },
modifier = Modifier
.testTag("view_oss_attribution")
Column(
modifier = Modifier.fillMaxWidth(0.7f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(text = stringResource(id = R.string.oss_attribution))
Text(
text = stringResource(R.string.about_section_oss_licenses),
style = MaterialTheme.typography.titleMedium
)
}
Spacer(Modifier.weight(1f))
Icon(
painter = painterResource(R.drawable.ic_chevron_forward_24dp),
contentDescription = null,
tint = LocalContentColor.current.copy(alpha = 0.5f)
)
}
}
Text(
text = stringResource(R.string.about_brought_to_you_by),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Light
),
color = LocalContentColor.current.copy(
alpha = 0.5f
),
textAlign = TextAlign.Center
)
}
}
}

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"/>
</vector>

View File

@ -87,8 +87,10 @@
<string name="about">About</string>
<string name="about_full_name">Stoat for Android</string>
<string name="about_brought_to_you_by">Brought to you with ❤️ by the Stoat team.</string>
<string name="about_tab_version">Version</string>
<string name="about_tab_details">Details</string>
<string name="about_section_debug_information">Debug Information</string>
<string name="about_section_debug_information_description">If you\'re experiencing issues with the app, this information may be helpful to include when contacting support or submitting a bug report.</string>
<string name="about_section_debug_information_copy_as_json">Copy as JSON</string>
<string name="about_section_oss_licenses">Open Source Licenses</string>
<string name="oss_attribution">OSS Licenses</string>
<string name="oss_attribution_tap_to_view_license">Tap to view license</string>