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