feat: profile settings (set pfp and background)

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-31 20:07:12 +01:00
parent fe024004cd
commit 771fc74cc1
12 changed files with 607 additions and 16 deletions

View File

@ -191,7 +191,6 @@ dependencies {
// Accompanist - Jetpack Compose Extensions // Accompanist - Jetpack Compose Extensions
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// KTOR - HTTP+WebSocket Library // KTOR - HTTP+WebSocket Library
implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-core:$ktor_version"

View File

@ -43,6 +43,7 @@ import chat.revolt.screens.settings.AppearanceSettingsScreen
import chat.revolt.screens.settings.ChangelogsSettingsScreen import chat.revolt.screens.settings.ChangelogsSettingsScreen
import chat.revolt.screens.settings.ClosedBetaUpdaterScreen import chat.revolt.screens.settings.ClosedBetaUpdaterScreen
import chat.revolt.screens.settings.DebugSettingsScreen import chat.revolt.screens.settings.DebugSettingsScreen
import chat.revolt.screens.settings.ProfileSettingsScreen
import chat.revolt.screens.settings.SessionSettingsScreen import chat.revolt.screens.settings.SessionSettingsScreen
import chat.revolt.screens.settings.SettingsScreen import chat.revolt.screens.settings.SettingsScreen
import chat.revolt.ui.theme.RevoltTheme import chat.revolt.ui.theme.RevoltTheme
@ -143,6 +144,7 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) {
composable("chat") { ChatRouterScreen(navController, windowSizeClass) } composable("chat") { ChatRouterScreen(navController, windowSizeClass) }
composable("settings") { SettingsScreen(navController) } composable("settings") { SettingsScreen(navController) }
composable("settings/profile") { ProfileSettingsScreen(navController) }
composable("settings/sessions") { SessionSettingsScreen(navController) } composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) } composable("settings/appearance") { AppearanceSettingsScreen(navController) }
composable("settings/debug") { DebugSettingsScreen(navController) } composable("settings/debug") { DebugSettingsScreen(navController) }

View File

@ -259,6 +259,13 @@ object RealtimeSocket {
val existing = RevoltAPI.userCache[userUpdateFrame.id] val existing = RevoltAPI.userCache[userUpdateFrame.id]
?: return // if we don't have the user no point in updating it ?: return // if we don't have the user no point in updating it
if (userUpdateFrame.clear != null) {
if (userUpdateFrame.clear.contains("Avatar")) {
RevoltAPI.userCache[userUpdateFrame.id] =
existing.copy(avatar = null)
}
}
RevoltAPI.userCache[userUpdateFrame.id] = RevoltAPI.userCache[userUpdateFrame.id] =
existing.mergeWithPartial(userUpdateFrame.data) existing.mergeWithPartial(userUpdateFrame.data)
} }

View File

@ -62,6 +62,9 @@ suspend fun uploadToAutumn(
val error = RevoltJson.decodeFromString(AutumnError.serializer(), response.bodyAsText()) val error = RevoltJson.decodeFromString(AutumnError.serializer(), response.bodyAsText())
throw Exception(error.type) throw Exception(error.type)
} catch (e: Exception) { } catch (e: Exception) {
if (response.status.value == 429) {
throw Exception("Rate limited")
}
throw Exception("Unknown error") throw Exception("Unknown error")
} }
} }

View File

@ -14,6 +14,7 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@ -41,12 +42,47 @@ suspend fun fetchSelf(): User {
return user return user
} }
suspend fun patchSelf(status: Status? = null, pure: Boolean = false) { suspend fun patchSelf(
status: Status? = null,
avatar: String? = null,
background: String? = null,
bio: String? = null,
remove: List<String>? = null,
pure: Boolean = false
) {
val body = mutableMapOf<String, JsonElement>() val body = mutableMapOf<String, JsonElement>()
if (status != null) { if (status != null) {
body["status"] = RevoltJson.encodeToJsonElement(Status.serializer(), status) body["status"] = RevoltJson.encodeToJsonElement(Status.serializer(), status)
} }
if (avatar != null) {
body["avatar"] = RevoltJson.encodeToJsonElement(String.serializer(), avatar)
}
if (background != null || bio != null) {
val profileMap = mutableMapOf<String, String>()
if (background != null) {
profileMap["background"] = background
}
if (bio != null) {
profileMap["bio"] = bio
}
body["profile"] = RevoltJson.encodeToJsonElement(
MapSerializer(
String.serializer(),
String.serializer()
),
profileMap
)
}
if (remove != null) {
body["remove"] = RevoltJson.encodeToJsonElement(ListSerializer(String.serializer()), remove)
}
val response = RevoltHttp.patch("/users/@me") { val response = RevoltHttp.patch("/users/@me") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody( setBody(

View File

@ -0,0 +1,179 @@
package chat.revolt.components.generic
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.revolt.R
import com.bumptech.glide.integration.compose.CrossFade
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
@Composable
fun InlineMediaPicker(
currentModel: Any?,
mimeType: String = "image/*",
circular: Boolean = false,
onPick: (Uri) -> Unit,
canRemove: Boolean = true,
onRemove: () -> Unit = {}
) {
if (circular) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
InlineMediaPickerMediaPicker(
currentModel = currentModel,
mimeType = mimeType,
circular = true,
onPick = onPick
)
if (canRemove) {
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
onRemove()
},
enabled = currentModel != null
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.inline_media_picker_remove)
)
}
}
}
} else {
Column {
InlineMediaPickerMediaPicker(
currentModel = currentModel,
mimeType = mimeType,
circular = false,
onPick = onPick
)
if (canRemove) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
onRemove()
},
enabled = currentModel != null,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.inline_media_picker_remove)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.inline_media_picker_remove),
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
fun InlineMediaPickerMediaPicker(
currentModel: Any?,
mimeType: String = "image/*",
circular: Boolean = false,
onPick: (Uri) -> Unit
) {
val documentsUiLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
onPick(uri)
}
}
if (currentModel != null) {
GlideImage(
model = currentModel,
contentDescription = stringResource(R.string.inline_media_picker_current_description),
contentScale = if (circular) ContentScale.Crop else ContentScale.FillWidth,
modifier = if (circular) {
Modifier
.clip(CircleShape)
.width(82.dp)
.height(82.dp)
} else {
Modifier
.clip(MaterialTheme.shapes.large)
.width(480.dp)
.height(140.dp)
}.clickable {
documentsUiLauncher.launch(mimeType)
},
transition = CrossFade,
)
} else {
Box(
modifier = if (circular) {
Modifier
.clip(CircleShape)
.width(82.dp)
.height(82.dp)
} else {
Modifier
.clip(MaterialTheme.shapes.large)
.width(480.dp)
.height(140.dp)
}
.clickable {
documentsUiLauncher.launch(mimeType)
}
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
if (circular) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.inline_media_picker_no_media_placeholder)
)
} else {
Text(
text = stringResource(R.string.inline_media_picker_no_media_placeholder),
style = MaterialTheme.typography.bodySmall.copy(
textAlign = TextAlign.Center
)
)
}
}
}
}

View File

@ -37,6 +37,7 @@ import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.solidColor import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.user.fetchUserProfile import chat.revolt.api.routes.user.fetchUserProfile
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.schemas.Profile import chat.revolt.api.schemas.Profile
import chat.revolt.api.schemas.User import chat.revolt.api.schemas.User
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
@ -70,7 +71,12 @@ fun UserOverview(user: User) {
} }
@Composable @Composable
fun RawUserOverview(user: User, profile: Profile? = null) { fun RawUserOverview(
user: User,
profile: Profile? = null,
pfpUrl: String? = null,
backgroundUrl: String? = null
) {
val context = LocalContext.current val context = LocalContext.current
var teamMemberFlair by remember { mutableStateOf<Brush?>(null) } var teamMemberFlair by remember { mutableStateOf<Brush?>(null) }
@ -104,9 +110,10 @@ fun RawUserOverview(user: User, profile: Profile? = null) {
} }
) )
) { ) {
profile?.background?.let { background -> (backgroundUrl ?: profile?.background)?.let { background ->
RemoteImage( RemoteImage(
url = "$REVOLT_FILES/backgrounds/${background.id}", url = backgroundUrl
?: "$REVOLT_FILES/backgrounds/${if (background is AutumnResource) background.id else null}",
description = null, description = null,
modifier = Modifier modifier = Modifier
.height(128.dp) .height(128.dp)
@ -137,6 +144,7 @@ fun RawUserOverview(user: User, profile: Profile? = null) {
) { ) {
UserAvatar( UserAvatar(
username = user.displayName ?: stringResource(id = R.string.unknown), username = user.displayName ?: stringResource(id = R.string.unknown),
rawUrl = pfpUrl,
userId = user.id ?: ULID.makeSpecial(0), userId = user.id ?: ULID.makeSpecial(0),
avatar = user.avatar, avatar = user.avatar,
size = 48.dp, size = 48.dp,

View File

@ -2,7 +2,10 @@ package chat.revolt.screens.settings
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
@ -29,7 +32,6 @@ import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.settings.appearance.ThemeChip import chat.revolt.components.screens.settings.appearance.ThemeChip
import chat.revolt.ui.theme.Theme import chat.revolt.ui.theme.Theme
import chat.revolt.ui.theme.systemSupportsDynamicColors import chat.revolt.ui.theme.systemSupportsDynamicColors
import com.google.accompanist.flowlayout.FlowRow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class AppearanceSettingsScreenViewModel : ViewModel() { class AppearanceSettingsScreenViewModel : ViewModel() {
@ -42,6 +44,7 @@ class AppearanceSettingsScreenViewModel : ViewModel() {
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun AppearanceSettingsScreen( fun AppearanceSettingsScreen(
navController: NavController, navController: NavController,
@ -80,14 +83,16 @@ fun AppearanceSettingsScreen(
) )
FlowRow( FlowRow(
mainAxisSpacing = 10.dp, horizontalArrangement = Arrangement.spacedBy(10.dp),
crossAxisSpacing = 10.dp verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
ThemeChip( ThemeChip(
color = Color(0xff1c243c), color = Color(0xff1c243c),
text = stringResource(id = R.string.settings_appearance_theme_revolt), text = stringResource(id = R.string.settings_appearance_theme_revolt),
selected = GlobalState.theme == Theme.Revolt, selected = GlobalState.theme == Theme.Revolt,
modifier = Modifier.weight(1f).testTag("set_theme_revolt") modifier = Modifier
.weight(1f)
.testTag("set_theme_revolt")
) { ) {
setNewTheme(Theme.Revolt) setNewTheme(Theme.Revolt)
} }
@ -96,7 +101,9 @@ fun AppearanceSettingsScreen(
color = Color(0xfff7f7f7), color = Color(0xfff7f7f7),
text = stringResource(id = R.string.settings_appearance_theme_light), text = stringResource(id = R.string.settings_appearance_theme_light),
selected = GlobalState.theme == Theme.Light, selected = GlobalState.theme == Theme.Light,
modifier = Modifier.weight(1f).testTag("set_theme_light") modifier = Modifier
.weight(1f)
.testTag("set_theme_light")
) { ) {
setNewTheme(Theme.Light) setNewTheme(Theme.Light)
} }
@ -105,7 +112,9 @@ fun AppearanceSettingsScreen(
color = Color(0xff000000), color = Color(0xff000000),
text = stringResource(id = R.string.settings_appearance_theme_amoled), text = stringResource(id = R.string.settings_appearance_theme_amoled),
selected = GlobalState.theme == Theme.Amoled, selected = GlobalState.theme == Theme.Amoled,
modifier = Modifier.weight(1f).testTag("set_theme_amoled") modifier = Modifier
.weight(1f)
.testTag("set_theme_amoled")
) { ) {
setNewTheme(Theme.Amoled) setNewTheme(Theme.Amoled)
} }
@ -114,7 +123,9 @@ fun AppearanceSettingsScreen(
color = if (isSystemInDarkTheme()) Color(0xff1c243c) else Color(0xfff7f7f7), color = if (isSystemInDarkTheme()) Color(0xff1c243c) else Color(0xfff7f7f7),
text = stringResource(id = R.string.settings_appearance_theme_none), text = stringResource(id = R.string.settings_appearance_theme_none),
selected = GlobalState.theme == Theme.None, selected = GlobalState.theme == Theme.None,
modifier = Modifier.weight(1f).testTag("set_theme_none") modifier = Modifier
.weight(1f)
.testTag("set_theme_none")
) { ) {
setNewTheme(Theme.None) setNewTheme(Theme.None)
} }
@ -124,7 +135,9 @@ fun AppearanceSettingsScreen(
color = dynamicDarkColorScheme(LocalContext.current).primary, color = dynamicDarkColorScheme(LocalContext.current).primary,
text = stringResource(id = R.string.settings_appearance_theme_m3dynamic), text = stringResource(id = R.string.settings_appearance_theme_m3dynamic),
selected = GlobalState.theme == Theme.M3Dynamic, selected = GlobalState.theme == Theme.M3Dynamic,
modifier = Modifier.weight(1f).testTag("set_theme_m3dynamic") modifier = Modifier
.weight(1f)
.testTag("set_theme_m3dynamic")
) { ) {
setNewTheme(Theme.M3Dynamic) setNewTheme(Theme.M3Dynamic)
} }
@ -135,7 +148,9 @@ fun AppearanceSettingsScreen(
id = R.string.settings_appearance_theme_m3dynamic_unsupported id = R.string.settings_appearance_theme_m3dynamic_unsupported
), ),
selected = false, selected = false,
modifier = Modifier.weight(1f).testTag("set_theme_m3dynamic_unsupported") modifier = Modifier
.weight(1f)
.testTag("set_theme_m3dynamic_unsupported")
) { ) {
Toast.makeText( Toast.makeText(
context, context,

View File

@ -0,0 +1,306 @@
package chat.revolt.screens.settings
import android.content.Context
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
import chat.revolt.api.routes.user.fetchUserProfile
import chat.revolt.api.routes.user.patchSelf
import chat.revolt.api.schemas.Profile
import chat.revolt.components.generic.InlineMediaPicker
import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.settings.RawUserOverview
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.http.ContentType
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@HiltViewModel
@Suppress("StaticFieldLeak")
class ProfileSettingsScreenViewModel @Inject constructor(@ApplicationContext val context: Context) :
ViewModel() {
var pfpModel by mutableStateOf<Any?>(null)
var currentProfile by mutableStateOf<Profile?>(null)
var pendingProfile by mutableStateOf<Profile?>(null)
var backgroundModel by mutableStateOf<Any?>(null)
var uploadProgress by mutableFloatStateOf(0f)
var uploadError by mutableStateOf<String?>(null)
init {
RevoltAPI.selfId?.let { self ->
RevoltAPI.userCache[self]?.avatar?.id?.let {
pfpModel = "https://autumn.revolt.chat/avatars/${it}"
}
viewModelScope.launch {
currentProfile = fetchUserProfile(self)
currentProfile!!.background?.id?.let {
backgroundModel = "https://autumn.revolt.chat/backgrounds/${it}"
}
pendingProfile = currentProfile!!.copy()
}
}
}
fun saveNewPfp() {
uploadError = null
val uri = when (pfpModel) {
is Uri -> pfpModel as Uri
is String -> Uri.parse(pfpModel as String)
else -> return
}
val mFile = File(context.cacheDir, uri.lastPathSegment ?: "avatar")
mFile.outputStream().use { output ->
context.contentResolver.openInputStream(uri)?.use { input ->
input.copyTo(output)
}
}
val mime = context.contentResolver.getType(uri)
if (mime?.endsWith("webp") == true) {
uploadError = "WebP is not supported"
return
}
viewModelScope.launch {
try {
val id = uploadToAutumn(
mFile,
uri.lastPathSegment ?: "avatar",
"avatars",
ContentType.parse(mime ?: "image/*"),
onProgress = { soFar, outOf ->
uploadProgress = soFar.toFloat() / outOf.toFloat()
}
)
patchSelf(avatar = id)
} catch (e: Exception) {
uploadError = e.message
uploadProgress = 0f
return@launch
}
pfpModel = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar?.id?.let {
"https://autumn.revolt.chat/avatars/${it}"
}
uploadProgress = 0f
}
}
fun saveNewBackground() {
uploadError = null
val uri = when (backgroundModel) {
is Uri -> backgroundModel as Uri
is String -> Uri.parse(backgroundModel as String)
else -> return
}
val mFile = File(context.cacheDir, uri.lastPathSegment ?: "background")
mFile.outputStream().use { output ->
context.contentResolver.openInputStream(uri)?.use { input ->
input.copyTo(output)
}
}
val mime = context.contentResolver.getType(uri)
if (mime?.endsWith("webp") == true) {
uploadError = "WebP is not supported"
return
}
viewModelScope.launch {
try {
val id = uploadToAutumn(
mFile,
uri.lastPathSegment ?: "background",
"backgrounds",
ContentType.parse(mime ?: "image/*"),
onProgress = { soFar, outOf ->
uploadProgress = soFar.toFloat() / outOf.toFloat()
}
)
patchSelf(background = id)
} catch (e: Exception) {
uploadError = e.message
uploadProgress = 0f
return@launch
}
backgroundModel = RevoltAPI.selfId?.let {
val profile = fetchUserProfile(it)
currentProfile = profile
pendingProfile = profile
profile.background?.id?.let {
"https://autumn.revolt.chat/backgrounds/${it}"
}
}
uploadProgress = 0f
}
}
fun removePfp() {
viewModelScope.launch {
patchSelf(remove = listOf("Avatar"))
pfpModel = null
}
}
fun removeBackground() {
viewModelScope.launch {
patchSelf(remove = listOf("ProfileBackground"))
pfpModel = null
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ProfileSettingsScreen(
navController: NavController,
viewModel: ProfileSettingsScreenViewModel = hiltViewModel()
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
) {
PageHeader(
text = stringResource(id = R.string.settings_profile),
showBackButton = true,
onBackButtonClicked = {
navController.popBackStack()
}
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
RevoltAPI.userCache[RevoltAPI.selfId]?.let {
RawUserOverview(
it,
viewModel.pendingProfile,
viewModel.pfpModel?.toString(),
viewModel.backgroundModel?.toString()
)
}
AnimatedVisibility(visible = viewModel.uploadProgress > 0f) {
LinearProgressIndicator(
progress = viewModel.uploadProgress,
modifier = Modifier
.fillMaxSize()
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp)
)
}
AnimatedVisibility(visible = viewModel.uploadError != null) {
Text(
text = viewModel.uploadError ?: "",
style = MaterialTheme.typography.labelLarge.copy(
color = MaterialTheme.colorScheme.error
),
modifier = Modifier
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp)
)
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(
modifier = Modifier
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp)
) {
Text(
text = stringResource(id = R.string.settings_profile_profile_picture),
style = MaterialTheme.typography.labelLarge
)
Spacer(Modifier.height(10.dp))
InlineMediaPicker(
currentModel = viewModel.pfpModel,
circular = true,
onPick = {
viewModel.pfpModel = it.toString()
viewModel.saveNewPfp()
},
canRemove = true,
onRemove = {
viewModel.removePfp()
}
)
}
Column(
modifier = Modifier
.padding(20.dp)
) {
Text(
text = stringResource(id = R.string.settings_profile_custom_background),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.height(10.dp))
InlineMediaPicker(
currentModel = viewModel.backgroundModel,
onPick = {
viewModel.backgroundModel = it.toString()
viewModel.saveNewBackground()
},
canRemove = true,
onRemove = {
viewModel.removeBackground()
}
)
}
}
}
}
}

View File

@ -33,8 +33,8 @@ import chat.revolt.components.generic.SheetClickable
import chat.revolt.components.screens.settings.SelfUserOverview import chat.revolt.components.screens.settings.SelfUserOverview
import chat.revolt.persistence.KVStorage import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsScreenViewModel @Inject constructor( class SettingsScreenViewModel @Inject constructor(
@ -85,6 +85,25 @@ fun SettingsScreen(
modifier = Modifier.padding(bottom = 10.dp, start = 10.dp, top = 20.dp) modifier = Modifier.padding(bottom = 10.dp, start = 10.dp, top = 20.dp)
) )
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(R.drawable.ic_card_account_details_24dp),
contentDescription = stringResource(id = R.string.settings_profile),
modifier = modifier
)
},
label = { textStyle ->
Text(
text = stringResource(id = R.string.settings_profile),
style = textStyle
)
},
modifier = Modifier.testTag("settings_view_profile")
) {
navController.navigate("settings/profile")
}
SheetClickable( SheetClickable(
icon = { modifier -> icon = { modifier ->
Icon( Icon(

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M2,3H22C23.05,3 24,3.95 24,5V19C24,20.05 23.05,21 22,21H2C0.95,21 0,20.05 0,19V5C0,3.95 0.95,3 2,3M14,6V7H22V6H14M14,8V9H21.5L22,9V8H14M14,10V11H21V10H14M8,13.91C6,13.91 2,15 2,17V18H14V17C14,15 10,13.91 8,13.91M8,6A3,3 0 0,0 5,9A3,3 0 0,0 8,12A3,3 0 0,0 11,9A3,3 0 0,0 8,6Z" />
</vector>

View File

@ -367,6 +367,10 @@
<string name="file_picker_chip_documents">Attach a file</string> <string name="file_picker_chip_documents">Attach a file</string>
<string name="file_picker_chip_camera">Take a photo</string> <string name="file_picker_chip_camera">Take a photo</string>
<string name="inline_media_picker_current_description">Currently selected media</string>
<string name="inline_media_picker_no_media_placeholder">Pick media…</string>
<string name="inline_media_picker_remove">Remove</string>
<string name="emoji_picker_close_skin_tone_menu">Close skin tone menu</string> <string name="emoji_picker_close_skin_tone_menu">Close skin tone menu</string>
<string name="emoji_picker_skin_tone_none">No skin tone</string> <string name="emoji_picker_skin_tone_none">No skin tone</string>
<string name="emoji_picker_skin_tone_fitzpatrick_1_2">Light skin tone</string> <string name="emoji_picker_skin_tone_fitzpatrick_1_2">Light skin tone</string>
@ -391,6 +395,10 @@
<string name="settings_category_miscellaneous">Miscellaneous</string> <string name="settings_category_miscellaneous">Miscellaneous</string>
<string name="settings_category_last" translatable="false">Revolt v%1$s</string> <string name="settings_category_last" translatable="false">Revolt v%1$s</string>
<string name="settings_profile">Profile</string>
<string name="settings_profile_profile_picture">Profile picture</string>
<string name="settings_profile_custom_background">Custom background</string>
<string name="settings_sessions">Sessions</string> <string name="settings_sessions">Sessions</string>
<string name="settings_sessions_this_device">This Device</string> <string name="settings_sessions_this_device">This Device</string>
<string name="settings_sessions_this_device_unavailable">The session of this device is unavailable. Please **log in again** to view your current session.</string> <string name="settings_sessions_this_device_unavailable">The session of this device is unavailable. Please **log in again** to view your current session.</string>