Add import for new color scheme format

This commit is contained in:
MM20
2025-06-07 14:06:09 +02:00
parent f21feba000
commit 889aa37915
15 changed files with 642 additions and 64 deletions
+1
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.kotlin.plugin.compose)
}
@@ -25,7 +25,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -46,7 +45,6 @@ import androidx.compose.ui.unit.dp
import de.mm20.launcher2.preferences.SearchBarStyle
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.layout.BottomReversed
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme
@Composable
@@ -61,6 +59,7 @@ fun SearchBar(
onUnfocus: () -> Unit = {},
reverse: Boolean = false,
darkColors: Boolean = false,
readOnly: Boolean = false,
menu: @Composable RowScope.() -> Unit = {},
actions: @Composable ColumnScope.() -> Unit = {},
onKeyboardActionGo: (KeyboardActionScope.() -> Unit)? = null
@@ -190,7 +189,8 @@ fun SearchBar(
),
keyboardActions = KeyboardActions(
onGo = onKeyboardActionGo,
)
),
readOnly = readOnly,
)
}
Row(
@@ -6,6 +6,7 @@ import de.mm20.launcher2.ui.base.BaseActivity
import de.mm20.launcher2.ui.base.ProvideSettings
import de.mm20.launcher2.ui.common.ImportThemeSheet
import de.mm20.launcher2.ui.overlays.OverlayHost
import de.mm20.launcher2.ui.settings.appearance.ImportThemeSettingsScreen
import de.mm20.launcher2.ui.theme.LauncherTheme
class ImportThemeActivity : BaseActivity() {
@@ -19,10 +20,7 @@ class ImportThemeActivity : BaseActivity() {
LauncherTheme {
ProvideSettings {
OverlayHost {
ImportThemeSheet(
onDismiss = { finish() },
uri = uri,
)
ImportThemeSettingsScreen(uri)
}
}
}
@@ -1,6 +1,7 @@
package de.mm20.launcher2.ui.settings
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
@@ -22,11 +23,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.core.net.toUri
import androidx.core.view.WindowCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.toRoute
import de.mm20.launcher2.licenses.AppLicense
import de.mm20.launcher2.licenses.OpenSourceLicenses
import de.mm20.launcher2.ui.base.BaseActivity
@@ -38,6 +41,8 @@ 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.appearance.ImportThemeSettingsRoute
import de.mm20.launcher2.ui.settings.appearance.ImportThemeSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
@@ -163,6 +168,10 @@ class SettingsActivity : BaseActivity() {
composable("settings/appearance/export") {
ExportThemeSettingsScreen()
}
composable<ImportThemeSettingsRoute> {
val route: ImportThemeSettingsRoute = it.toRoute() ?: return@composable
ImportThemeSettingsScreen(route.fromUri.toUri())
}
composable("settings/homescreen") {
HomescreenSettingsScreen()
}
@@ -1,5 +1,7 @@
package de.mm20.launcher2.ui.settings.appearance
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowCircleDown
import androidx.compose.material.icons.rounded.ArrowCircleUp
@@ -35,6 +37,14 @@ fun AppearanceSettingsScreen() {
val colorThemeName by viewModel.colorThemeName.collectAsStateWithLifecycle(null)
val shapeThemeName by viewModel.shapeThemeName.collectAsStateWithLifecycle(null)
val compatModeColors by viewModel.compatModeColors.collectAsState()
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
if (it == null) {
return@rememberLauncherForActivityResult
}
navController?.navigate(ImportThemeSettingsRoute(it.toString()))
}
PreferenceScreen(title = stringResource(id = R.string.preference_screen_appearance)) {
item {
PreferenceCategory {
@@ -105,11 +115,14 @@ fun AppearanceSettingsScreen() {
item {
PreferenceCategory {
Preference(
title = "Import",
title = stringResource(R.string.theme_import_title),
icon = Icons.Rounded.ArrowCircleDown,
onClick = {
importLauncher.launch(arrayOf("*/*"))
}
)
Preference(
title = "Export",
title = stringResource(R.string.theme_export_title),
icon = Icons.Rounded.ArrowCircleUp,
onClick = {
navController?.navigate("settings/appearance/export")
@@ -0,0 +1,350 @@
package de.mm20.launcher2.ui.settings.appearance
import android.net.Uri
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background
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.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.ChangeCircle
import androidx.compose.material.icons.rounded.CropSquare
import androidx.compose.material.icons.rounded.DarkMode
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.LightMode
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.PublishedWithChanges
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.Upgrade
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.innerShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.InnerShadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.preferences.SearchBarStyle
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.SearchBar
import de.mm20.launcher2.ui.component.SearchBarLevel
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.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.theme.colorscheme.darkColorSchemeOf
import de.mm20.launcher2.ui.theme.colorscheme.lightColorSchemeOf
import de.mm20.launcher2.ui.theme.shapes.shapesOf
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Serializable
data class ImportThemeSettingsRoute(val fromUri: String)
@Composable
fun ImportThemeSettingsScreen(
fromUri: Uri,
) {
val context = LocalContext.current
val activity = LocalActivity.current
val viewModel: ImportThemeSettingsScreenVM = viewModel()
val scope = rememberCoroutineScope()
val themeBundle = viewModel.themeBundle
LaunchedEffect(fromUri) {
viewModel.init(context, fromUri)
}
PreferenceScreen(title = stringResource(R.string.theme_import_title)) {
if (viewModel.error) {
item {
PreferenceCategory {
Banner(
text = stringResource(R.string.import_theme_error),
icon = Icons.Rounded.ErrorOutline,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
)
}
}
} else if (themeBundle != null) {
item {
PreferenceCategory {
Preference(
title = themeBundle.name,
summary = themeBundle.author?.takeIf { it.isNotBlank() })
}
}
item {
val isDarkMode = LocalDarkTheme.current
var darkModePreview by remember { mutableStateOf(isDarkMode) }
MaterialTheme(
colorScheme = themeBundle.colors?.let {
if (darkModePreview) darkColorSchemeOf(it) else lightColorSchemeOf(it)
} ?: MaterialTheme.colorScheme,
shapes = themeBundle.shapes?.let { shapesOf(it) } ?: MaterialTheme.shapes,
) {
ThemePreview(
darkMode = darkModePreview,
onDarkModeChanged = { darkModePreview = it }
)
}
}
item {
PreferenceCategory {
if (themeBundle.colors != null) {
Preference(
icon = Icons.Rounded.Palette,
title = stringResource(R.string.preference_screen_colors),
summary = themeBundle.colors?.name,
controls = if (viewModel.colorsExists) {
{
Icon(Icons.Rounded.ChangeCircle, null)
}
} else null,
)
}
if (themeBundle.shapes != null) {
Preference(
icon = Icons.Rounded.CropSquare,
title = stringResource(R.string.preference_screen_shapes),
summary = themeBundle.shapes?.name,
controls = if (viewModel.shapesExists) {
{
Icon(Icons.Rounded.ChangeCircle, null)
}
} else null,
)
}
if (viewModel.colorsExists || viewModel.shapesExists) {
Banner(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.shapes.extraSmall
)
.padding(16.dp),
icon = Icons.Rounded.ChangeCircle,
text = stringResource(R.string.import_theme_exists)
)
}
}
}
item {
PreferenceCategory {
SwitchPreference(
title = stringResource(R.string.import_theme_apply),
value = viewModel.applyTheme,
onValueChanged = { viewModel.applyTheme = it },
)
Button(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.shapes.extraSmall
)
.padding(16.dp),
enabled = !viewModel.loading,
onClick = {
scope.launch {
viewModel.import()?.join()
activity?.onBackPressed()
}
}
) {
Text(stringResource(R.string.action_import))
}
}
}
}
}
}
@Composable
private fun ThemePreview(
darkMode: Boolean,
onDarkModeChanged: (Boolean) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(
MaterialTheme.colorScheme.surfaceContainer,
MaterialTheme.shapes.medium
)
.innerShadow(
MaterialTheme.shapes.medium,
InnerShadow(8.dp, color = Color(0f, 0f, 0f, 0.2f))
)
) {
SearchBar(
style = SearchBarStyle.Solid,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, start = 12.dp, end = 12.dp),
level = SearchBarLevel.Active,
value = "",
onValueChange = {},
readOnly = true,
menu = {
IconButton(onClick = {}) {
Icon(Icons.Rounded.MoreVert, null)
}
}
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, start = 12.dp, end = 12.dp)
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium)
.padding(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(80.dp)
.background(
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.shapes.small
),
)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
"Title",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
"Subtitle",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary
)
Text(
"Body",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp),
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = true,
label = { Text("Chip") },
leadingIcon = {
Icon(
Icons.Rounded.Star, null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
onClick = { },
)
FilterChip(
selected = false,
label = { Text("Chip") },
leadingIcon = {
Icon(
Icons.Rounded.Star, null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
onClick = { },
)
Spacer(modifier = Modifier.weight(1f))
FilledTonalIconButton(onClick = {}) {
Icon(Icons.Rounded.Edit, null)
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
Button(onClick = {}) { Text("Button") }
OutlinedButton(onClick = {}) { Text("Button") }
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
contentAlignment = Alignment.CenterEnd,
) {
SingleChoiceSegmentedButtonRow {
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
selected = !darkMode,
onClick = { onDarkModeChanged(false) }
) {
Icon(Icons.Rounded.LightMode, null)
}
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
selected = darkMode,
onClick = { onDarkModeChanged(true) }
) {
Icon(Icons.Rounded.DarkMode, null)
}
}
}
}
}
@Preview
@Composable
private fun ThemePreviewPreview() {
ThemePreview(
darkMode = false,
onDarkModeChanged = {}
)
}
@@ -0,0 +1,109 @@
package de.mm20.launcher2.ui.settings.appearance
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.preferences.ColorsDescriptor
import de.mm20.launcher2.preferences.ShapesDescriptor
import de.mm20.launcher2.preferences.ui.UiSettings
import de.mm20.launcher2.themes.ThemeBundle
import de.mm20.launcher2.themes.ThemeRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.getValue
class ImportThemeSettingsScreenVM: ViewModel(), KoinComponent {
private val themeRepository by inject<ThemeRepository>()
private val uiSettings by inject<UiSettings>()
var themeBundle by mutableStateOf<ThemeBundle?>(null)
private set
var colorsExists by mutableStateOf(false)
private set
var shapesExists by mutableStateOf(false)
private set
var loading by mutableStateOf(false)
private set
var error by mutableStateOf(false)
private set
var applyTheme by mutableStateOf(true)
fun init(context: Context, fromUri: Uri) {
themeBundle = null
error = false
applyTheme = true
loading = true
viewModelScope.launch(Dispatchers.IO) {
try {
context.contentResolver.openInputStream(fromUri)?.reader()?.use {
val text = it.readText()
val theme = ThemeBundle.fromJson(text)
if (theme != null) {
val colors = theme.colors?.id?.let { themeRepository.getColors(it) }?.first()
val shapes = theme.shapes?.id?.let { themeRepository.getShapes(it) }?.first()
colorsExists = colors != null
shapesExists = shapes != null
themeBundle = theme
loading = false
} else {
error = true
}
}
} catch (e: SecurityException) {
CrashReporter.logException(e)
error = true
}
}
}
fun import(): Job? {
val themeBundle = this.themeBundle ?: return null
val colors = themeBundle.colors
val shapes = themeBundle.shapes
val colorsExist = this.colorsExists
val shapesExist = this.shapesExists
loading = true
return viewModelScope.launch {
if (colors != null) {
if (colorsExist) {
themeRepository.updateColors(colors)
} else {
themeRepository.createColors(colors)
}
if (applyTheme) {
uiSettings.setColors(ColorsDescriptor.Custom(colors.id.toString()))
}
}
if (shapes != null) {
if (shapesExist) {
themeRepository.updateShapes(shapes)
} else {
themeRepository.createShapes(shapes)
}
if (applyTheme) {
uiSettings.setShapes(ShapesDescriptor.Custom(shapes.id.toString()))
}
}
loading = false
}
}
}
@@ -97,7 +97,8 @@ fun CorePaletteColorPreference(
value = currentValue == null,
onValueChanged = {
currentValue = if (it) null else defaultValue
}
},
containerColor = Color.Transparent,
)
AnimatedVisibility(
currentValue != null,