Extract StackBlur and split CameraScreen into smaller files
Move the ~220-line stack blur algorithm from ImageCaptureHandler into its own util/StackBlur.kt object, making it independently testable and reducing ImageCaptureHandler from 759 to 531 lines. Split CameraScreen.kt (873 lines) by extracting: - ControlPanel.kt: ModeToggle, ControlPanel, SliderControl - CaptureControls.kt: CaptureButton, LastPhotoThumbnail CameraScreen.kt is now 609 lines focused on layout and state wiring. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5b9aedd109
commit
88d04515e2
5 changed files with 579 additions and 501 deletions
|
|
@ -18,6 +18,7 @@ import no.naiv.tiltshift.effect.BlurMode
|
|||
import no.naiv.tiltshift.effect.BlurParameters
|
||||
import no.naiv.tiltshift.storage.PhotoSaver
|
||||
import no.naiv.tiltshift.storage.SaveResult
|
||||
import no.naiv.tiltshift.util.StackBlur
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.cos
|
||||
|
|
@ -386,7 +387,7 @@ class ImageCaptureHandler(
|
|||
|
||||
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
||||
|
||||
blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
||||
blurred = StackBlur.blur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
||||
scaled.recycle()
|
||||
scaled = null
|
||||
|
||||
|
|
@ -530,230 +531,4 @@ class ImageCaptureHandler(
|
|||
return fullPixels
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast stack blur algorithm.
|
||||
*/
|
||||
private fun stackBlur(bitmap: Bitmap, radius: Int): Bitmap {
|
||||
if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||
|
||||
val w = bitmap.width
|
||||
val h = bitmap.height
|
||||
val pix = IntArray(w * h)
|
||||
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
|
||||
|
||||
val wm = w - 1
|
||||
val hm = h - 1
|
||||
val wh = w * h
|
||||
val div = radius + radius + 1
|
||||
|
||||
val r = IntArray(wh)
|
||||
val g = IntArray(wh)
|
||||
val b = IntArray(wh)
|
||||
var rsum: Int
|
||||
var gsum: Int
|
||||
var bsum: Int
|
||||
var x: Int
|
||||
var y: Int
|
||||
var i: Int
|
||||
var p: Int
|
||||
var yp: Int
|
||||
var yi: Int
|
||||
var yw: Int
|
||||
val vmin = IntArray(maxOf(w, h))
|
||||
|
||||
var divsum = (div + 1) shr 1
|
||||
divsum *= divsum
|
||||
val dv = IntArray(256 * divsum)
|
||||
for (i2 in 0 until 256 * divsum) {
|
||||
dv[i2] = (i2 / divsum)
|
||||
}
|
||||
|
||||
yi = 0
|
||||
yw = 0
|
||||
|
||||
val stack = Array(div) { IntArray(3) }
|
||||
var stackpointer: Int
|
||||
var stackstart: Int
|
||||
var sir: IntArray
|
||||
var rbs: Int
|
||||
val r1 = radius + 1
|
||||
var routsum: Int
|
||||
var goutsum: Int
|
||||
var boutsum: Int
|
||||
var rinsum: Int
|
||||
var ginsum: Int
|
||||
var binsum: Int
|
||||
|
||||
for (y2 in 0 until h) {
|
||||
rinsum = 0
|
||||
ginsum = 0
|
||||
binsum = 0
|
||||
routsum = 0
|
||||
goutsum = 0
|
||||
boutsum = 0
|
||||
rsum = 0
|
||||
gsum = 0
|
||||
bsum = 0
|
||||
for (i2 in -radius..radius) {
|
||||
p = pix[yi + minOf(wm, maxOf(i2, 0))]
|
||||
sir = stack[i2 + radius]
|
||||
sir[0] = (p and 0xff0000) shr 16
|
||||
sir[1] = (p and 0x00ff00) shr 8
|
||||
sir[2] = (p and 0x0000ff)
|
||||
rbs = r1 - kotlin.math.abs(i2)
|
||||
rsum += sir[0] * rbs
|
||||
gsum += sir[1] * rbs
|
||||
bsum += sir[2] * rbs
|
||||
if (i2 > 0) {
|
||||
rinsum += sir[0]
|
||||
ginsum += sir[1]
|
||||
binsum += sir[2]
|
||||
} else {
|
||||
routsum += sir[0]
|
||||
goutsum += sir[1]
|
||||
boutsum += sir[2]
|
||||
}
|
||||
}
|
||||
stackpointer = radius
|
||||
|
||||
for (x2 in 0 until w) {
|
||||
r[yi] = dv[rsum]
|
||||
g[yi] = dv[gsum]
|
||||
b[yi] = dv[bsum]
|
||||
|
||||
rsum -= routsum
|
||||
gsum -= goutsum
|
||||
bsum -= boutsum
|
||||
|
||||
stackstart = stackpointer - radius + div
|
||||
sir = stack[stackstart % div]
|
||||
|
||||
routsum -= sir[0]
|
||||
goutsum -= sir[1]
|
||||
boutsum -= sir[2]
|
||||
|
||||
if (y2 == 0) {
|
||||
vmin[x2] = minOf(x2 + radius + 1, wm)
|
||||
}
|
||||
p = pix[yw + vmin[x2]]
|
||||
|
||||
sir[0] = (p and 0xff0000) shr 16
|
||||
sir[1] = (p and 0x00ff00) shr 8
|
||||
sir[2] = (p and 0x0000ff)
|
||||
|
||||
rinsum += sir[0]
|
||||
ginsum += sir[1]
|
||||
binsum += sir[2]
|
||||
|
||||
rsum += rinsum
|
||||
gsum += ginsum
|
||||
bsum += binsum
|
||||
|
||||
stackpointer = (stackpointer + 1) % div
|
||||
sir = stack[(stackpointer) % div]
|
||||
|
||||
routsum += sir[0]
|
||||
goutsum += sir[1]
|
||||
boutsum += sir[2]
|
||||
|
||||
rinsum -= sir[0]
|
||||
ginsum -= sir[1]
|
||||
binsum -= sir[2]
|
||||
|
||||
yi++
|
||||
}
|
||||
yw += w
|
||||
}
|
||||
for (x2 in 0 until w) {
|
||||
rinsum = 0
|
||||
ginsum = 0
|
||||
binsum = 0
|
||||
routsum = 0
|
||||
goutsum = 0
|
||||
boutsum = 0
|
||||
rsum = 0
|
||||
gsum = 0
|
||||
bsum = 0
|
||||
yp = -radius * w
|
||||
for (i2 in -radius..radius) {
|
||||
yi = maxOf(0, yp) + x2
|
||||
|
||||
sir = stack[i2 + radius]
|
||||
|
||||
sir[0] = r[yi]
|
||||
sir[1] = g[yi]
|
||||
sir[2] = b[yi]
|
||||
|
||||
rbs = r1 - kotlin.math.abs(i2)
|
||||
|
||||
rsum += r[yi] * rbs
|
||||
gsum += g[yi] * rbs
|
||||
bsum += b[yi] * rbs
|
||||
|
||||
if (i2 > 0) {
|
||||
rinsum += sir[0]
|
||||
ginsum += sir[1]
|
||||
binsum += sir[2]
|
||||
} else {
|
||||
routsum += sir[0]
|
||||
goutsum += sir[1]
|
||||
boutsum += sir[2]
|
||||
}
|
||||
|
||||
if (i2 < hm) {
|
||||
yp += w
|
||||
}
|
||||
}
|
||||
yi = x2
|
||||
stackpointer = radius
|
||||
for (y2 in 0 until h) {
|
||||
pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
|
||||
|
||||
rsum -= routsum
|
||||
gsum -= goutsum
|
||||
bsum -= boutsum
|
||||
|
||||
stackstart = stackpointer - radius + div
|
||||
sir = stack[stackstart % div]
|
||||
|
||||
routsum -= sir[0]
|
||||
goutsum -= sir[1]
|
||||
boutsum -= sir[2]
|
||||
|
||||
if (x2 == 0) {
|
||||
vmin[y2] = minOf(y2 + r1, hm) * w
|
||||
}
|
||||
p = x2 + vmin[y2]
|
||||
|
||||
sir[0] = r[p]
|
||||
sir[1] = g[p]
|
||||
sir[2] = b[p]
|
||||
|
||||
rinsum += sir[0]
|
||||
ginsum += sir[1]
|
||||
binsum += sir[2]
|
||||
|
||||
rsum += rinsum
|
||||
gsum += ginsum
|
||||
bsum += binsum
|
||||
|
||||
stackpointer = (stackpointer + 1) % div
|
||||
sir = stack[stackpointer]
|
||||
|
||||
routsum += sir[0]
|
||||
goutsum += sir[1]
|
||||
boutsum += sir[2]
|
||||
|
||||
rinsum -= sir[0]
|
||||
ginsum -= sir[1]
|
||||
binsum -= sir[2]
|
||||
|
||||
yi += w
|
||||
}
|
||||
}
|
||||
|
||||
val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
result.setPixels(pix, 0, w, 0, 0, w, h)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
package no.naiv.tiltshift.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.systemGestureExclusion
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -29,24 +26,21 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.systemGestureExclusion
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FlipCameraAndroid
|
||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material.icons.filled.LocationOff
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
|
@ -65,10 +59,9 @@ import androidx.compose.ui.graphics.asImageBitmap
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -79,7 +72,6 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import no.naiv.tiltshift.effect.BlurMode
|
||||
import no.naiv.tiltshift.effect.BlurParameters
|
||||
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
||||
import no.naiv.tiltshift.ui.theme.AppColors
|
||||
|
|
@ -608,265 +600,3 @@ fun CameraScreen(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode toggle for Linear / Radial blur modes.
|
||||
*/
|
||||
@Composable
|
||||
private fun ModeToggle(
|
||||
currentMode: BlurMode,
|
||||
onModeChange: (BlurMode) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(AppColors.OverlayDark)
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ModeButton(
|
||||
text = "Linear",
|
||||
isSelected = currentMode == BlurMode.LINEAR,
|
||||
onClick = { onModeChange(BlurMode.LINEAR) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
ModeButton(
|
||||
text = "Radial",
|
||||
isSelected = currentMode == BlurMode.RADIAL,
|
||||
onClick = { onModeChange(BlurMode.RADIAL) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeButton(
|
||||
text: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (isSelected) AppColors.Accent else Color.Transparent)
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.semantics {
|
||||
stateDescription = if (isSelected) "Selected" else "Not selected"
|
||||
contentDescription = "$text blur mode"
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = if (isSelected) Color.Black else Color.White,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control panel with sliders for blur parameters.
|
||||
* Includes position/size/angle sliders as gesture alternatives for accessibility.
|
||||
*/
|
||||
@Composable
|
||||
private fun ControlPanel(
|
||||
params: BlurParameters,
|
||||
onParamsChange: (BlurParameters) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val currentParams by rememberUpdatedState(params)
|
||||
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(200.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppColors.OverlayDarker)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Reset button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onReset,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RestartAlt,
|
||||
contentDescription = "Reset all parameters to defaults",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Blur intensity slider
|
||||
SliderControl(
|
||||
label = "Blur",
|
||||
value = params.blurAmount,
|
||||
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
|
||||
formatValue = { "${(it * 100).toInt()}%" },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
|
||||
)
|
||||
|
||||
// Falloff slider
|
||||
SliderControl(
|
||||
label = "Falloff",
|
||||
value = params.falloff,
|
||||
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
|
||||
formatValue = { "${(it * 100).toInt()}%" },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
|
||||
)
|
||||
|
||||
// Size slider (gesture alternative for pinch-to-resize)
|
||||
SliderControl(
|
||||
label = "Size",
|
||||
value = params.size,
|
||||
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
|
||||
formatValue = { "${(it * 100).toInt()}%" },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
|
||||
)
|
||||
|
||||
// Aspect ratio slider (radial mode only)
|
||||
if (params.mode == BlurMode.RADIAL) {
|
||||
SliderControl(
|
||||
label = "Shape",
|
||||
value = params.aspectRatio,
|
||||
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
|
||||
formatValue = { "%.1f:1".format(it) },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
// Angle slider (gesture alternative for two-finger rotate)
|
||||
SliderControl(
|
||||
label = "Angle",
|
||||
value = params.angle,
|
||||
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
|
||||
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SliderControl(
|
||||
label: String,
|
||||
value: Float,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text(
|
||||
text = formatValue(value),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
valueRange = valueRange,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = AppColors.Accent,
|
||||
activeTrackColor = AppColors.Accent,
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
||||
),
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.semantics { contentDescription = "$label: ${formatValue(value)}" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture button with processing indicator.
|
||||
*/
|
||||
@Composable
|
||||
private fun CaptureButton(
|
||||
isCapturing: Boolean,
|
||||
isProcessing: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val outerSize = 72.dp
|
||||
val innerSize = if (isCapturing) 48.dp else 60.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(outerSize)
|
||||
.clip(CircleShape)
|
||||
.border(4.dp, Color.White, CircleShape)
|
||||
.clickable(
|
||||
enabled = !isCapturing,
|
||||
role = Role.Button,
|
||||
onClick = onClick
|
||||
)
|
||||
.semantics {
|
||||
contentDescription = "Capture photo with tilt-shift effect"
|
||||
if (isCapturing) stateDescription = "Processing photo"
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(innerSize)
|
||||
.clip(CircleShape)
|
||||
.background(if (isCapturing) AppColors.Accent else Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isProcessing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.Black,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded thumbnail of the last captured photo.
|
||||
* Tapping opens the image in the default photo viewer.
|
||||
*/
|
||||
@Composable
|
||||
private fun LastPhotoThumbnail(
|
||||
thumbnail: Bitmap?,
|
||||
onTap: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = thumbnail != null,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.6f),
|
||||
exit = fadeOut(),
|
||||
modifier = modifier
|
||||
) {
|
||||
thumbnail?.let { bmp ->
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "Last captured photo. Tap to open in viewer.",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
|
||||
.clickable(role = Role.Button, onClick = onTap)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt
Normal file
107
app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package no.naiv.tiltshift.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import no.naiv.tiltshift.ui.theme.AppColors
|
||||
|
||||
/**
|
||||
* Capture button with processing indicator.
|
||||
*/
|
||||
@Composable
|
||||
fun CaptureButton(
|
||||
isCapturing: Boolean,
|
||||
isProcessing: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val outerSize = 72.dp
|
||||
val innerSize = if (isCapturing) 48.dp else 60.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(outerSize)
|
||||
.clip(CircleShape)
|
||||
.border(4.dp, Color.White, CircleShape)
|
||||
.clickable(
|
||||
enabled = !isCapturing,
|
||||
role = Role.Button,
|
||||
onClick = onClick
|
||||
)
|
||||
.semantics {
|
||||
contentDescription = "Capture photo with tilt-shift effect"
|
||||
if (isCapturing) stateDescription = "Processing photo"
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(innerSize)
|
||||
.clip(CircleShape)
|
||||
.background(if (isCapturing) AppColors.Accent else Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isProcessing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.Black,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded thumbnail of the last captured photo.
|
||||
* Tapping opens the image in the default photo viewer.
|
||||
*/
|
||||
@Composable
|
||||
fun LastPhotoThumbnail(
|
||||
thumbnail: Bitmap?,
|
||||
onTap: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = thumbnail != null,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.6f),
|
||||
exit = fadeOut(),
|
||||
modifier = modifier
|
||||
) {
|
||||
thumbnail?.let { bmp ->
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "Last captured photo. Tap to open in viewer.",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
|
||||
.clickable(role = Role.Button, onClick = onTap)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
218
app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt
Normal file
218
app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package no.naiv.tiltshift.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.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.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import no.naiv.tiltshift.effect.BlurMode
|
||||
import no.naiv.tiltshift.effect.BlurParameters
|
||||
import no.naiv.tiltshift.ui.theme.AppColors
|
||||
|
||||
/**
|
||||
* Mode toggle for Linear / Radial blur modes.
|
||||
*/
|
||||
@Composable
|
||||
fun ModeToggle(
|
||||
currentMode: BlurMode,
|
||||
onModeChange: (BlurMode) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(AppColors.OverlayDark)
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
ModeButton(
|
||||
text = "Linear",
|
||||
isSelected = currentMode == BlurMode.LINEAR,
|
||||
onClick = { onModeChange(BlurMode.LINEAR) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
ModeButton(
|
||||
text = "Radial",
|
||||
isSelected = currentMode == BlurMode.RADIAL,
|
||||
onClick = { onModeChange(BlurMode.RADIAL) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeButton(
|
||||
text: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (isSelected) AppColors.Accent else Color.Transparent)
|
||||
.clickable(role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.semantics {
|
||||
stateDescription = if (isSelected) "Selected" else "Not selected"
|
||||
contentDescription = "$text blur mode"
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = if (isSelected) Color.Black else Color.White,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control panel with sliders for blur parameters.
|
||||
* Includes position/size/angle sliders as gesture alternatives for accessibility.
|
||||
*/
|
||||
@Composable
|
||||
fun ControlPanel(
|
||||
params: BlurParameters,
|
||||
onParamsChange: (BlurParameters) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val currentParams by rememberUpdatedState(params)
|
||||
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(200.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppColors.OverlayDarker)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Reset button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onReset,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RestartAlt,
|
||||
contentDescription = "Reset all parameters to defaults",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SliderControl(
|
||||
label = "Blur",
|
||||
value = params.blurAmount,
|
||||
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
|
||||
formatValue = { "${(it * 100).toInt()}%" },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
|
||||
)
|
||||
|
||||
SliderControl(
|
||||
label = "Falloff",
|
||||
value = params.falloff,
|
||||
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
|
||||
formatValue = { "${(it * 100).toInt()}%" },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
|
||||
)
|
||||
|
||||
SliderControl(
|
||||
label = "Size",
|
||||
value = params.size,
|
||||
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
|
||||
formatValue = { "${(it * 100).toInt()}%" },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
|
||||
)
|
||||
|
||||
if (params.mode == BlurMode.RADIAL) {
|
||||
SliderControl(
|
||||
label = "Shape",
|
||||
value = params.aspectRatio,
|
||||
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
|
||||
formatValue = { "%.1f:1".format(it) },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
SliderControl(
|
||||
label = "Angle",
|
||||
value = params.angle,
|
||||
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
|
||||
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
|
||||
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SliderControl(
|
||||
label: String,
|
||||
value: Float,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text(
|
||||
text = formatValue(value),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
valueRange = valueRange,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = AppColors.Accent,
|
||||
activeTrackColor = AppColors.Accent,
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
||||
),
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.semantics { contentDescription = "$label: ${formatValue(value)}" }
|
||||
)
|
||||
}
|
||||
}
|
||||
248
app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt
Normal file
248
app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
package no.naiv.tiltshift.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
||||
/**
|
||||
* Fast stack blur algorithm for CPU-based bitmap blurring.
|
||||
*
|
||||
* Used by the capture/gallery pipeline where GPU shaders aren't available.
|
||||
* Stack blur is an approximation of Gaussian blur that runs in O(n) per pixel
|
||||
* regardless of radius, making it suitable for large images.
|
||||
*/
|
||||
object StackBlur {
|
||||
|
||||
/**
|
||||
* Applies stack blur to a bitmap and returns a new blurred bitmap.
|
||||
* The source bitmap is not modified.
|
||||
*
|
||||
* @param bitmap Source bitmap to blur.
|
||||
* @param radius Blur radius (1-25). Larger = more blur.
|
||||
* @return A new blurred bitmap. The caller owns it and must recycle it.
|
||||
*/
|
||||
fun blur(bitmap: Bitmap, radius: Int): Bitmap {
|
||||
if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||
|
||||
val w = bitmap.width
|
||||
val h = bitmap.height
|
||||
val pix = IntArray(w * h)
|
||||
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
|
||||
|
||||
val wm = w - 1
|
||||
val hm = h - 1
|
||||
val wh = w * h
|
||||
val div = radius + radius + 1
|
||||
|
||||
val r = IntArray(wh)
|
||||
val g = IntArray(wh)
|
||||
val b = IntArray(wh)
|
||||
var rsum: Int
|
||||
var gsum: Int
|
||||
var bsum: Int
|
||||
var x: Int
|
||||
var y: Int
|
||||
var i: Int
|
||||
var p: Int
|
||||
var yp: Int
|
||||
var yi: Int
|
||||
var yw: Int
|
||||
val vmin = IntArray(maxOf(w, h))
|
||||
|
||||
var divsum = (div + 1) shr 1
|
||||
divsum *= divsum
|
||||
val dv = IntArray(256 * divsum)
|
||||
for (i2 in 0 until 256 * divsum) {
|
||||
dv[i2] = (i2 / divsum)
|
||||
}
|
||||
|
||||
yi = 0
|
||||
yw = 0
|
||||
|
||||
val stack = Array(div) { IntArray(3) }
|
||||
var stackpointer: Int
|
||||
var stackstart: Int
|
||||
var sir: IntArray
|
||||
var rbs: Int
|
||||
val r1 = radius + 1
|
||||
var routsum: Int
|
||||
var goutsum: Int
|
||||
var boutsum: Int
|
||||
var rinsum: Int
|
||||
var ginsum: Int
|
||||
var binsum: Int
|
||||
|
||||
// Horizontal pass
|
||||
for (y2 in 0 until h) {
|
||||
rinsum = 0
|
||||
ginsum = 0
|
||||
binsum = 0
|
||||
routsum = 0
|
||||
goutsum = 0
|
||||
boutsum = 0
|
||||
rsum = 0
|
||||
gsum = 0
|
||||
bsum = 0
|
||||
for (i2 in -radius..radius) {
|
||||
p = pix[yi + minOf(wm, maxOf(i2, 0))]
|
||||
sir = stack[i2 + radius]
|
||||
sir[0] = (p and 0xff0000) shr 16
|
||||
sir[1] = (p and 0x00ff00) shr 8
|
||||
sir[2] = (p and 0x0000ff)
|
||||
rbs = r1 - kotlin.math.abs(i2)
|
||||
rsum += sir[0] * rbs
|
||||
gsum += sir[1] * rbs
|
||||
bsum += sir[2] * rbs
|
||||
if (i2 > 0) {
|
||||
rinsum += sir[0]
|
||||
ginsum += sir[1]
|
||||
binsum += sir[2]
|
||||
} else {
|
||||
routsum += sir[0]
|
||||
goutsum += sir[1]
|
||||
boutsum += sir[2]
|
||||
}
|
||||
}
|
||||
stackpointer = radius
|
||||
|
||||
for (x2 in 0 until w) {
|
||||
r[yi] = dv[rsum]
|
||||
g[yi] = dv[gsum]
|
||||
b[yi] = dv[bsum]
|
||||
|
||||
rsum -= routsum
|
||||
gsum -= goutsum
|
||||
bsum -= boutsum
|
||||
|
||||
stackstart = stackpointer - radius + div
|
||||
sir = stack[stackstart % div]
|
||||
|
||||
routsum -= sir[0]
|
||||
goutsum -= sir[1]
|
||||
boutsum -= sir[2]
|
||||
|
||||
if (y2 == 0) {
|
||||
vmin[x2] = minOf(x2 + radius + 1, wm)
|
||||
}
|
||||
p = pix[yw + vmin[x2]]
|
||||
|
||||
sir[0] = (p and 0xff0000) shr 16
|
||||
sir[1] = (p and 0x00ff00) shr 8
|
||||
sir[2] = (p and 0x0000ff)
|
||||
|
||||
rinsum += sir[0]
|
||||
ginsum += sir[1]
|
||||
binsum += sir[2]
|
||||
|
||||
rsum += rinsum
|
||||
gsum += ginsum
|
||||
bsum += binsum
|
||||
|
||||
stackpointer = (stackpointer + 1) % div
|
||||
sir = stack[(stackpointer) % div]
|
||||
|
||||
routsum += sir[0]
|
||||
goutsum += sir[1]
|
||||
boutsum += sir[2]
|
||||
|
||||
rinsum -= sir[0]
|
||||
ginsum -= sir[1]
|
||||
binsum -= sir[2]
|
||||
|
||||
yi++
|
||||
}
|
||||
yw += w
|
||||
}
|
||||
|
||||
// Vertical pass
|
||||
for (x2 in 0 until w) {
|
||||
rinsum = 0
|
||||
ginsum = 0
|
||||
binsum = 0
|
||||
routsum = 0
|
||||
goutsum = 0
|
||||
boutsum = 0
|
||||
rsum = 0
|
||||
gsum = 0
|
||||
bsum = 0
|
||||
yp = -radius * w
|
||||
for (i2 in -radius..radius) {
|
||||
yi = maxOf(0, yp) + x2
|
||||
|
||||
sir = stack[i2 + radius]
|
||||
|
||||
sir[0] = r[yi]
|
||||
sir[1] = g[yi]
|
||||
sir[2] = b[yi]
|
||||
|
||||
rbs = r1 - kotlin.math.abs(i2)
|
||||
|
||||
rsum += r[yi] * rbs
|
||||
gsum += g[yi] * rbs
|
||||
bsum += b[yi] * rbs
|
||||
|
||||
if (i2 > 0) {
|
||||
rinsum += sir[0]
|
||||
ginsum += sir[1]
|
||||
binsum += sir[2]
|
||||
} else {
|
||||
routsum += sir[0]
|
||||
goutsum += sir[1]
|
||||
boutsum += sir[2]
|
||||
}
|
||||
|
||||
if (i2 < hm) {
|
||||
yp += w
|
||||
}
|
||||
}
|
||||
yi = x2
|
||||
stackpointer = radius
|
||||
for (y2 in 0 until h) {
|
||||
pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
|
||||
|
||||
rsum -= routsum
|
||||
gsum -= goutsum
|
||||
bsum -= boutsum
|
||||
|
||||
stackstart = stackpointer - radius + div
|
||||
sir = stack[stackstart % div]
|
||||
|
||||
routsum -= sir[0]
|
||||
goutsum -= sir[1]
|
||||
boutsum -= sir[2]
|
||||
|
||||
if (x2 == 0) {
|
||||
vmin[y2] = minOf(y2 + r1, hm) * w
|
||||
}
|
||||
p = x2 + vmin[y2]
|
||||
|
||||
sir[0] = r[p]
|
||||
sir[1] = g[p]
|
||||
sir[2] = b[p]
|
||||
|
||||
rinsum += sir[0]
|
||||
ginsum += sir[1]
|
||||
binsum += sir[2]
|
||||
|
||||
rsum += rinsum
|
||||
gsum += ginsum
|
||||
bsum += binsum
|
||||
|
||||
stackpointer = (stackpointer + 1) % div
|
||||
sir = stack[stackpointer]
|
||||
|
||||
routsum += sir[0]
|
||||
goutsum += sir[1]
|
||||
boutsum += sir[2]
|
||||
|
||||
rinsum -= sir[0]
|
||||
ginsum -= sir[1]
|
||||
binsum -= sir[2]
|
||||
|
||||
yi += w
|
||||
}
|
||||
}
|
||||
|
||||
val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
result.setPixels(pix, 0, w, 0, 0, w, h)
|
||||
return result
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue