diff --git a/app/ui/src/main/AndroidManifest.xml b/app/ui/src/main/AndroidManifest.xml
index 843f3111..5589242a 100644
--- a/app/ui/src/main/AndroidManifest.xml
+++ b/app/ui/src/main/AndroidManifest.xml
@@ -25,7 +25,8 @@
-
@@ -62,9 +63,27 @@
android:value="de.mm20.launcher2.ui.launcher.SharedLauncherActivity" />
+
+
+ android:name=".launcher.ImportThemeActivity"
+ android:excludeFromRecents="true"
+ android:exported="true"
+ android:theme="@style/DialogTheme">
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/common/ImportThemeSheet.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/common/ImportThemeSheet.kt
new file mode 100644
index 00000000..f848e1db
--- /dev/null
+++ b/app/ui/src/main/java/de/mm20/launcher2/ui/common/ImportThemeSheet.kt
@@ -0,0 +1,310 @@
+package de.mm20.launcher2.ui.common
+
+import android.net.Uri
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.DarkMode
+import androidx.compose.material.icons.rounded.ErrorOutline
+import androidx.compose.material.icons.rounded.LightMode
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+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.themes.Theme
+import de.mm20.launcher2.ui.R
+import de.mm20.launcher2.ui.component.BottomSheetDialog
+import de.mm20.launcher2.ui.component.LargeMessage
+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 hct.Hct
+
+@Composable
+fun ImportThemeSheet(
+ uri: Uri,
+ onDismiss: () -> Unit,
+) {
+ val viewModel: ImportThemeSheetVM = viewModel()
+
+ val context = LocalContext.current
+
+ LaunchedEffect(uri) {
+ viewModel.readTheme(context, uri)
+ }
+
+ val theme by viewModel.theme
+ val error by viewModel.error
+ var apply by viewModel.apply
+
+ BottomSheetDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = if (theme != null && !error) {
+ {
+ Button(
+ onClick = {
+ viewModel.import()
+ onDismiss()
+ }
+ ) {
+ Text(stringResource(R.string.action_import))
+ }
+ }
+ } else null,
+ ) {
+ if (theme == null && !error) {
+ Box(
+ modifier = Modifier
+ .padding(it)
+ .fillMaxWidth()
+ .aspectRatio(1f),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else if (error) {
+ Box(
+ modifier = Modifier
+ .padding(it)
+ .fillMaxWidth()
+ ) {
+ LargeMessage(
+ icon = Icons.Rounded.ErrorOutline,
+ text = "Theme could not be read. Is the file corrupted?"
+ )
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(it)
+ ) {
+ ThemePreview(
+ theme!!,
+ )
+ Surface(
+ modifier = Modifier.padding(top = 8.dp),
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
+ ) {
+
+ SwitchPreference(
+ title = stringResource(R.string.import_theme_apply),
+ iconPadding = false,
+ value = apply,
+ onValueChanged = {
+ apply = it
+ })
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ThemePreview(
+ theme: Theme,
+ modifier: Modifier = Modifier,
+) {
+ val darkMode = LocalDarkTheme.current
+ var darkTheme by remember { mutableStateOf(darkMode) }
+
+ val colorScheme = if (darkTheme) darkColorSchemeOf(theme) else lightColorSchemeOf(theme)
+
+ Column(modifier = modifier) {
+ Row(
+ modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = theme.name,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ SingleChoiceSegmentedButtonRow {
+ SegmentedButton(
+ shape = SegmentedButtonDefaults.shape(position = 0, count = 2),
+ selected = !darkTheme,
+ onClick = { darkTheme = false }
+ ) {
+ Icon(Icons.Rounded.LightMode, null)
+ }
+ SegmentedButton(
+ shape = SegmentedButtonDefaults.shape(position = 1, count = 2),
+ selected = darkTheme,
+ onClick = { darkTheme = true }
+ ) {
+ Icon(Icons.Rounded.DarkMode, null)
+ }
+ }
+ }
+ MaterialTheme(
+ colorScheme = colorScheme
+ ) {
+ Column(
+ modifier = Modifier
+ .clip(MaterialTheme.shapes.medium)
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .padding(start = 14.dp, end = 14.dp, bottom = 14.dp, top = 14.dp)
+ ) {
+ Row {
+ ColorSwatch(color = MaterialTheme.colorScheme.primary, darkTheme = darkTheme)
+ ColorSwatch(color = MaterialTheme.colorScheme.onPrimary, darkTheme = darkTheme)
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ darkTheme = darkTheme
+ )
+ }
+ Row {
+ ColorSwatch(color = MaterialTheme.colorScheme.secondary, darkTheme = darkTheme)
+ ColorSwatch(color = MaterialTheme.colorScheme.onSecondary, darkTheme = darkTheme)
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ darkTheme = darkTheme
+ )
+ }
+ Row {
+ ColorSwatch(color = MaterialTheme.colorScheme.tertiary, darkTheme = darkTheme)
+ ColorSwatch(color = MaterialTheme.colorScheme.onTertiary, darkTheme = darkTheme)
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.tertiaryContainer,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ darkTheme = darkTheme
+ )
+ }
+ Row {
+ ColorSwatch(color = MaterialTheme.colorScheme.error, darkTheme = darkTheme)
+ ColorSwatch(color = MaterialTheme.colorScheme.onError, darkTheme = darkTheme)
+ ColorSwatch(color = MaterialTheme.colorScheme.errorContainer, darkTheme = darkTheme)
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ darkTheme = darkTheme
+ )
+ }
+ Row {
+ ColorSwatch(color = MaterialTheme.colorScheme.surfaceDim, darkTheme = darkTheme)
+ ColorSwatch(color = MaterialTheme.colorScheme.surface, darkTheme = darkTheme)
+ ColorSwatch(color = MaterialTheme.colorScheme.surfaceBright, darkTheme = darkTheme)
+ }
+ Row {
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.surfaceContainerLowest,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ darkTheme = darkTheme
+ )
+ }
+ Row {
+ ColorSwatch(color = MaterialTheme.colorScheme.onSurface, darkTheme = darkTheme)
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(color = MaterialTheme.colorScheme.outline, darkTheme = darkTheme)
+ ColorSwatch(color = MaterialTheme.colorScheme.outlineVariant, darkTheme = darkTheme)
+ }
+ Row {
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.inverseSurface,
+ darkTheme = darkTheme,
+ weight = 2f
+ )
+ ColorSwatch(
+ color = MaterialTheme.colorScheme.inverseOnSurface,
+ darkTheme = darkTheme
+ )
+ ColorSwatch(color = MaterialTheme.colorScheme.inversePrimary, darkTheme = darkTheme)
+ }
+
+ }
+ }
+ }
+}
+
+@Composable
+fun RowScope.ColorSwatch(
+ color: Color,
+ darkTheme: Boolean,
+ weight: Float = 1f,
+) {
+ val borderColor = Color(Hct.fromInt(color.toArgb()).let {
+ val tone = if (darkTheme) 30f else 80f
+ it.apply {
+ this.tone = tone.toDouble()
+ }.toInt()
+ })
+ Box(
+ modifier = Modifier
+ .weight(weight)
+ .padding(2.dp)
+ .height(36.dp)
+ .clip(MaterialTheme.shapes.small)
+ .border(
+ 1.dp,
+ borderColor,
+ MaterialTheme.shapes.small
+ )
+ .background(color),
+ )
+}
\ No newline at end of file
diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/common/ImportThemeSheetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/common/ImportThemeSheetVM.kt
new file mode 100644
index 00000000..71e67b3b
--- /dev/null
+++ b/app/ui/src/main/java/de/mm20/launcher2/ui/common/ImportThemeSheetVM.kt
@@ -0,0 +1,71 @@
+package de.mm20.launcher2.ui.common
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import de.mm20.launcher2.preferences.LauncherDataStore
+import de.mm20.launcher2.themes.Theme
+import de.mm20.launcher2.themes.ThemeRepository
+import de.mm20.launcher2.themes.fromJson
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class ImportThemeSheetVM : ViewModel(), KoinComponent {
+
+ private val themeRepository: ThemeRepository by inject()
+ private val dataStore: LauncherDataStore by inject()
+
+ val theme = mutableStateOf(null)
+ val error = mutableStateOf(false)
+ val apply = mutableStateOf(false)
+
+ fun import() {
+ val theme = theme.value
+ val apply = apply.value
+ if (theme != null) {
+ viewModelScope.launch {
+ importTheme(theme, apply)
+ }
+ }
+ }
+
+
+ fun readTheme(context: Context, uri: Uri) {
+ error.value = false
+ theme.value = null
+ apply.value = true
+ viewModelScope.launch(Dispatchers.IO) {
+ val inputStream =
+ context.contentResolver.openInputStream(uri) ?: return@launch
+ val theme = inputStream.use {
+ val json = it.readBytes().toString(Charsets.UTF_8)
+ try {
+ Theme.fromJson(json)
+ } catch (e: IllegalArgumentException) {
+ null
+ }
+ }
+ this@ImportThemeSheetVM.theme.value = theme
+ if (theme == null) {
+ error.value = true
+ }
+ }
+ }
+
+ private suspend fun importTheme(theme: Theme, apply: Boolean) {
+ themeRepository.createTheme(theme)
+ if (apply) {
+ dataStore.updateData {
+ it.toBuilder()
+ .setAppearance(
+ it.appearance.toBuilder()
+ .setThemeId(theme.id.toString())
+ ).build()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/ImportThemeActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/ImportThemeActivity.kt
new file mode 100644
index 00000000..5004c4af
--- /dev/null
+++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/ImportThemeActivity.kt
@@ -0,0 +1,31 @@
+package de.mm20.launcher2.ui.launcher
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+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.theme.LauncherTheme
+
+class ImportThemeActivity : BaseActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val uri = intent.data ?: return finish()
+
+ setContent {
+ LauncherTheme {
+ ProvideSettings {
+ OverlayHost {
+ ImportThemeSheet(
+ onDismiss = { finish() },
+ uri = uri,
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/base/src/main/res/values/themes.xml b/core/base/src/main/res/values/themes.xml
index 1ba9a084..8ce9a76f 100644
--- a/core/base/src/main/res/values/themes.xml
+++ b/core/base/src/main/res/values/themes.xml
@@ -31,4 +31,24 @@
- true
- shortEdges
+
+
\ No newline at end of file
diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml
index 220425b6..016f36f2 100644
--- a/core/i18n/src/main/res/values/strings.xml
+++ b/core/i18n/src/main/res/values/strings.xml
@@ -24,6 +24,7 @@
%1$s has been hidden.
Undo
+ Import
Delete
Remove
@@ -839,4 +840,5 @@
Palette
Custom
Restore default
+ Apply theme
\ No newline at end of file
diff --git a/data/themes/src/main/java/de/mm20/launcher2/themes/Serialization.kt b/data/themes/src/main/java/de/mm20/launcher2/themes/Serialization.kt
index c3d8bbfb..8d183f1e 100644
--- a/data/themes/src/main/java/de/mm20/launcher2/themes/Serialization.kt
+++ b/data/themes/src/main/java/de/mm20/launcher2/themes/Serialization.kt
@@ -1,6 +1,7 @@
package de.mm20.launcher2.themes
import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
@@ -53,5 +54,9 @@ fun Theme.toJson(): String {
}
fun Theme.Companion.fromJson(json: String): Theme {
- return ThemeJson.decodeFromString(json)
+ return try {
+ ThemeJson.decodeFromString(json)
+ } catch (e: SerializationException) {
+ throw IllegalArgumentException(e)
+ }
}
\ No newline at end of file