feat(IconOpts): custom foreground image selection

This commit is contained in:
rushiiMachine 2025-03-30 23:08:23 -07:00
parent c22567f337
commit 32194af7fe
No known key found for this signature in database
GPG key ID: DCBE5952BB3B6420
5 changed files with 188 additions and 11 deletions

View file

@ -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()
}
}
}

View file

@ -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"),
)
}
}

View file

@ -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),
)
}
}
}
}
}

View file

@ -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),

View file

@ -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 &amp; 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>