parent
28232a33b4
commit
5ac4022eb5
@ -37,6 +37,7 @@ import de.mm20.launcher2.ui.locals.LocalWallpaperColors
|
||||
import de.mm20.launcher2.ui.overlays.OverlayHost
|
||||
import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.appearance.ExportThemeSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsScreen
|
||||
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
|
||||
@ -159,6 +160,9 @@ class SettingsActivity : BaseActivity() {
|
||||
composable("settings/appearance") {
|
||||
AppearanceSettingsScreen()
|
||||
}
|
||||
composable("settings/appearance/export") {
|
||||
ExportThemeSettingsScreen()
|
||||
}
|
||||
composable("settings/homescreen") {
|
||||
HomescreenSettingsScreen()
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package de.mm20.launcher2.ui.settings.appearance
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowCircleDown
|
||||
import androidx.compose.material.icons.rounded.ArrowCircleUp
|
||||
import androidx.compose.material.icons.rounded.CropSquare
|
||||
import androidx.compose.material.icons.rounded.Palette
|
||||
import androidx.compose.material.icons.rounded.TextFields
|
||||
@ -100,6 +102,22 @@ fun AppearanceSettingsScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceCategory {
|
||||
Preference(
|
||||
title = "Import",
|
||||
icon = Icons.Rounded.ArrowCircleDown,
|
||||
)
|
||||
Preference(
|
||||
title = "Export",
|
||||
icon = Icons.Rounded.ArrowCircleUp,
|
||||
onClick = {
|
||||
navController?.navigate("settings/appearance/export")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isAtLeastApiLevel(31)) {
|
||||
item {
|
||||
PreferenceCategory(stringResource(R.string.preference_category_advanced)) {
|
||||
|
||||
@ -0,0 +1,181 @@
|
||||
package de.mm20.launcher2.ui.settings.appearance
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CropSquare
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.Palette
|
||||
import androidx.compose.material.icons.rounded.Save
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SplitButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.mm20.launcher2.ui.R
|
||||
import de.mm20.launcher2.ui.component.preferences.ListPreference
|
||||
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
|
||||
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
|
||||
import de.mm20.launcher2.ui.component.preferences.TextPreference
|
||||
|
||||
@Composable
|
||||
fun ExportThemeSettingsScreen() {
|
||||
val viewModel = viewModel<ExportThemeSettingsScreenVM>()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val colorSchemes by viewModel.colorSchemes.collectAsState(emptyList())
|
||||
val shapeThemes by viewModel.shapeSchemes.collectAsState(emptyList())
|
||||
|
||||
val isValidSelection by remember {
|
||||
derivedStateOf {
|
||||
viewModel.colorScheme != null || viewModel.shapeScheme != null
|
||||
}
|
||||
}
|
||||
|
||||
val fileChooserLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.de.mm20.launcher2.theme")) {
|
||||
if (it != null) viewModel.exportTheme(context, it)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init()
|
||||
}
|
||||
PreferenceScreen(
|
||||
title = stringResource(R.string.theme_export_title)
|
||||
) {
|
||||
item {
|
||||
PreferenceCategory {
|
||||
TextPreference(
|
||||
stringResource(R.string.theme_bundle_name),
|
||||
value = viewModel.themeName,
|
||||
summary = viewModel.themeName.takeIf { it.isNotBlank() },
|
||||
onValueChanged = {
|
||||
viewModel.themeName = it
|
||||
}
|
||||
)
|
||||
TextPreference(
|
||||
stringResource(R.string.theme_bundle_author),
|
||||
value = viewModel.themeAuthor,
|
||||
summary = viewModel.themeAuthor.takeIf { it.isNotBlank() },
|
||||
onValueChanged = {
|
||||
viewModel.themeAuthor = it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
PreferenceCategory {
|
||||
ListPreference(
|
||||
stringResource(R.string.preference_screen_colors),
|
||||
icon = Icons.Rounded.Palette,
|
||||
value = viewModel.colorScheme,
|
||||
items = listOf(stringResource(R.string.no_selection) to null) + colorSchemes.map {
|
||||
it.name to it
|
||||
},
|
||||
onValueChanged = { newValue ->
|
||||
viewModel.setColorScheme(newValue)
|
||||
}
|
||||
)
|
||||
ListPreference(
|
||||
stringResource(R.string.preference_screen_shapes),
|
||||
icon = Icons.Rounded.CropSquare,
|
||||
value = viewModel.shapeScheme,
|
||||
items = listOf(stringResource(R.string.no_selection) to null) + shapeThemes.map {
|
||||
it.name to it
|
||||
},
|
||||
onValueChanged = { newValue ->
|
||||
viewModel.setShapeScheme(newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
PreferenceCategory {
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surface,
|
||||
MaterialTheme.shapes.extraSmall
|
||||
)
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(SplitButtonDefaults.Spacing)
|
||||
) {
|
||||
SplitButtonDefaults.LeadingButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = isValidSelection,
|
||||
onClick = {
|
||||
viewModel.shareTheme(context)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Share,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(end = ButtonDefaults.IconSpacing)
|
||||
.size(SplitButtonDefaults.LeadingIconSize),
|
||||
)
|
||||
Text(stringResource(R.string.menu_share))
|
||||
}
|
||||
SplitButtonDefaults.TrailingButton(
|
||||
onClick = {
|
||||
showDropdown = !showDropdown
|
||||
},
|
||||
enabled = isValidSelection,
|
||||
) {
|
||||
val rotation: Float by animateFloatAsState(
|
||||
targetValue = if (showDropdown) 180f else 0f,
|
||||
label = "Trailing Icon Rotation"
|
||||
)
|
||||
Icon(
|
||||
Icons.Rounded.KeyboardArrowDown,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SplitButtonDefaults.TrailingIconSize)
|
||||
.graphicsLayer {
|
||||
this.rotationZ = rotation
|
||||
},
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.save_as_file)) },
|
||||
onClick = {
|
||||
fileChooserLauncher.launch("${viewModel.themeName}.kvtheme")
|
||||
showDropdown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Rounded.Save, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package de.mm20.launcher2.ui.settings.appearance
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.mm20.launcher2.ktx.tryStartActivity
|
||||
import de.mm20.launcher2.themes.Colors
|
||||
import de.mm20.launcher2.themes.Shapes
|
||||
import de.mm20.launcher2.themes.ThemeBundle
|
||||
import de.mm20.launcher2.themes.ThemeRepository
|
||||
import de.mm20.launcher2.themes.toLegacyJson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
class ExportThemeSettingsScreenVM: ViewModel(), KoinComponent {
|
||||
|
||||
private val themeRepository: ThemeRepository by inject()
|
||||
|
||||
val colorSchemes = themeRepository.getAllColors().map { it.filter { !it.builtIn } }
|
||||
val shapeSchemes = themeRepository.getAllShapes().map { it.filter { !it.builtIn } }
|
||||
|
||||
var themeName by mutableStateOf("")
|
||||
var themeAuthor by mutableStateOf("")
|
||||
|
||||
|
||||
fun init() {
|
||||
themeName = ""
|
||||
}
|
||||
|
||||
var colorScheme by mutableStateOf<Colors?>(null)
|
||||
@JvmName("_setColorScheme")
|
||||
private set
|
||||
fun setColorScheme(scheme: Colors?) {
|
||||
if (themeName.isBlank() && scheme != null) themeName = scheme.name
|
||||
colorScheme = scheme
|
||||
}
|
||||
|
||||
var shapeScheme by mutableStateOf<Shapes?>(null)
|
||||
@JvmName("_setShapeScheme")
|
||||
private set
|
||||
fun setShapeScheme(scheme: Shapes?) {
|
||||
if (themeName.isBlank() && scheme != null) themeName = scheme.name
|
||||
shapeScheme = scheme
|
||||
}
|
||||
|
||||
private fun getThemeBundle(): ThemeBundle {
|
||||
return ThemeBundle(
|
||||
name = themeName,
|
||||
author = themeAuthor.takeIf { it.isNotBlank() },
|
||||
colors = colorScheme,
|
||||
shapes = shapeScheme,
|
||||
)
|
||||
}
|
||||
|
||||
fun exportTheme(context: Context, uri: Uri) {
|
||||
val themeBundle = getThemeBundle()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
context.contentResolver.openOutputStream(uri)?.writer()?.use {
|
||||
it.write(themeBundle.toJson())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareTheme(context: Context) {
|
||||
val themeBundle = getThemeBundle()
|
||||
viewModelScope.launch {
|
||||
val file = withContext(Dispatchers.IO) {
|
||||
val file = File(context.cacheDir, "${themeName}.kvtheme")
|
||||
file.writeText(themeBundle.toJson())
|
||||
file
|
||||
}
|
||||
context.tryStartActivity(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "application/json"
|
||||
putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(
|
||||
context,
|
||||
context.applicationContext.packageName + ".fileprovider",
|
||||
file
|
||||
))
|
||||
}.let { Intent.createChooser(it, null) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -159,7 +159,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
|
||||
)
|
||||
},
|
||||
defaultValue = systemPalette.primary,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
CorePaletteColorPreference(
|
||||
title = "Secondary",
|
||||
@ -174,7 +173,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
|
||||
)
|
||||
},
|
||||
defaultValue = systemPalette.secondary,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
autoGenerate = {
|
||||
theme!!.corePalette.primary?.let {
|
||||
CorePalette.of(it).a2.keyColor.toInt()
|
||||
@ -194,7 +192,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
|
||||
)
|
||||
},
|
||||
defaultValue = systemPalette.tertiary,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
autoGenerate = {
|
||||
theme!!.corePalette.primary?.let {
|
||||
CorePalette.of(it).a3.keyColor.toInt()
|
||||
@ -214,7 +211,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
|
||||
)
|
||||
},
|
||||
defaultValue = systemPalette.neutral,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
autoGenerate = {
|
||||
theme!!.corePalette.primary?.let {
|
||||
CorePalette.of(it).n1.keyColor.toInt()
|
||||
@ -234,7 +230,6 @@ fun ColorSchemeSettingsScreen(themeId: UUID) {
|
||||
)
|
||||
},
|
||||
defaultValue = systemPalette.neutralVariant,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
autoGenerate = {
|
||||
theme!!.corePalette.primary?.let {
|
||||
CorePalette.of(it).n2.keyColor.toInt()
|
||||
|
||||
@ -67,24 +67,13 @@ fun ColorSchemesSettingsScreen() {
|
||||
|
||||
var deleteColors by remember { mutableStateOf<Colors?>(null) }
|
||||
|
||||
var importThemeUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val importIntentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||
importThemeUri = it
|
||||
}
|
||||
|
||||
PreferenceScreen(
|
||||
title = stringResource(R.string.preference_screen_colors),
|
||||
topBarActions = {
|
||||
IconButton(onClick = { importIntentLauncher.launch(arrayOf("*/*")) }) {
|
||||
Icon(Icons.Rounded.Download, null)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { viewModel.createNew(context) }) {
|
||||
IconButton(onClick = { viewModel.createNew(context) }) {
|
||||
Icon(Icons.Rounded.Add, null)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
item {
|
||||
PreferenceCategory {
|
||||
@ -192,10 +181,6 @@ fun ColorSchemesSettingsScreen() {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (importThemeUri != null) {
|
||||
ImportThemeSheet(uri = importThemeUri!!, onDismiss = { importThemeUri = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -18,7 +18,6 @@ import androidx.compose.material.icons.rounded.RadioButtonUnchecked
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -43,7 +42,6 @@ import de.mm20.launcher2.ui.component.preferences.Preference
|
||||
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
|
||||
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
|
||||
import de.mm20.launcher2.ui.locals.LocalNavController
|
||||
import de.mm20.launcher2.ui.theme.shapes.shapesOf
|
||||
|
||||
@Composable
|
||||
fun ShapeSchemesSettingsScreen() {
|
||||
@ -58,11 +56,11 @@ fun ShapeSchemesSettingsScreen() {
|
||||
|
||||
PreferenceScreen(
|
||||
title = stringResource(R.string.preference_screen_shapes),
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { viewModel.createNew(context) }) {
|
||||
topBarActions = {
|
||||
IconButton(onClick = { viewModel.createNew(context) }) {
|
||||
Icon(Icons.Rounded.Add, null)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
item {
|
||||
PreferenceCategory {
|
||||
|
||||
@ -243,7 +243,9 @@
|
||||
<string name="easter_egg_text">Well, you found me. Congratulations. Was it worth it\?</string>
|
||||
<!-- Close a dialog -->
|
||||
<string name="close">Close</string>
|
||||
<string name="no_selection">No selection</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="save_as_file">Save as file</string>
|
||||
<string name="duplicate">Duplicate</string>
|
||||
<string name="edit">Edit</string>
|
||||
<string name="skip">Skip</string>
|
||||
@ -1029,4 +1031,7 @@
|
||||
<string name="departure_time_departed">departed</string>
|
||||
<string name="bad_configuration_title">Congratulations, you\'ve locked yourself out!</string>
|
||||
<string name="bad_configuration_summary">You\'ve discovered a combination of settings that makes both the search and settings inaccessible — effectively locking you out of the launcher.</string>
|
||||
<string name="theme_export_title">Export theme</string>
|
||||
<string name="theme_bundle_name">Name</string>
|
||||
<string name="theme_bundle_author">Author</string>
|
||||
</resources>
|
||||
@ -0,0 +1,69 @@
|
||||
package de.mm20.launcher2.themes
|
||||
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.serialization.Json
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
@Serializable
|
||||
data class ThemeBundle(
|
||||
val name: String,
|
||||
val author: String? = null,
|
||||
val colors: Colors? = null,
|
||||
val shapes: Shapes? = null,
|
||||
/**
|
||||
* The file version, always 2 for the new theme format.
|
||||
*/
|
||||
val version: Int = 2,
|
||||
) {
|
||||
fun toJson(): String {
|
||||
return Json.Lenient.encodeToString(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(jsonString: String): ThemeBundle? {
|
||||
try {
|
||||
val jsonElement = Json.Lenient.parseToJsonElement(jsonString).jsonObject
|
||||
|
||||
val version = (jsonElement.get("version") as? JsonPrimitive)?.intOrNull
|
||||
|
||||
if (version != 2) {
|
||||
return fromLegacyJson(jsonElement)
|
||||
}
|
||||
|
||||
return Json.Lenient.decodeFromJsonElement(jsonElement)
|
||||
|
||||
} catch (e: SerializationException) {
|
||||
CrashReporter.logException(e)
|
||||
return null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
CrashReporter.logException(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun fromLegacyJson(jsonElement: JsonElement): ThemeBundle? {
|
||||
try {
|
||||
val colorScheme: Colors = LegacyThemeJson.decodeFromJsonElement(jsonElement)
|
||||
return ThemeBundle(
|
||||
name = colorScheme.name,
|
||||
author = "",
|
||||
colors = colorScheme,
|
||||
shapes = null,
|
||||
version = 2,
|
||||
)
|
||||
} catch (e: SerializationException) {
|
||||
CrashReporter.logException(e)
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user