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.content.ClipData
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.ColorFilter
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.platform.toClipEntry
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -54,80 +52,34 @@ import androidx.navigation.NavController
import chat.stoat.BuildConfig import chat.stoat.BuildConfig
import chat.stoat.R import chat.stoat.R
import chat.stoat.api.StoatJson import chat.stoat.api.StoatJson
import chat.stoat.api.routes.misc.Root
import chat.stoat.api.routes.misc.getRootRoute import chat.stoat.api.routes.misc.getRootRoute
import chat.stoat.composables.generic.PrimaryTabs
import chat.stoat.core.model.data.STOAT_BASE import chat.stoat.core.model.data.STOAT_BASE
import chat.stoat.internals.Platform import chat.stoat.internals.Platform
import chat.stoat.settings.dsl.SettingsPage
import chat.stoat.ui.theme.FragmentMono
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URI import java.net.URI
class AboutViewModel : ViewModel() { class AboutViewModel : ViewModel() {
var root by mutableStateOf<Root?>(null) var debugInfo by mutableStateOf<Map<String, String>>(emptyMap())
var selectedTabIndex by mutableIntStateOf(0) private set
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})"
)
}
init { init {
viewModelScope.launch { viewModelScope.launch {
root = getRootRoute().copy() 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,
@Composable "API Version" to it.revolt,
fun VersionItem(key: String, value: String, modifier: Modifier = Modifier) { "Runtime SDK" to Build.VERSION.SDK_INT.toString(),
Row(modifier) { "Model" to "${Build.MANUFACTURER} ${
Text( Build.DEVICE.replaceFirstChar {
text = key, if (it.isLowerCase()) it.titlecase() else it.toString()
color = MaterialTheme.colorScheme.onBackground.copy( }
alpha = 1.0f } (${Build.MODEL})"
), )
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)
} }
} }
} }
@ -140,109 +92,150 @@ fun AboutScreen(navController: NavController, viewModel: AboutViewModel = viewMo
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Scaffold( var debugInfoOpen by remember { mutableStateOf(false) }
topBar = { val debugInfoChevronRotation by animateFloatAsState(if (debugInfoOpen) 270f else 90f)
TopAppBar(
title = { SettingsPage(
Text( navController,
text = stringResource(R.string.about), title = {
maxLines = 1, Text(
overflow = TextOverflow.Ellipsis, text = stringResource(id = 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)
)
}
}
) )
}, }
) { pv -> ) {
Box(Modifier.padding(pv)) { Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .padding(top = 16.dp)
verticalArrangement = Arrangement.Center, .fillMaxWidth()
.padding(vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
PrimaryTabs( Image(
tabs = listOf( painter = painterResource(R.drawable.stoat_logo_white),
stringResource(R.string.about_tab_version), contentDescription = stringResource(R.string.about_full_name),
stringResource(R.string.about_tab_details) colorFilter = ColorFilter.tint(LocalContentColor.current),
), modifier = Modifier
currentIndex = viewModel.selectedTabIndex, .width(250.dp)
onTabSelected = { viewModel.selectedTabIndex = it }
) )
Spacer(modifier = Modifier.height(16.dp))
Column( Column(
modifier = Modifier verticalArrangement = Arrangement.spacedBy(4.dp),
.fillMaxWidth()
.fillMaxHeight()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (viewModel.root == null) { Text(
CircularProgressIndicator( text = stringResource(R.string.about_full_name),
modifier = Modifier style = MaterialTheme.typography.titleLarge,
.size(48.dp) 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 { Text(
when (viewModel.selectedTabIndex) { text = stringResource(R.string.about_section_debug_information_description),
0 -> { style = MaterialTheme.typography.bodySmall,
Image( lineHeight = 16.sp,
painter = painterResource(R.drawable.stoat_logo_white), fontWeight = FontWeight.Normal,
contentDescription = stringResource(R.string.about_full_name), color = LocalContentColor.current.copy(alpha = 0.7f)
colorFilter = ColorFilter.tint(LocalContentColor.current), )
modifier = Modifier }
.width(250.dp) 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( Column(
text = stringResource(R.string.about_full_name), verticalArrangement = Arrangement.spacedBy(8.dp),
style = MaterialTheme.typography.titleMedium, horizontalAlignment = Alignment.Start,
textAlign = TextAlign.Center 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(
text = BuildConfig.VERSION_NAME, text = value,
style = MaterialTheme.typography.labelMedium.copy( style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Normal fontWeight = FontWeight.Light,
), fontFamily = FragmentMono,
textAlign = TextAlign.Center textAlign = TextAlign.End
) ),
color = LocalContentColor.current.copy(alpha = 0.7f)
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
)
} }
1 -> { Spacer(Modifier.height(16.dp))
DebugInfo(viewModel)
TextButton(onClick = { TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = {
scope.launch { scope.launch {
clipboard.setClipEntry( clipboard.setClipEntry(
ClipData.newPlainText( ClipData.newPlainText(
"Stoat Debug Information", "Stoat Debug Information",
StoatJson.encodeToString(viewModel.getDebugInformation()) StoatJson.encodeToString(viewModel.debugInfo)
).toClipEntry() ).toClipEntry()
) )
@ -254,29 +247,67 @@ fun AboutScreen(navController: NavController, viewModel: AboutViewModel = viewMo
).show() ).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 modifier = Modifier
.clickable { navController.navigate("about/oss") }
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 30.dp), .padding(24.dp)
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
ElevatedButton( Column(
onClick = { navController.navigate("about/oss") }, modifier = Modifier.fillMaxWidth(0.7f),
modifier = Modifier verticalArrangement = Arrangement.spacedBy(4.dp)
.testTag("view_oss_attribution")
) { ) {
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">About</string>
<string name="about_full_name">Stoat for Android</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_brought_to_you_by">Brought to you with ❤️ by the Stoat team.</string>
<string name="about_tab_version">Version</string> <string name="about_section_debug_information">Debug Information</string>
<string name="about_tab_details">Details</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">OSS Licenses</string>
<string name="oss_attribution_tap_to_view_license">Tap to view license</string> <string name="oss_attribution_tap_to_view_license">Tap to view license</string>