book-reader/app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt

1571 lines
No EOL
65 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* Electronic mail: epistemereader@gmail.com
*/
package com.aryan.reader.pdf
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
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.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.graphics.toColorInt
import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity
import timber.log.Timber
import java.io.File
import kotlin.math.max
import kotlin.math.roundToInt
private enum class ColorMenuMode {
PALETTE,
SPECTRUM
}
private enum class ActivePopup {
NONE, FONT_SIZE, FONT_FAMILY, COLOR, BACKGROUND
}
private fun getAssetPathForReaderFont(fontName: String): String? {
return when (fontName) {
"Merriweather" -> "asset:fonts/merriweather.ttf"
"Lato" -> "asset:fonts/lato.ttf"
"Lora" -> "asset:fonts/lora.ttf"
"Roboto Mono" -> "asset:fonts/roboto_mono.ttf"
"Lexend" -> "asset:fonts/lexend.ttf"
else -> null
}
}
@Composable
fun TextAnnotationDock(
currentStyle: SpanStyle,
textColorPalette: List<Color>,
onTextColorPaletteChange: (List<Color>) -> Unit,
backgroundColorPalette: List<Color>,
onBackgroundColorPaletteChange: (List<Color>) -> Unit,
onUpdateStyle: (SpanStyle) -> Unit,
onApplyToSelection: () -> Unit,
onClose: () -> Unit,
onPopupStateChange: (Boolean) -> Unit,
onInsertTextBox: () -> Unit,
onClearTextBoxSelection: () -> Unit = {},
bottomDockPadding: androidx.compose.ui.unit.Dp = 0.dp,
customFonts: List<CustomFontEntity> = emptyList(),
onImportFont: (android.net.Uri) -> Unit = {},
currentFontName: String? = null,
onFontSelected: (String, String?) -> Unit = { _, _ -> }
) {
var activeMenuMode by remember { mutableStateOf(ColorMenuMode.PALETTE) }
var colorPickerSlotIndex by remember { mutableIntStateOf(-1) }
var activePopup by remember { mutableStateOf(ActivePopup.NONE) }
val fontPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri -> uri?.let { onImportFont(it) } }
)
LaunchedEffect(activePopup) {
onPopupStateChange(activePopup != ActivePopup.NONE)
}
val fontSizes = listOf(12.sp, 14.sp, 16.sp, 18.sp, 20.sp, 24.sp, 30.sp)
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
val imeBottom = WindowInsets.ime.getBottom(density)
val navBottom = WindowInsets.navigationBars.getBottom(density)
val spacerHeightPx = max(0, imeBottom - navBottom)
val spacerHeightDp = with(density) { spacerHeightPx.toDp() }
val effectiveSpacerHeight = if (spacerHeightDp > 0.dp) {
spacerHeightDp
} else {
bottomDockPadding
}
LaunchedEffect(activePopup) {
if (activePopup == ActivePopup.NONE) {
activeMenuMode = ColorMenuMode.PALETTE
colorPickerSlotIndex = -1
}
}
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
if (activePopup != ActivePopup.NONE && activePopup != ActivePopup.FONT_SIZE) {
val dockBarHeight = 48.dp
val margin = 8.dp
val finalOffsetY = -(effectiveSpacerHeight + dockBarHeight + margin)
val isFocusable = activeMenuMode == ColorMenuMode.SPECTRUM || activePopup == ActivePopup.FONT_FAMILY
DockBubblePopup(
onDismissRequest = { activePopup = ActivePopup.NONE },
alignment = Alignment.BottomCenter,
offsetY = finalOffsetY,
focusable = isFocusable
) {
when (activePopup) {
ActivePopup.FONT_FAMILY -> {
// Re-using the logic from your EPUB FontSelectionSheetContent but adapted for a Popup
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E),
shadowElevation = 8.dp,
modifier = Modifier.width(260.dp)
) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
Column {
TabRow(
selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary,
divider = {}
) {
Tab(
selected = selectedTabIndex == 0,
onClick = { selectedTabIndex = 0 },
text = { Text("Presets", fontSize = 12.sp) }
)
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
text = { Text("Imported", fontSize = 12.sp) }
)
}
Box(modifier = Modifier.heightIn(max = 300.dp).padding(8.dp)) {
if (selectedTabIndex == 0) {
LazyColumn {
item {
val isSelected = currentFontName == "Default" || currentFontName == null
FontItem(
name = "Default System Font",
isSelected = isSelected,
fontFamily = FontFamily.Default,
onClick = {
onFontSelected("Default", null)
activePopup = ActivePopup.NONE
}
)
}
items(com.aryan.reader.epubreader.ReaderFont.entries.toTypedArray()) { font ->
if (font == com.aryan.reader.epubreader.ReaderFont.ORIGINAL) return@items
val isSelected = currentFontName == font.displayName
FontItem(
name = font.displayName,
isSelected = isSelected,
fontFamily = com.aryan.reader.epubreader.getComposeFontFamily(font, null, LocalContext.current.assets),
onClick = {
val assetPath = getAssetPathForReaderFont(font.displayName)
onFontSelected(font.displayName, assetPath)
activePopup = ActivePopup.NONE
}
)
}
}
} else {
Column {
Button(
onClick = {
fontPickerLauncher.launch(arrayOf("font/ttf", "font/otf", "application/x-font-ttf"))
},
modifier = Modifier.fillMaxWidth().padding(8.dp),
contentPadding = PaddingValues(0.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text("Import", fontSize = 12.sp)
}
if (customFonts.isEmpty()) {
Text(
"No fonts imported",
color = Color.Gray,
modifier = Modifier.fillMaxWidth().padding(16.dp),
textAlign = TextAlign.Center,
fontSize = 12.sp
)
} else {
LazyColumn {
items(customFonts) { fontEntity ->
val isSelected = currentFontName == fontEntity.displayName
val customFamily = remember(fontEntity.path) {
try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
}
FontItem(
name = fontEntity.displayName,
isSelected = isSelected,
fontFamily = customFamily,
onClick = {
onFontSelected(fontEntity.displayName, fontEntity.path)
activePopup = ActivePopup.NONE
}
)
}
}
}
}
}
}
}
}
}
ActivePopup.COLOR -> {
if (activeMenuMode == ColorMenuMode.PALETTE) {
ColorPickerBubble(
title = "Font color",
currentColor = currentStyle.color.takeIf { it != Color.Unspecified }
?: Color.Black,
palette = textColorPalette,
showTransparent = false,
onColorSelected = { color ->
onUpdateStyle(currentStyle.copy(color = color, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
activePopup = ActivePopup.NONE
},
onPaletteChanged = onTextColorPaletteChange,
onShowColorPicker = { slotIndex ->
colorPickerSlotIndex = slotIndex
activeMenuMode = ColorMenuMode.SPECTRUM
},
onDismiss = { activePopup = ActivePopup.NONE }
)
} else {
val initialColor =
textColorPalette.getOrElse(colorPickerSlotIndex) { Color.Black }
ColorPickerSpectrumContent(
initialColor = initialColor,
onBack = { activeMenuMode = ColorMenuMode.PALETTE },
onColorSelected = { newColor ->
val mutableList = textColorPalette.toMutableList()
if (colorPickerSlotIndex in mutableList.indices) {
mutableList[colorPickerSlotIndex] = newColor
onTextColorPaletteChange(mutableList)
onUpdateStyle(currentStyle.copy(color = newColor, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
}
activeMenuMode = ColorMenuMode.PALETTE
}
)
}
}
ActivePopup.BACKGROUND -> {
if (activeMenuMode == ColorMenuMode.PALETTE) {
ColorPickerBubble(
title = "Highlight",
currentColor = when (currentStyle.background) {
Color.Unspecified, Color.Transparent -> Color.Transparent
else -> currentStyle.background
},
palette = backgroundColorPalette,
showTransparent = true,
onColorSelected = { color ->
onUpdateStyle(currentStyle.copy(background = color, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
activePopup = ActivePopup.NONE
},
onPaletteChanged = onBackgroundColorPaletteChange,
onShowColorPicker = { slotIndex ->
colorPickerSlotIndex = slotIndex
activeMenuMode = ColorMenuMode.SPECTRUM
},
onDismiss = { activePopup = ActivePopup.NONE }
)
} else {
val initialColor =
backgroundColorPalette.getOrElse(colorPickerSlotIndex) { Color.Black }
ColorPickerSpectrumContent(
initialColor = initialColor,
onBack = { activeMenuMode = ColorMenuMode.PALETTE },
onColorSelected = { newColor ->
val mutableList = backgroundColorPalette.toMutableList()
if (colorPickerSlotIndex in mutableList.indices) {
mutableList[colorPickerSlotIndex] = newColor
onBackgroundColorPaletteChange(mutableList)
onUpdateStyle(currentStyle.copy(background = newColor, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
}
activeMenuMode = ColorMenuMode.PALETTE
}
)
}
}
else -> {}
}
}
}
Column(
modifier = Modifier.fillMaxWidth()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
color = Color(0xFFF0F0F0),
shadowElevation = 8.dp
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
FormattingIconButton(
isSelected = activePopup == ActivePopup.FONT_FAMILY,
iconRes = R.drawable.fonts,
contentDescription = "Select Font Family",
onClick = {
activePopup =
if (activePopup == ActivePopup.FONT_FAMILY) ActivePopup.NONE else ActivePopup.FONT_FAMILY
}
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
if (activePopup == ActivePopup.FONT_SIZE) {
DockBubblePopup(
onDismissRequest = { activePopup = ActivePopup.NONE },
offsetY = (-55).dp,
alignment = Alignment.TopCenter,
focusable = false
) {
LazyColumn(
modifier = Modifier
.heightIn(max = 200.dp)
.width(80.dp)
.background(Color(0xFF1E1E1E), RoundedCornerShape(16.dp))
) {
items(fontSizes) { size ->
val isSelected = currentStyle.fontSize == size
Box(
modifier = Modifier
.fillMaxWidth()
.clickable {
onUpdateStyle(currentStyle.copy(fontSize = size, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
activePopup = ActivePopup.NONE
}
.background(if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) else Color.Transparent)
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "${size.value.toInt()}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.White
)
}
}
}
}
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable {
activePopup =
if (activePopup == ActivePopup.FONT_SIZE) ActivePopup.NONE else ActivePopup.FONT_SIZE
}
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "${currentStyle.fontSize.value.toInt()}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.Black
)
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "Select Font Size",
tint = Color.Gray,
modifier = Modifier.size(16.dp)
)
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.clickable {
activeMenuMode = ColorMenuMode.PALETTE
activePopup =
if (activePopup == ActivePopup.COLOR) ActivePopup.NONE else ActivePopup.COLOR
},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
(-3).dp,
Alignment.CenterVertically
)
) {
Text(
text = "A",
color = Color.Black,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
style = TextStyle(
platformStyle = PlatformTextStyle(
includeFontPadding = false
)
)
)
Box(
modifier = Modifier
.width(16.dp)
.height(2.dp)
.background(
currentStyle.color.takeIf { it != Color.Unspecified }
?: Color.Black)
)
}
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.clickable {
activeMenuMode = ColorMenuMode.PALETTE
activePopup =
if (activePopup == ActivePopup.BACKGROUND) ActivePopup.NONE else ActivePopup.BACKGROUND
},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
(0).dp,
Alignment.CenterVertically
)
) {
Icon(
painter = painterResource(id = R.drawable.font_background),
contentDescription = "Font Background",
modifier = Modifier.size(17.dp),
tint = Color.Black
)
Box(
modifier = Modifier
.width(16.dp)
.height(2.dp)
.background(
if (currentStyle.background == Color.Unspecified || currentStyle.background == Color.Transparent)
Color.Gray else currentStyle.background
)
)
}
}
}
val isBold = currentStyle.fontWeight == FontWeight.Bold
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(), contentAlignment = Alignment.Center
) {
FormattingIconButton(
isSelected = isBold,
iconRes = R.drawable.format_bold,
contentDescription = "Bold",
onClick = {
val newW = if (isBold) FontWeight.Normal else FontWeight.Bold
onUpdateStyle(currentStyle.copy(fontWeight = newW, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
}
)
}
val isItalic = currentStyle.fontStyle == FontStyle.Italic
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(), contentAlignment = Alignment.Center
) {
FormattingIconButton(
isSelected = isItalic,
iconRes = R.drawable.format_italic,
contentDescription = "Italic",
onClick = {
val newStyle = if (isItalic) FontStyle.Normal else FontStyle.Italic
onUpdateStyle(currentStyle.copy(fontStyle = newStyle, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
}
)
}
val currentDec = currentStyle.textDecoration ?: TextDecoration.None
val isUnderline = currentDec.contains(TextDecoration.Underline)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(), contentAlignment = Alignment.Center
) {
FormattingIconButton(
isSelected = isUnderline,
iconRes = R.drawable.format_underlined,
contentDescription = "Underline",
onClick = {
val hasStrike = currentDec.contains(TextDecoration.LineThrough)
val newDec = if (isUnderline) {
if (hasStrike) TextDecoration.LineThrough else TextDecoration.None
} else {
if (hasStrike) TextDecoration.combine(
listOf(
TextDecoration.Underline,
TextDecoration.LineThrough
)
) else TextDecoration.Underline
}
onUpdateStyle(currentStyle.copy(textDecoration = newDec, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
}
)
}
val isStrike = currentDec.contains(TextDecoration.LineThrough)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(), contentAlignment = Alignment.Center
) {
FormattingIconButton(
isSelected = isStrike,
iconRes = R.drawable.format_strikethrough,
contentDescription = "Strikethrough",
onClick = {
val hasUnd = currentDec.contains(TextDecoration.Underline)
val newDec = if (isStrike) {
if (hasUnd) TextDecoration.Underline else TextDecoration.None
} else {
if (hasUnd) TextDecoration.combine(
listOf(
TextDecoration.Underline,
TextDecoration.LineThrough
)
) else TextDecoration.LineThrough
}
onUpdateStyle(currentStyle.copy(textDecoration = newDec, fontFamily = currentStyle.fontFamily))
onApplyToSelection()
}
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
FormattingIconButton(
isSelected = false,
iconRes = R.drawable.text_box,
contentDescription = "Insert Text Box",
onClick = {
Timber.tag("PdfTextBoxDebug").d("Dock: Insert Text Box icon clicked")
onInsertTextBox()
}
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(), contentAlignment = Alignment.Center
) {
FormattingIconButton(
isSelected = false,
iconVector = Icons.Default.Close,
contentDescription = "Close",
onClick = {
focusManager.clearFocus()
onClearTextBoxSelection()
onClose()
}
)
}
}
}
Spacer(modifier = Modifier.height(effectiveSpacerHeight))
}
}
}
@Composable
private fun FontItem(
name: String,
isSelected: Boolean,
fontFamily: FontFamily,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.background(if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) else Color.Transparent)
.padding(vertical = 10.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = name,
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.White,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = fontFamily),
maxLines = 1
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
}
}
@Composable
private fun DockBubblePopup(
onDismissRequest: () -> Unit,
alignment: Alignment = Alignment.TopCenter,
offsetY: androidx.compose.ui.unit.Dp,
focusable: Boolean = false,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val offsetPx = with(density) { offsetY.roundToPx() }
Popup(
alignment = alignment,
offset = IntOffset(0, offsetPx),
onDismissRequest = onDismissRequest,
properties = PopupProperties(
focusable = focusable,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
content()
}
}
@Composable
private fun ColorPickerBubble(
title: String,
currentColor: Color,
palette: List<Color>,
showTransparent: Boolean = false,
onColorSelected: (Color) -> Unit,
@Suppress("unused") onPaletteChanged: (List<Color>) -> Unit,
onShowColorPicker: (Int) -> Unit,
onDismiss: () -> Unit
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E),
shadowElevation = 8.dp,
modifier = Modifier.wrapContentWidth()
) {
Column(
modifier = Modifier
.padding(12.dp)
.width(androidx.compose.foundation.layout.IntrinsicSize.Min)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.width(16.dp))
IconButton(
onClick = onDismiss,
modifier = Modifier.size(20.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.padding(horizontal = 2.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (showTransparent) {
val isSelected = currentColor == Color.Transparent || currentColor == Color.Unspecified
Box(
modifier = Modifier
.size(26.dp)
.clip(CircleShape)
.border(
width = 1.5.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.3f),
shape = CircleShape
)
.clickable { onColorSelected(Color.Transparent) },
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(color = Color(0xFFAAAAAA))
drawLine(
color = Color(0xFF555555),
start = Offset(size.width * 0.2f, size.height * 0.2f),
end = Offset(size.width * 0.8f, size.height * 0.8f),
strokeWidth = 2.dp.toPx()
)
}
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(16.dp)
)
}
}
}
// Regular palette colors
palette.forEachIndexed { index, color ->
val isSelected = if (color == Color.White) {
currentColor == color || currentColor == Color.Unspecified
} else {
currentColor == color
}
ColorCircle(
color = color,
isSelected = isSelected,
onClick = { onColorSelected(color) },
onLongClick = { onShowColorPicker(index) }
)
}
// Spectrum button
val rainbowColors = listOf(
Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red
)
Box(
modifier = Modifier
.size(26.dp)
.clip(CircleShape)
.background(Brush.sweepGradient(rainbowColors))
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.3f),
shape = CircleShape
)
.clickable { onShowColorPicker(palette.size.coerceAtMost(4)) },
contentAlignment = Alignment.Center
) {}
}
}
}
}
@Composable
private fun ColorCircle(
color: Color,
isSelected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
Box(
modifier = Modifier
.size(26.dp)
.clip(CircleShape)
.background(color)
.then(
if (color == Color.White) {
Modifier.border(1.dp, Color.White.copy(alpha = 0.3f), CircleShape)
} else Modifier
)
.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
contentAlignment = Alignment.Center
) {
if (isSelected) {
val checkColor = if (color.luminance() > 0.6f) Color.Black else Color.White
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = checkColor,
modifier = Modifier.size(16.dp)
)
}
}
}
@Composable
private fun FormattingIconButton(
isSelected: Boolean,
iconRes: Int? = null,
iconVector: androidx.compose.ui.graphics.vector.ImageVector? = null,
contentDescription: String,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(if (isSelected) Color.Black.copy(alpha = 0.1f) else Color.Transparent)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (iconRes != null) {
Icon(
painter = painterResource(iconRes),
contentDescription = contentDescription,
tint = Color.Black.copy(alpha = 0.8f),
modifier = Modifier.size(20.dp)
)
} else if (iconVector != null) {
Icon(
imageVector = iconVector,
contentDescription = contentDescription,
tint = Color.Black.copy(alpha = 0.8f),
modifier = Modifier.size(20.dp)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StyledPropertySlider(
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
isOpacity: Boolean,
trackColor: Color,
thumbColor: Color,
activeColor: Color,
trackBrush: Brush? = null
) {
val displayValue = remember(value, valueRange) {
val fraction = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start)
(fraction * 100).roundToInt().coerceIn(0, 100)
}
val onePercentDelta = (valueRange.endInclusive - valueRange.start) / 100f
val canDecrease = value > valueRange.start + 0.0001f
val canIncrease = value < valueRange.endInclusive - 0.0001f
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.size(32.dp)
.clickable(enabled = canDecrease) {
val newValue = (value - onePercentDelta).coerceAtLeast(valueRange.start)
onValueChange(newValue)
},
contentAlignment = Alignment.Center
) {
Text(
text = "",
color = if (canDecrease) Color.White else Color.White.copy(alpha = 0.3f),
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.width(4.dp))
Box(modifier = Modifier.weight(1f)) {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = Color.Transparent,
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent
),
modifier = Modifier.height(32.dp),
thumb = {
Surface(
shape = CircleShape,
color = thumbColor,
modifier = Modifier
.size(26.dp)
.padding(2.dp),
shadowElevation = 4.dp,
border = if (isOpacity) null else androidx.compose.foundation.BorderStroke(1.dp, Color.Gray)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayValue.toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
},
track = { _ ->
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(16.dp)
) {
val trackHeight = size.height
val cornerRadius = CornerRadius(trackHeight / 2)
if (trackBrush != null) {
// Draw gradient track
drawRoundRect(
brush = trackBrush,
size = size,
cornerRadius = cornerRadius
)
} else if (isOpacity) {
drawRoundRect(
color = Color.Gray,
size = size,
cornerRadius = cornerRadius
)
val clipPath = androidx.compose.ui.graphics.Path().apply {
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
rect = androidx.compose.ui.geometry.Rect(Offset.Zero, size),
cornerRadius = cornerRadius
)
)
}
clipPath(clipPath) {
val boxSize = 12f
val columns = (size.width / boxSize).toInt() + 1
val rows = (size.height / boxSize).toInt() + 1
for (col in 0 until columns) {
for (row in 0 until rows) {
val color = if ((col + row) % 2 == 0) Color(0xFF555555) else Color(0xFF333333)
drawRect(
color = color,
topLeft = Offset(col * boxSize, row * boxSize),
size = androidx.compose.ui.geometry.Size(boxSize, boxSize)
)
}
}
drawRect(color = activeColor)
}
} else {
drawRoundRect(
color = trackColor.copy(alpha = 0.5f),
size = size,
cornerRadius = cornerRadius
)
val dotRadius = 1.5.dp.toPx()
val padding = trackHeight / 2
val availableWidth = size.width - (padding * 2)
val dotCount = 8
val spacing = availableWidth / (dotCount - 1)
for (i in 0 until dotCount) {
drawCircle(
color = Color.White.copy(alpha = 0.2f),
radius = dotRadius,
center = Offset(padding + (i * spacing), size.height / 2)
)
}
}
}
}
)
}
Spacer(modifier = Modifier.width(4.dp))
Box(
modifier = Modifier
.size(32.dp)
.clickable(enabled = canIncrease) {
val newValue = (value + onePercentDelta).coerceAtMost(valueRange.endInclusive)
onValueChange(newValue)
},
contentAlignment = Alignment.Center
) {
Text(
text = "+",
color = if (canIncrease) Color.White else Color.White.copy(alpha = 0.3f),
fontSize = 22.sp,
fontWeight = FontWeight.Normal
)
}
}
}
@Composable
private fun ColorPickerSpectrumContent(
initialColor: Color,
onBack: () -> Unit,
onColorSelected: (Color) -> Unit
) {
val lockedInitialColor = remember { initialColor }
val initialHsv = remember(initialColor) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv)
hsv
}
var hue by remember { mutableFloatStateOf(initialHsv[0]) }
var saturation by remember { mutableFloatStateOf(initialHsv[1]) }
var value by remember { mutableFloatStateOf(initialHsv[2]) }
val alpha = 1.0f
val currentColor by remember {
derivedStateOf {
val hsv = floatArrayOf(hue, saturation, value)
val argb = android.graphics.Color.HSVToColor((alpha * 255).toInt(), hsv)
Color(argb)
}
}
fun updateFromColor(color: Color) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
hue = hsv[0]
saturation = hsv[1]
value = hsv[2]
}
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier.width(320.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
Text(
text = "Spectrum",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.padding(start = 12.dp)
)
}
Spacer(Modifier.height(16.dp))
// Spectrum Area
SpectrumBox(
hue = hue,
saturation = saturation,
currentColor = currentColor,
onHueSatChanged = { h, s ->
hue = h
saturation = s
},
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
)
Spacer(Modifier.height(16.dp))
// Brightness Slider
val brightnessGradient = remember(hue, saturation) {
Brush.horizontalGradient(
colors = listOf(Color.Black, Color.hsv(hue, saturation, 1f))
)
}
StyledPropertySlider(
value = value,
onValueChange = { value = it },
valueRange = 0f..1f,
isOpacity = false,
trackColor = Color.Transparent,
thumbColor = currentColor,
activeColor = currentColor,
trackBrush = brightnessGradient
)
Spacer(Modifier.height(16.dp))
// RGB and Hex Inputs
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorComparePill(
oldColor = lockedInitialColor,
newColor = currentColor,
modifier = Modifier
.width(48.dp)
.height(36.dp)
)
// Hex Input
Column(
modifier = Modifier.weight(1.5f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Hex",
color = Color.Gray,
fontSize = 11.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
HexInput(
color = currentColor,
onHexChanged = { updateFromColor(it) }
)
}
// RGB Inputs
Row(
modifier = Modifier.weight(2.5f),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
RgbInputColumn(
label = "R",
value = currentColor.red,
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "G",
value = currentColor.green,
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "B",
value = currentColor.blue,
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(16.dp))
Button(
onClick = { onColorSelected(currentColor) },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black
),
modifier = Modifier.fillMaxWidth().height(40.dp),
contentPadding = PaddingValues(0.dp)
) {
Text("Done", fontSize = 14.sp)
}
}
}
}
@Composable
private fun SpectrumBox(
hue: Float,
saturation: Float,
currentColor: Color,
onHueSatChanged: (Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
val rainbowColors = listOf(
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
)
val touchPadding = 12.dp
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val paddingPx = touchPadding.toPx()
val activeWidth = size.width.toFloat() - (paddingPx * 2)
val activeHeight = size.height.toFloat() - (paddingPx * 2)
fun update(offset: Offset) {
val relativeX = offset.x - paddingPx
val relativeY = offset.y - paddingPx
val h = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
val s = (relativeY / activeHeight).coerceIn(0f, 1f)
onHueSatChanged(h, s)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(touchPadding)
.clip(RoundedCornerShape(12.dp))
) {
drawRect(
brush = Brush.horizontalGradient(rainbowColors)
)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
)
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
val paddingPx = touchPadding.toPx()
val activeWidth = size.width - (paddingPx * 2)
val activeHeight = size.height - (paddingPx * 2)
val x = paddingPx + (hue / 360f) * activeWidth
val y = paddingPx + saturation * activeHeight
val pointerRadius = 10.dp.toPx()
val strokeWidth = 2.dp.toPx()
drawCircle(
color = Color.Black.copy(alpha = 0.25f),
radius = pointerRadius + 1.dp.toPx(),
center = Offset(x, y + 1.dp.toPx())
)
drawCircle(
color = currentColor.copy(alpha = 1f),
radius = pointerRadius,
center = Offset(x, y)
)
drawCircle(
color = Color.White,
radius = pointerRadius,
center = Offset(x, y),
style = Stroke(width = strokeWidth)
)
}
}
}
@Composable
private fun RgbInputColumn(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val intValue = (value * 255).roundToInt()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
text = label,
color = Color.Gray,
fontSize = 11.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
RgbInput(value = intValue, onValueChange = onValueChange)
}
}
@Composable
private fun RgbInput(
value: Int,
onValueChange: (Float) -> Unit
) {
var text by remember(value) { mutableStateOf(value.toString()) }
LaunchedEffect(value) {
text = value.toString()
}
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 3 && newText.all { it.isDigit() }) {
text = newText
val intVal = newText.toIntOrNull()
if (intVal != null) {
onValueChange(intVal.coerceIn(0, 255) / 255f)
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 13.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(vertical = 9.dp)
)
}
@Composable
private fun HexInput(
color: Color,
onHexChanged: (Color) -> Unit
) {
val hexValue = remember(color) {
String.format("%06X", (0xFFFFFF and color.toArgb()))
}
var text by remember(hexValue) { mutableStateOf(hexValue) }
LaunchedEffect(color) {
val currentParsed = try {
Color(("#$text").toColorInt())
} catch (_: Exception) {
null
}
if (currentParsed?.toArgb() != color.toArgb()) {
text = String.format("%06X", (0xFFFFFF and color.toArgb()))
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "#",
color = Color.Gray,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 6) {
val uppercased = newText.uppercase()
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
text = uppercased
if (uppercased.length == 6) {
try {
val parsedColorInt = "#$uppercased".toColorInt()
val newColor = Color(parsedColorInt)
onHexChanged(newColor)
} catch (_: Exception) {
}
}
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Start,
fontSize = 13.sp
),
singleLine = true,
cursorBrush = SolidColor(Color.White),
modifier = Modifier
.padding(start = 2.dp)
.width(50.dp)
)
}
}
@Composable
private fun ColorComparePill(
oldColor: Color,
newColor: Color,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
drawRect(
color = oldColor.copy(alpha = 1f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
drawRect(
color = newColor.copy(alpha = 1f),
topLeft = Offset(size.width / 2, 0f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
}
}