feat: import and export theme override (rato) files

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-11-01 00:28:23 +01:00
parent 35c8976e30
commit 3ef9ae2fe9
6 changed files with 169 additions and 5 deletions

View File

@ -171,7 +171,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:1.9.10" implementation "org.jetbrains.kotlin:kotlin-reflect:1.9.10"
// Kotlinx - various first-party extensions for Kotlin // Kotlinx - various first-party extensions for Kotlin
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.6.0"
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0" implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0"
// Compose BOM // Compose BOM

View File

@ -9,7 +9,6 @@ import chat.revolt.api.internals.Members
import chat.revolt.api.realtime.DisconnectionState import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.user.fetchSelf import chat.revolt.api.routes.user.fetchSelf
import chat.revolt.api.schemas.Channel as ChannelSchema
import chat.revolt.api.schemas.Emoji import chat.revolt.api.schemas.Emoji
import chat.revolt.api.schemas.Message import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.Server import chat.revolt.api.schemas.Server
@ -38,7 +37,9 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import chat.revolt.api.schemas.Channel as ChannelSchema
const val REVOLT_BASE = "https://api.revolt.chat" const val REVOLT_BASE = "https://api.revolt.chat"
const val REVOLT_SUPPORT = "https://support.revolt.chat" const val REVOLT_SUPPORT = "https://support.revolt.chat"
@ -61,6 +62,11 @@ val RevoltJson = Json {
explicitNulls = false explicitNulls = false
} }
@OptIn(ExperimentalSerializationApi::class)
val RevoltCbor = Cbor {
ignoreUnknownKeys = true
}
val RevoltHttp = HttpClient(OkHttp) { val RevoltHttp = HttpClient(OkHttp) {
install(DefaultRequest) install(DefaultRequest)
install(ContentNegotiation) { install(ContentNegotiation) {

View File

@ -1,6 +1,10 @@
package chat.revolt.screens.settings package chat.revolt.screens.settings
import android.content.Context
import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -51,14 +55,16 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.RevoltCbor
import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings import chat.revolt.api.settings.SyncedSettings
import chat.revolt.components.generic.PageHeader import chat.revolt.components.generic.PageHeader
@ -71,11 +77,22 @@ import com.github.skydoves.colorpicker.compose.BrightnessSlider
import com.github.skydoves.colorpicker.compose.ColorEnvelope import com.github.skydoves.colorpicker.compose.ColorEnvelope
import com.github.skydoves.colorpicker.compose.HsvColorPicker import com.github.skydoves.colorpicker.compose.HsvColorPicker
import com.github.skydoves.colorpicker.compose.rememberColorPickerController import com.github.skydoves.colorpicker.compose.rememberColorPickerController
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import java.io.File
import javax.inject.Inject
import kotlin.reflect.KVisibility import kotlin.reflect.KVisibility
import kotlin.reflect.full.memberProperties import kotlin.reflect.full.memberProperties
class AppearanceSettingsScreenViewModel : ViewModel() { @HiltViewModel
@Suppress("StaticFieldLeak")
class AppearanceSettingsScreenViewModel @Inject constructor(
@ApplicationContext val context: Context
) : ViewModel() {
var showColourOverrides by mutableStateOf(false) var showColourOverrides by mutableStateOf(false)
var selectedOverrideName by mutableStateOf<String?>(null) var selectedOverrideName by mutableStateOf<String?>(null)
var selectedOverrideInitialValue by mutableStateOf<Int?>(null) var selectedOverrideInitialValue by mutableStateOf<Int?>(null)
@ -116,13 +133,72 @@ class AppearanceSettingsScreenViewModel : ViewModel() {
} }
} }
} }
private fun validOverrideKey(key: String): Boolean {
return ColorScheme::class.memberProperties.any { it.name == key }
}
private fun applyBulkOverrides(overrides: Map<String, Int>) {
val existingOverrides = SyncedSettings.android.colourOverrides ?: mapOf()
val newOverrides = existingOverrides.toMutableMap()
newOverrides.putAll(overrides.filterKeys { validOverrideKey(it) })
viewModelScope.launch {
SyncedSettings.updateAndroid(
SyncedSettings.android.copy(
colourOverrides = newOverrides
)
)
}
}
@OptIn(ExperimentalSerializationApi::class)
fun processImportedOverrides(uri: Uri) {
val mFile = File(context.cacheDir, uri.lastPathSegment ?: "temp")
mFile.outputStream().use { outputStream ->
context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
try {
RevoltCbor.decodeFromByteArray(
MapSerializer(String.serializer(), Int.serializer()),
mFile.readBytes()
).let {
applyBulkOverrides(it)
}
} catch (e: Exception) {
Toast.makeText(
context,
context.getString(R.string.settings_appearance_colour_overrides_import_error),
Toast.LENGTH_SHORT
).show()
}
mFile.delete()
}
@OptIn(ExperimentalSerializationApi::class)
fun saveOverridesToFile(uri: Uri) {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(
RevoltCbor.encodeToByteArray(
MapSerializer(String.serializer(), Int.serializer()),
SyncedSettings.android.colourOverrides ?: mapOf()
)
)
}
}
} }
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppearanceSettingsScreen( fun AppearanceSettingsScreen(
navController: NavController, navController: NavController,
viewModel: AppearanceSettingsScreenViewModel = viewModel() viewModel: AppearanceSettingsScreenViewModel = hiltViewModel()
) { ) {
val colourOverridesOpenerArrowRotation by animateFloatAsState( val colourOverridesOpenerArrowRotation by animateFloatAsState(
if (viewModel.showColourOverrides) { if (viewModel.showColourOverrides) {
@ -131,6 +207,21 @@ fun AppearanceSettingsScreen(
label = "colourOverridesOpenerArrowRotation" label = "colourOverridesOpenerArrowRotation"
) )
val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
if (uri != null) {
viewModel.processImportedOverrides(uri)
}
}
val fileSaver = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/x-revolt-android-theme-overrides"),
) { uri ->
if (uri != null) {
viewModel.saveOverridesToFile(uri)
}
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -314,6 +405,53 @@ fun AppearanceSettingsScreen(
AnimatedVisibility(viewModel.showColourOverrides) { AnimatedVisibility(viewModel.showColourOverrides) {
Column { Column {
Spacer(modifier = Modifier.height(10.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
TextButton(
onClick = {
filePicker.launch(arrayOf("*/*"))
},
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_folder_24dp),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.settings_appearance_colour_overrides_import)
)
}
TextButton(
onClick = {
fileSaver.launch("${SyncedSettings.android.theme}-colours.rato")
},
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_content_save_24dp),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.settings_appearance_colour_overrides_export)
)
}
}
Spacer(modifier = Modifier.height(10.dp))
ColorScheme::class.memberProperties.forEach { member -> ColorScheme::class.memberProperties.forEach { member ->
if (member.visibility != KVisibility.PUBLIC) return@forEach if (member.visibility != KVisibility.PUBLIC) return@forEach

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="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z" />
</vector>

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="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" />
</vector>

View File

@ -455,6 +455,7 @@
<string name="settings_appearance_colour_overrides_reset">Reset</string> <string name="settings_appearance_colour_overrides_reset">Reset</string>
<string name="settings_appearance_colour_overrides_export">Export</string> <string name="settings_appearance_colour_overrides_export">Export</string>
<string name="settings_appearance_colour_overrides_import">Import</string> <string name="settings_appearance_colour_overrides_import">Import</string>
<string name="settings_appearance_colour_overrides_import_error">This file is not a valid colour override file.</string>
<string name="settings_feedback">Feedback</string> <string name="settings_feedback">Feedback</string>
<string name="settings_feedback_introduction">Any feedback you have for Revolt is greatly appreciated and all feedback is read by the development team of our Android app.</string> <string name="settings_feedback_introduction">Any feedback you have for Revolt is greatly appreciated and all feedback is read by the development team of our Android app.</string>