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:
Ole-Morten Duesund 2026-03-18 17:50:30 +01:00
commit 88d04515e2
5 changed files with 579 additions and 501 deletions

View file

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