diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/IconOptionsScreenPreview.kt b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/IconOptionsScreenPreview.kt index a1ee4cd..2db153b 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/IconOptionsScreenPreview.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/IconOptionsScreenPreview.kt @@ -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 { @@ -46,14 +58,39 @@ private class IconOptionsParametersProvider : PreviewParameterProvider(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"), + ) } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsScreen.kt index d8eb98e..7fa865b 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/IconOptionsScreen.kt @@ -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), + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/components/RGBTextField.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/components/RGBTextField.kt index 5ed8f7a..1f2a1c9 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/components/RGBTextField.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/iconopts/components/RGBTextField.kt @@ -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), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8d27a3..e904b38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,15 +166,23 @@ Icon Replacement Custom Color + Custom Image Does not alter the original icon, instead only adding the missing monochrome icon to the APK. Changes the original icon color to Aliucord\'s branding. Select a custom icon background color. + Select a custom replacement image. Note that the image must have sufficient padding as per the Adaptive Icon foreground guidelines. Android 8+ only. Hue & Saturation The main HSV color components. Lightness The brightness component of the color. Hex Color Manually enter the RGB hex value of the color. Transparency is ignored! + Failed to open image + Select Image + Example image + Preview + Uncropped preview of the selected image + No image selected Active installation Progress notifications sent during a minimized installation