feat(IconOpts): custom foreground image selection
This commit is contained in:
parent
c22567f337
commit
32194af7fe
5 changed files with 188 additions and 11 deletions
|
|
@ -1,12 +1,20 @@
|
|||
package com.aliucord.manager.ui.previews.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.ui.components.ManagerTheme
|
||||
import com.aliucord.manager.ui.screens.iconopts.*
|
||||
import com.aliucord.manager.ui.screens.patchopts.PatchOptions.IconReplacement
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
// This preview has scrollable/interactable content that cannot be properly tested from an IDE preview
|
||||
|
||||
|
|
@ -27,11 +35,14 @@ private fun IconOptionsScreenPreview(
|
|||
onDispose {}
|
||||
}
|
||||
|
||||
val image = parameters.selectedImage()
|
||||
ManagerTheme {
|
||||
IconOptionsScreenContent(
|
||||
mode = mode,
|
||||
setMode = { mode = it },
|
||||
selectedColor = color,
|
||||
selectedImage = { image },
|
||||
setSelectedImage = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +50,7 @@ private fun IconOptionsScreenPreview(
|
|||
private data class IconOptionsParameters(
|
||||
val mode: IconOptionsMode,
|
||||
val selectedColor: Color,
|
||||
val selectedImage: @Composable () -> ByteArray?,
|
||||
)
|
||||
|
||||
private class IconOptionsParametersProvider : PreviewParameterProvider<IconOptionsParameters> {
|
||||
|
|
@ -46,14 +58,39 @@ private class IconOptionsParametersProvider : PreviewParameterProvider<IconOptio
|
|||
IconOptionsParameters(
|
||||
mode = IconOptionsMode.Original,
|
||||
selectedColor = IconReplacement.Aliucord.color,
|
||||
selectedImage = { null },
|
||||
),
|
||||
IconOptionsParameters(
|
||||
mode = IconOptionsMode.Aliucord,
|
||||
selectedColor = IconReplacement.Aliucord.color,
|
||||
selectedImage = { null },
|
||||
),
|
||||
IconOptionsParameters(
|
||||
mode = IconOptionsMode.CustomColor,
|
||||
selectedColor = Color(0xFFFFB6C1),
|
||||
selectedImage = { null },
|
||||
),
|
||||
IconOptionsParameters(
|
||||
mode = IconOptionsMode.CustomImage,
|
||||
selectedColor = Color.Black,
|
||||
selectedImage = { null },
|
||||
),
|
||||
IconOptionsParameters(
|
||||
mode = IconOptionsMode.CustomImage,
|
||||
selectedColor = Color.Black,
|
||||
selectedImage = {
|
||||
val context = LocalContext.current
|
||||
remember(context) { getMipmapBytes(context, R.mipmap.ic_launcher) }
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
private fun getMipmapBytes(context: Context, @DrawableRes id: Int): ByteArray {
|
||||
val drawable = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)!!
|
||||
val bitmap = drawable.toBitmap()
|
||||
return ByteArrayOutputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, it)
|
||||
it.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
package com.aliucord.manager.ui.screens.iconopts
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.aliucord.manager.BuildConfig
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.ui.screens.patchopts.PatchOptions.IconReplacement
|
||||
import com.aliucord.manager.util.launchBlock
|
||||
import com.aliucord.manager.util.showToast
|
||||
import java.io.IOException
|
||||
|
||||
class IconOptionsModel(
|
||||
prefilledOptions: IconReplacement,
|
||||
private val application: Application,
|
||||
) : ScreenModel {
|
||||
// ---------- Icon patching mode ---------- //
|
||||
var mode by mutableStateOf(IconOptionsMode.Original)
|
||||
|
|
@ -24,6 +34,23 @@ class IconOptionsModel(
|
|||
selectedColor = color.toHSVState()
|
||||
}
|
||||
|
||||
// ---------- Replacement color ---------- //
|
||||
var selectedImage by mutableStateOf<ByteArray?>(null)
|
||||
|
||||
fun changeSelectedImageUri(uri: Uri) = screenModelScope.launchBlock {
|
||||
try {
|
||||
val bytes = application.contentResolver
|
||||
.openInputStream(uri)
|
||||
?.use { it.readBytes() }
|
||||
?: throw IOException("Failed to open input stream")
|
||||
|
||||
selectedImage = bytes
|
||||
} catch (t: Throwable) {
|
||||
application.showToast(R.string.iconopts_failed_image)
|
||||
Log.w(BuildConfig.TAG, "Failed to open selected foreground replacement image", t)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Other ---------- //
|
||||
init {
|
||||
when (prefilledOptions) {
|
||||
|
|
@ -39,7 +66,7 @@ class IconOptionsModel(
|
|||
|
||||
is IconReplacement.CustomImage -> {
|
||||
changeMode(IconOptionsMode.CustomImage)
|
||||
TODO()
|
||||
selectedImage = prefilledOptions.imageBytes
|
||||
}
|
||||
|
||||
IconReplacement.Original -> changeMode(IconOptionsMode.Original)
|
||||
|
|
@ -50,6 +77,8 @@ class IconOptionsModel(
|
|||
IconOptionsMode.Original -> IconReplacement.Original
|
||||
IconOptionsMode.Aliucord -> IconReplacement.Aliucord
|
||||
IconOptionsMode.CustomColor -> IconReplacement.CustomColor(color = selectedColor.toARGB())
|
||||
IconOptionsMode.CustomImage -> TODO()
|
||||
IconOptionsMode.CustomImage -> IconReplacement.CustomImage(
|
||||
imageBytes = selectedImage ?: throw IllegalStateException("Cannot generate config without a selected image"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
package com.aliucord.manager.ui.screens.iconopts
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import coil3.compose.AsyncImage
|
||||
import com.aliucord.manager.R
|
||||
import com.aliucord.manager.ui.components.InteractiveSlider
|
||||
import com.aliucord.manager.ui.components.MainActionButton
|
||||
import com.aliucord.manager.ui.screens.iconopts.components.*
|
||||
import com.aliucord.manager.ui.screens.patchopts.components.options.PatchOption
|
||||
import com.aliucord.manager.ui.util.ColorSaver
|
||||
|
|
@ -50,6 +59,8 @@ class IconOptionsScreen(
|
|||
mode = model.mode,
|
||||
setMode = model::changeMode,
|
||||
selectedColor = model.selectedColor,
|
||||
selectedImage = { model.selectedImage },
|
||||
setSelectedImage = model::changeSelectedImageUri,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -59,13 +70,14 @@ fun IconOptionsScreenContent(
|
|||
mode: IconOptionsMode,
|
||||
setMode: (IconOptionsMode) -> Unit,
|
||||
selectedColor: HSVColorState,
|
||||
selectedImage: () -> ByteArray?,
|
||||
setSelectedImage: (Uri) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { IconOptionsAppBar() },
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
|
|
@ -94,16 +106,33 @@ fun IconOptionsScreenContent(
|
|||
selected = mode == IconOptionsMode.CustomColor,
|
||||
onClick = remember { { setMode(IconOptionsMode.CustomColor) } },
|
||||
)
|
||||
RadioSelectorItem(
|
||||
name = stringResource(R.string.iconopts_variant_title_image),
|
||||
description = stringResource(R.string.iconopts_variant_desc_image),
|
||||
selected = mode == IconOptionsMode.CustomImage,
|
||||
onClick = remember { { setMode(IconOptionsMode.CustomImage) } },
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = mode == IconOptionsMode.CustomColor,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically { -it / 10 },
|
||||
enter = fadeIn(tween(delayMillis = 250)),
|
||||
exit = fadeOut(tween(durationMillis = 200)),
|
||||
) {
|
||||
CustomColorOptions(selectedColor)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = mode == IconOptionsMode.CustomImage,
|
||||
enter = fadeIn(tween(delayMillis = 250)),
|
||||
exit = fadeOut(tween(durationMillis = 200)),
|
||||
) {
|
||||
CustomImageOptions(
|
||||
selectedImage = selectedImage,
|
||||
setSelectedImage = setSelectedImage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -177,3 +206,73 @@ private fun CustomColorOptions(color: HSVColorState) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomImageOptions(
|
||||
selectedImage: () -> ByteArray?,
|
||||
setSelectedImage: (Uri) -> Unit,
|
||||
) {
|
||||
val pickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia(),
|
||||
onResult = { it?.let(setSelectedImage) }
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(15.dp),
|
||||
modifier = Modifier.padding(horizontal = 15.dp)
|
||||
) {
|
||||
MainActionButton(
|
||||
text = stringResource(R.string.iconopts_btn_open_selection),
|
||||
icon = painterResource(R.drawable.ic_launch),
|
||||
onClick = { pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
|
||||
)
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
MainActionButton(
|
||||
text = stringResource(R.string.iconopts_btn_open_example_image),
|
||||
icon = painterResource(R.drawable.ic_launch),
|
||||
onClick = { uriHandler.openUri("https://github.com/Aliucord/Aliucord/blob/fe8b17002ec1ee66ac3eeb2855c9ca2f2f307410/installer/android/app/src/main/assets/icon1.png") },
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
)
|
||||
|
||||
PatchOption(
|
||||
name = stringResource(R.string.iconopts_image_preview_title),
|
||||
description = stringResource(R.string.iconopts_image_preview_desc),
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selectedImage(),
|
||||
label = "Selected image preview fade",
|
||||
animationSpec = tween(durationMillis = 300, delayMillis = 250),
|
||||
) { image ->
|
||||
if (image == null) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1.2f)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.iconopts_image_preview_none),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.alpha(.5f),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = image,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.clip(MaterialTheme.shapes.large),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aliucord.manager.R
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
|
@ -45,7 +47,9 @@ fun RGBTextField(
|
|||
value = text,
|
||||
onValueChange = { newText ->
|
||||
val cleaned = newText.removePrefix("#").uppercase()
|
||||
val newColor = cleaned.toLongOrNull(16)?.let(::Color)?.copy(alpha = 1f)
|
||||
val newColor = cleaned
|
||||
.toLongOrNull(radix = 16)?.let(::Color)
|
||||
?.copy(alpha = 1f)
|
||||
|
||||
text = cleaned
|
||||
isError = newColor == null
|
||||
|
|
@ -54,7 +58,7 @@ fun RGBTextField(
|
|||
isError = isError,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(autoCorrectEnabled = false),
|
||||
label = { Text("Hex Value") },
|
||||
label = { Text(stringResource(R.string.iconopts_hex_title)) },
|
||||
prefix = { Text("# ") },
|
||||
modifier = modifier
|
||||
.widthIn(min = 200.dp, max = 300.dp),
|
||||
|
|
|
|||
|
|
@ -166,15 +166,23 @@
|
|||
|
||||
<string name="iconopts_screen_title">Icon Replacement</string>
|
||||
<string name="iconopts_variant_title_color">Custom Color</string>
|
||||
<string name="iconopts_variant_title_image">Custom Image</string>
|
||||
<string name="iconopts_variant_desc_discord">Does not alter the original icon, instead only adding the missing monochrome icon to the APK.</string>
|
||||
<string name="iconopts_variant_desc_aliucord">Changes the original icon color to Aliucord\'s branding.</string>
|
||||
<string name="iconopts_variant_desc_color">Select a custom icon background color.</string>
|
||||
<string name="iconopts_variant_desc_image">Select a custom replacement image. Note that the image must have sufficient padding as per the Adaptive Icon foreground guidelines. Android 8+ only.</string>
|
||||
<string name="iconopts_colorpicker_title">Hue & Saturation</string>
|
||||
<string name="iconopts_colorpicker_desc">The main HSV color components.</string>
|
||||
<string name="iconopts_lightness_title">Lightness</string>
|
||||
<string name="iconopts_lightness_desc">The brightness component of the color.</string>
|
||||
<string name="iconopts_hex_title">Hex Color</string>
|
||||
<string name="iconopts_hex_desc">Manually enter the RGB hex value of the color. Transparency is ignored!</string>
|
||||
<string name="iconopts_failed_image">Failed to open image</string>
|
||||
<string name="iconopts_btn_open_selection">Select Image</string>
|
||||
<string name="iconopts_btn_open_example_image">Example image</string>
|
||||
<string name="iconopts_image_preview_title">Preview</string>
|
||||
<string name="iconopts_image_preview_desc">Uncropped preview of the selected image</string>
|
||||
<string name="iconopts_image_preview_none">No image selected</string>
|
||||
|
||||
<string name="notif_group_install_title">Active installation</string>
|
||||
<string name="notif_group_install_desc">Progress notifications sent during a minimized installation</string>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue