* Implemented a custom theme engine for the EPUB reader, allowing users to switch between built-in themes (System, Light, Dark, Sepia, Slate, and OLED). Key changes include: - Added `ReaderThemePanel` and theme selection logic to `EpubReaderScreen`. - Introduced a theme persistence layer using SharedPreferences. - Updated `ChapterWebView` and `epub_reader.js` to dynamically apply background and text colors via JavaScript, including improved contrast adjustment for inline styles. - Enhanced `CssParser` and `ContentStyler` in the paginated reader to adapt CSS colors based on the active theme's background and text luminance. - Integrated theme-specific background and text colors into `PaginatedReaderScreen` and its layout components. - Added a theme palette icon to the reader controls. * Added custom theme builder and texture support to EPUB reader * Refactored color picker(PDF) into shared components and enhanced EPUB theme editor * Updated theme customization UI and improved WebView theme application efficiency * Updated page flip animation to use paper color with a theme-based tint * Improved state restoration during paginated reader reconfiguration
789 lines
No EOL
31 KiB
Kotlin
789 lines
No EOL
31 KiB
Kotlin
/*
|
|
* Episteme Reader - A native Android document reader.
|
|
* Copyright (C) 2026 Episteme
|
|
*
|
|
* 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/>.
|
|
*
|
|
* mail: epistemereader@gmail.com
|
|
*/
|
|
package com.aryan.reader.pdf
|
|
|
|
import androidx.compose.animation.core.animateFloatAsState
|
|
import androidx.compose.foundation.Canvas
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.gestures.detectTapGestures
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Slider
|
|
import androidx.compose.material3.SliderDefaults
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Switch
|
|
import androidx.compose.material3.SwitchDefaults
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
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.rememberUpdatedState
|
|
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.draw.scale
|
|
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.drawscope.Stroke
|
|
import androidx.compose.ui.graphics.drawscope.clipPath
|
|
import androidx.compose.ui.graphics.toArgb
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.platform.testTag
|
|
import androidx.compose.ui.semantics.selected
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.window.Dialog
|
|
import androidx.compose.ui.window.DialogProperties
|
|
import com.aryan.reader.BrightnessSlider
|
|
import com.aryan.reader.ColorComparePill
|
|
import com.aryan.reader.HexInput
|
|
import com.aryan.reader.RgbInputColumn
|
|
import com.aryan.reader.SpectrumBox
|
|
import kotlin.math.roundToInt
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun ToolSettingsPopup(
|
|
selectedTool: InkType,
|
|
activeToolThickness: Float,
|
|
fountainPenColor: Color,
|
|
markerColor: Color,
|
|
pencilColor: Color,
|
|
highlighterColor: Color,
|
|
highlighterRoundColor: Color,
|
|
activePalette: List<Color>,
|
|
onToolTypeChanged: (InkType) -> Unit,
|
|
onColorChanged: (Color) -> Unit,
|
|
onThicknessChanged: (Float) -> Unit,
|
|
onPaletteChange: (List<Color>) -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
isHighlighterSnapEnabled: Boolean = false,
|
|
onSnapToggle: (Boolean) -> Unit = {}
|
|
) {
|
|
val isHighlighter = selectedTool == InkType.HIGHLIGHTER || selectedTool == InkType.HIGHLIGHTER_ROUND
|
|
val isEraser = selectedTool == InkType.ERASER
|
|
|
|
val activeColor = when (selectedTool) {
|
|
InkType.FOUNTAIN_PEN -> fountainPenColor
|
|
InkType.PEN -> markerColor
|
|
InkType.PENCIL -> pencilColor
|
|
InkType.HIGHLIGHTER -> highlighterColor
|
|
InkType.HIGHLIGHTER_ROUND -> highlighterRoundColor
|
|
InkType.ERASER -> Color.White
|
|
else -> markerColor
|
|
}
|
|
|
|
val currentAlpha = activeColor.alpha
|
|
|
|
val safeOnColorChanged: (Color) -> Unit = { newColor ->
|
|
if (isHighlighter) {
|
|
onColorChanged(newColor.copy(alpha = currentAlpha))
|
|
} else {
|
|
onColorChanged(newColor)
|
|
}
|
|
}
|
|
|
|
val thicknessRange = when {
|
|
isHighlighter -> 0.01f..0.06f
|
|
isEraser -> 0.01f..0.1f
|
|
else -> 0.001f..0.015f
|
|
}
|
|
@Suppress("UnusedExpression") if (isHighlighter) 0.005f else 0.001f
|
|
|
|
var showColorPicker by remember { mutableStateOf(false) }
|
|
var colorPickerSlotIndex by remember { mutableIntStateOf(-1) }
|
|
|
|
val currentOnColorChanged by rememberUpdatedState(safeOnColorChanged)
|
|
|
|
val selectedPaletteIndex = remember(activePalette, activeColor, isHighlighter) {
|
|
activePalette.indexOfFirst { paletteColor ->
|
|
if (isHighlighter) {
|
|
paletteColor.copy(alpha = 1f) == activeColor.copy(alpha = 1f)
|
|
} else {
|
|
paletteColor == activeColor
|
|
}
|
|
}
|
|
}
|
|
|
|
val circleSize = 28.dp
|
|
|
|
Surface(
|
|
modifier = modifier
|
|
.width(360.dp)
|
|
.padding(12.dp),
|
|
shape = RoundedCornerShape(28.dp),
|
|
color = Color(0xFF1E1E1E),
|
|
shadowElevation = 12.dp,
|
|
tonalElevation = 0.dp
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(20.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
if (isEraser) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(125.dp),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
val radiusDp = (activeToolThickness * 1000).coerceIn(10f, 100f).dp
|
|
Box(
|
|
modifier = Modifier.size(radiusDp),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
drawCircle(
|
|
color = Color.White.copy(alpha = 0.3f),
|
|
radius = size.width / 2
|
|
)
|
|
drawCircle(
|
|
color = Color.White,
|
|
radius = size.width / 2,
|
|
style = Stroke(width = 2.dp.toPx())
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(125.dp),
|
|
contentAlignment = Alignment.BottomCenter
|
|
) {
|
|
Row(
|
|
horizontalArrangement = Arrangement.spacedBy(28.dp),
|
|
verticalAlignment = Alignment.Bottom
|
|
) {
|
|
if (isHighlighter) {
|
|
PenItem(
|
|
type = PenType.HIGHLIGHTER,
|
|
forcedInkType = InkType.HIGHLIGHTER,
|
|
color = highlighterColor.copy(alpha = 1f),
|
|
inkColor = highlighterColor,
|
|
isSelected = selectedTool == InkType.HIGHLIGHTER,
|
|
strokeWidth = activeToolThickness,
|
|
onClick = { onToolTypeChanged(InkType.HIGHLIGHTER) },
|
|
isSnappingEnabled = isHighlighterSnapEnabled
|
|
)
|
|
|
|
PenItem(
|
|
type = PenType.HIGHLIGHTER_ROUND,
|
|
forcedInkType = InkType.HIGHLIGHTER_ROUND,
|
|
color = highlighterRoundColor.copy(alpha = 1f),
|
|
inkColor = highlighterRoundColor,
|
|
isSelected = selectedTool == InkType.HIGHLIGHTER_ROUND,
|
|
strokeWidth = activeToolThickness,
|
|
onClick = { onToolTypeChanged(InkType.HIGHLIGHTER_ROUND) },
|
|
isSnappingEnabled = isHighlighterSnapEnabled
|
|
)
|
|
} else {
|
|
PenItem(
|
|
type = PenType.FOUNTAIN_PEN,
|
|
forcedInkType = InkType.FOUNTAIN_PEN,
|
|
color = fountainPenColor,
|
|
isSelected = selectedTool == InkType.FOUNTAIN_PEN,
|
|
strokeWidth = activeToolThickness,
|
|
onClick = { onToolTypeChanged(InkType.FOUNTAIN_PEN) }
|
|
)
|
|
|
|
PenItem(
|
|
type = PenType.MARKER,
|
|
forcedInkType = InkType.PEN,
|
|
color = markerColor,
|
|
isSelected = selectedTool == InkType.PEN,
|
|
strokeWidth = activeToolThickness,
|
|
onClick = { onToolTypeChanged(InkType.PEN) }
|
|
)
|
|
|
|
PenItem(
|
|
type = PenType.PENCIL,
|
|
forcedInkType = InkType.PENCIL,
|
|
color = pencilColor,
|
|
isSelected = selectedTool == InkType.PENCIL,
|
|
strokeWidth = activeToolThickness,
|
|
onClick = { onToolTypeChanged(InkType.PENCIL) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
if (isHighlighter) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Text(
|
|
text = "Straight Line",
|
|
color = Color.White,
|
|
style = MaterialTheme.typography.bodyMedium
|
|
)
|
|
Switch(
|
|
checked = isHighlighterSnapEnabled,
|
|
onCheckedChange = onSnapToggle,
|
|
colors = SwitchDefaults.colors(
|
|
checkedThumbColor = Color.White,
|
|
checkedTrackColor = activeColor.copy(alpha = 1f),
|
|
uncheckedThumbColor = Color.Gray,
|
|
uncheckedTrackColor = Color(0xFF424242)
|
|
),
|
|
modifier = Modifier.scale(0.8f)
|
|
)
|
|
}
|
|
Spacer(Modifier.height(16.dp))
|
|
}
|
|
|
|
// THICKNESS SLIDER
|
|
StyledPropertySlider(
|
|
value = activeToolThickness,
|
|
onValueChange = onThicknessChanged,
|
|
valueRange = thicknessRange, isOpacity = false,
|
|
trackColor = Color(0xFF424242),
|
|
thumbColor = Color(0xFF757575),
|
|
activeColor = if (isEraser) Color.White else activeColor
|
|
)
|
|
|
|
// Darkness (Opacity) Slider for Highlighters
|
|
if (isHighlighter) {
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
StyledPropertySlider(
|
|
value = currentAlpha,
|
|
onValueChange = { newAlpha ->
|
|
onColorChanged(activeColor.copy(alpha = newAlpha))
|
|
},
|
|
valueRange = 0.1f..1.0f, isOpacity = true,
|
|
trackColor = activeColor.copy(alpha = 1f),
|
|
thumbColor = activeColor.copy(alpha = 1f),
|
|
activeColor = activeColor
|
|
)
|
|
}
|
|
|
|
if (!isEraser) {
|
|
Spacer(Modifier.height(16.dp)) // Reduced spacing
|
|
|
|
// --- Color Palette ---
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Row(
|
|
modifier = Modifier.weight(1f),
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
activePalette.take(6).forEachIndexed { index, color ->
|
|
val isSelected = index == selectedPaletteIndex
|
|
|
|
Box(
|
|
contentAlignment = Alignment.Center,
|
|
modifier = Modifier
|
|
.size(circleSize)
|
|
.testTag("Palette_Item_$index")
|
|
.pointerInput(color) {
|
|
detectTapGestures(
|
|
onTap = {
|
|
currentOnColorChanged(color)
|
|
},
|
|
onLongPress = {
|
|
colorPickerSlotIndex = index
|
|
showColorPicker = true
|
|
}
|
|
)
|
|
}
|
|
) {
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
drawCircle(color = color.copy(alpha = 1f))
|
|
if (isSelected) {
|
|
drawCircle(
|
|
color = Color.White,
|
|
radius = size.minDimension / 2,
|
|
style = Stroke(width = 2.dp.toPx())
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.width(16.dp))
|
|
|
|
// Divider
|
|
Box(
|
|
modifier = Modifier
|
|
.width(1.dp)
|
|
.height(circleSize)
|
|
.background(Color.White.copy(alpha = 0.15f))
|
|
)
|
|
|
|
Spacer(Modifier.width(16.dp))
|
|
|
|
// Spectrum / Color Wheel Button
|
|
val rainbowColors = listOf(
|
|
Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red
|
|
)
|
|
Box(
|
|
contentAlignment = Alignment.Center,
|
|
modifier = Modifier
|
|
.size(circleSize)
|
|
.clip(CircleShape)
|
|
.background(Brush.sweepGradient(rainbowColors))
|
|
.clickable {
|
|
if (selectedPaletteIndex != -1) {
|
|
colorPickerSlotIndex = selectedPaletteIndex
|
|
showColorPicker = true
|
|
}
|
|
}
|
|
) {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (showColorPicker && colorPickerSlotIndex != -1) {
|
|
val initialColor = activePalette.getOrElse(colorPickerSlotIndex) { Color.Black }
|
|
ColorPickerDialog(
|
|
initialColor = initialColor,
|
|
onDismiss = { showColorPicker = false },
|
|
onColorSelected = { newColor ->
|
|
val mutableList = activePalette.toMutableList()
|
|
if (colorPickerSlotIndex in mutableList.indices) {
|
|
mutableList[colorPickerSlotIndex] = newColor
|
|
onPaletteChange(mutableList)
|
|
if (isHighlighter) {
|
|
onColorChanged(newColor.copy(alpha = currentAlpha))
|
|
} else {
|
|
onColorChanged(newColor)
|
|
}
|
|
}
|
|
showColorPicker = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ColorPickerDialog(
|
|
initialColor: Color,
|
|
onDismiss: () -> 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]
|
|
}
|
|
|
|
Dialog(
|
|
onDismissRequest = onDismiss,
|
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
|
) {
|
|
Surface(
|
|
shape = RoundedCornerShape(24.dp),
|
|
color = Color(0xFF2C2C2C),
|
|
modifier = Modifier
|
|
.fillMaxWidth(0.85f)
|
|
.padding(8.dp)
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(20.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp))
|
|
.padding(horizontal = 24.dp, vertical = 8.dp)
|
|
) {
|
|
Text(
|
|
text = "Spectrum",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.SemiBold,
|
|
color = Color.White
|
|
)
|
|
}
|
|
|
|
Spacer(Modifier.height(20.dp))
|
|
|
|
SpectrumBox(
|
|
hue = hue,
|
|
saturation = saturation,
|
|
currentColor = currentColor,
|
|
onHueSatChanged = { h, s ->
|
|
hue = h
|
|
saturation = s
|
|
},
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(220.dp)
|
|
)
|
|
|
|
Spacer(Modifier.height(20.dp))
|
|
|
|
BrightnessSlider(
|
|
hue = hue,
|
|
saturation = saturation,
|
|
value = value,
|
|
onValueChanged = { value = it },
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(24.dp)
|
|
.clip(RoundedCornerShape(12.dp))
|
|
)
|
|
|
|
Spacer(Modifier.height(24.dp))
|
|
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.Bottom,
|
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
ColorComparePill(
|
|
oldColor = lockedInitialColor,
|
|
newColor = currentColor,
|
|
modifier = Modifier
|
|
.width(64.dp)
|
|
.height(36.dp)
|
|
)
|
|
|
|
Column(
|
|
modifier = Modifier.weight(1.6f),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
Text(
|
|
"Hex",
|
|
color = Color.Gray,
|
|
fontSize = 12.sp,
|
|
maxLines = 1
|
|
)
|
|
Spacer(Modifier.height(4.dp))
|
|
HexInput(
|
|
color = currentColor,
|
|
onHexChanged = { updateFromColor(it) }
|
|
)
|
|
}
|
|
|
|
Row(
|
|
modifier = Modifier.weight(2.4f),
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
|
) {
|
|
RgbInputColumn(
|
|
label = "Red",
|
|
value = currentColor.red,
|
|
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
RgbInputColumn(
|
|
label = "Green",
|
|
value = currentColor.green,
|
|
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
RgbInputColumn(
|
|
label = "Blue",
|
|
value = currentColor.blue,
|
|
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(24.dp))
|
|
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.End,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
TextButton(onClick = onDismiss) {
|
|
Text("Cancel", color = Color.Gray)
|
|
}
|
|
Spacer(Modifier.width(8.dp))
|
|
Button(
|
|
onClick = { onColorSelected(currentColor) },
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = Color.White,
|
|
contentColor = Color.Black
|
|
)
|
|
) {
|
|
Text("Done")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PenItem(
|
|
type: PenType,
|
|
color: Color,
|
|
isSelected: Boolean,
|
|
strokeWidth: Float,
|
|
onClick: () -> Unit,
|
|
forcedInkType: InkType? = null,
|
|
inkColor: Color? = null,
|
|
isSnappingEnabled: Boolean = false
|
|
) {
|
|
val scale by animateFloatAsState(
|
|
targetValue = if (isSelected) 1.15f else 0.9f, label = "scale"
|
|
)
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.width(44.dp)
|
|
.height(100.dp)
|
|
.scale(scale)
|
|
.testTag("SettingsItem_${type.name}")
|
|
.semantics { this.selected = isSelected }
|
|
.clickable(onClick = onClick),
|
|
contentAlignment = Alignment.BottomCenter
|
|
) {
|
|
PenIcon(
|
|
color = color,
|
|
inkColor = inkColor,
|
|
type = type,
|
|
isSelected = isSelected,
|
|
strokeWidth = strokeWidth,
|
|
forcedInkType = forcedInkType,
|
|
modifier = Modifier.fillMaxSize(),
|
|
isSnappingEnabled = isSnappingEnabled
|
|
)
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
private fun StyledPropertySlider(
|
|
value: Float,
|
|
onValueChange: (Float) -> Unit,
|
|
valueRange: ClosedFloatingPointRange<Float>, isOpacity: Boolean,
|
|
trackColor: Color,
|
|
thumbColor: Color,
|
|
activeColor: Color
|
|
) {
|
|
val displayValue = remember(value, valueRange) {
|
|
val fraction = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start)
|
|
(fraction * 100).roundToInt().coerceIn(1, 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()
|
|
) {
|
|
// Minus Button
|
|
Box(
|
|
modifier = Modifier
|
|
.size(32.dp)
|
|
.testTag("Property_Minus")
|
|
.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))
|
|
|
|
// Custom Slider
|
|
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 (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))
|
|
|
|
// Plus Button
|
|
Box(
|
|
modifier = Modifier
|
|
.size(32.dp)
|
|
.testTag("Property_Plus")
|
|
.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
|
|
)
|
|
}
|
|
}
|
|
} |