Created
April 29, 2026 09:18
-
-
Save TuleSimon/10eecc0e72304b967a0d258c4e212104 to your computer and use it in GitHub Desktop.
A flashlight in Jetpack Compose that reveals text hidden in the dark. The cone shape, soft diffusion, dust streaks, and warm bulb glow are all one AGSL fragment shader running over regular Compose Text. You can drag the torch to move it, and tap the switch on the body to toggle the light.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.anonymous.flashlight | |
| import android.graphics.RenderEffect | |
| import android.graphics.RuntimeShader | |
| import androidx.compose.animation.core.LinearEasing | |
| import androidx.compose.animation.core.RepeatMode | |
| import androidx.compose.animation.core.animateFloat | |
| import androidx.compose.animation.core.animateFloatAsState | |
| import androidx.compose.animation.core.infiniteRepeatable | |
| import androidx.compose.animation.core.rememberInfiniteTransition | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.gestures.awaitEachGesture | |
| import androidx.compose.foundation.gestures.awaitFirstDown | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.rememberScrollState | |
| import androidx.compose.foundation.verticalScroll | |
| import androidx.compose.material3.Slider | |
| import androidx.compose.material3.SliderDefaults | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.Stable | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableFloatStateOf | |
| 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.geometry.CornerRadius | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Rect | |
| import androidx.compose.ui.geometry.Size | |
| import androidx.compose.ui.geometry.isSpecified | |
| import androidx.compose.ui.geometry.isUnspecified | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.asComposeRenderEffect | |
| import androidx.compose.ui.graphics.drawscope.DrawScope | |
| import androidx.compose.ui.graphics.drawscope.rotate | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.graphics.lerp | |
| import androidx.compose.ui.input.pointer.AwaitPointerEventScope | |
| import androidx.compose.ui.input.pointer.PointerInputScope | |
| import androidx.compose.ui.input.pointer.changedToUp | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.layout.onSizeChanged | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.text.style.TextAlign | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import androidx.compose.ui.unit.toSize | |
| import com.anonymous.flashlight.ui.theme.FlashlightTheme | |
| import kotlin.math.PI | |
| import kotlin.math.abs | |
| import kotlin.math.atan2 | |
| import kotlin.math.cos | |
| import kotlin.math.min | |
| import kotlin.math.sin | |
| private const val SHADER_SRC = """ | |
| uniform shader content; | |
| uniform float2 resolution; | |
| uniform float2 flashPos; | |
| uniform float heading; | |
| uniform float halfAngle; | |
| uniform float coneLength; | |
| uniform float onAlpha; | |
| uniform float flickerSeed; | |
| uniform float brightness; | |
| float hash(float2 p) { | |
| p = fract(p * float2(123.34, 456.21)); | |
| p += dot(p, p + 45.32); | |
| return fract(p.x * p.y); | |
| } | |
| float valueNoise(float2 p) { | |
| float2 i = floor(p); | |
| float2 f = fract(p); | |
| float2 u = f * f * (3.0 - 2.0 * f); | |
| float a = hash(i); | |
| float b = hash(i + float2(1.0, 0.0)); | |
| float c = hash(i + float2(0.0, 1.0)); | |
| float d = hash(i + float2(1.0, 1.0)); | |
| return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); | |
| } | |
| half4 main(float2 fragCoord) { | |
| half4 src = content.eval(fragCoord); | |
| float2 delta = fragCoord - flashPos; | |
| float dist = length(delta); | |
| // angular offset from the beam axis, wrapped to [-PI, PI] | |
| float angle = atan(delta.y, delta.x); | |
| float aDiff = angle - heading; | |
| aDiff = aDiff - 6.28318530718 * floor((aDiff + 3.14159265359) / 6.28318530718); | |
| // beam-aligned coords so noise stretches into ray-like streaks | |
| float ch = cos(-heading); | |
| float sh = sin(-heading); | |
| float2 beam = float2( | |
| delta.x * ch - delta.y * sh, | |
| delta.x * sh + delta.y * ch | |
| ); | |
| float n1 = valueNoise(float2(beam.x * 0.004, beam.y * 0.025) + float2(flickerSeed * 0.5, 0.0)); | |
| float n2 = valueNoise(float2(beam.x * 0.012, beam.y * 0.06) + float2(0.0, flickerSeed * 0.7)); | |
| float dust = n1 * 0.6 + n2 * 0.4; | |
| // tight bright core + wide soft skirt, combined into a single angular falloff. | |
| // both branches shape the same intensity mask — no additive warm overlay, | |
| // so the cone illuminates the text rather than painting fog on top of it. | |
| float core = exp(-(aDiff * aDiff) / (2.0 * (halfAngle * 0.5) * (halfAngle * 0.5))); | |
| float wide = exp(-(aDiff * aDiff) / (2.0 * (halfAngle * 1.25) * (halfAngle * 1.25))) * 0.32; | |
| float angFall = max(core, wide); | |
| float r = dist / coneLength; | |
| float radFall = 1.0 / (1.0 + 5.0 * r * r); | |
| float intensity = angFall * radFall * onAlpha * (0.88 + 0.18 * dust); | |
| half3 warm = half3(1.00, 0.93, 0.74); | |
| half3 cool = half3(0.88, 0.91, 1.00); | |
| half3 tint = mix(warm, cool, smoothstep(0.0, coneLength, dist)); | |
| half3 lit = src.rgb * tint * brightness; | |
| half3 outRgb = mix(half3(0.0), lit, intensity); | |
| // tight bulb glow right at the lens — the only additive layer | |
| float haloR = dist / (coneLength * 0.07); | |
| outRgb += warm * exp(-haloR * haloR) * onAlpha * 0.35 * brightness; | |
| return half4(outRgb, 1.0); | |
| } | |
| """ | |
| @Stable | |
| class FlashlightState( | |
| initialPosition: Offset = Offset.Unspecified, | |
| initialIsOn: Boolean = false, | |
| initialHeadingRadians: Float = -(PI / 2.0).toFloat(), | |
| ) { | |
| var position: Offset by mutableStateOf(initialPosition) | |
| var isOn: Boolean by mutableStateOf(initialIsOn) | |
| var headingRadians: Float by mutableFloatStateOf(initialHeadingRadians) | |
| fun toggle() { | |
| isOn = !isOn | |
| } | |
| fun translate(delta: Offset, bounds: Size) { | |
| if (position.isUnspecified) return | |
| val newX = (position.x + delta.x).coerceIn(0f, bounds.width) | |
| val newY = (position.y + delta.y).coerceIn(0f, bounds.height) | |
| position = Offset(newX, newY) | |
| } | |
| fun rotateBy(deltaRadians: Float) { | |
| headingRadians += deltaRadians | |
| } | |
| } | |
| @Composable | |
| fun rememberFlashlightState(): FlashlightState = remember { FlashlightState() } | |
| @Composable | |
| fun FlashlightScreen( | |
| state: FlashlightState = rememberFlashlightState(), | |
| ) { | |
| val shader = remember { RuntimeShader(SHADER_SRC) } | |
| var brightness by remember { mutableFloatStateOf(1.9f) } | |
| val coneAlpha by animateFloatAsState( | |
| targetValue = if (state.isOn) 1f else 0f, | |
| animationSpec = tween(durationMillis = 220), | |
| label = "coneAlpha", | |
| ) | |
| val nubProgress by animateFloatAsState( | |
| targetValue = if (state.isOn) 1f else 0f, | |
| animationSpec = tween(durationMillis = 180), | |
| label = "nubProgress", | |
| ) | |
| val flickerTransition = rememberInfiniteTransition(label = "flicker") | |
| val flickerSeed by flickerTransition.animateFloat( | |
| initialValue = 0f, | |
| targetValue = 1000f, | |
| animationSpec = infiniteRepeatable( | |
| animation = tween(durationMillis = 60_000, easing = LinearEasing), | |
| repeatMode = RepeatMode.Restart, | |
| ), | |
| label = "flickerSeed", | |
| ) | |
| Box(modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.Black)) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .onSizeChanged { sz -> | |
| if (state.position.isUnspecified && sz.width > 0 && sz.height > 0) { | |
| state.position = Offset(sz.width / 2f, sz.height / 2f) | |
| } | |
| } | |
| .graphicsLayer { | |
| val safePos = if (state.position.isSpecified) state.position | |
| else Offset(-10_000f, -10_000f) | |
| shader.setFloatUniform("resolution", size.width, size.height) | |
| shader.setFloatUniform("flashPos", safePos.x, safePos.y) | |
| shader.setFloatUniform("heading", state.headingRadians) | |
| shader.setFloatUniform("halfAngle", (PI / 7.0).toFloat()) | |
| shader.setFloatUniform( | |
| "coneLength", | |
| min(size.width, size.height) * 0.9f, | |
| ) | |
| shader.setFloatUniform("onAlpha", coneAlpha) | |
| shader.setFloatUniform("flickerSeed", flickerSeed) | |
| shader.setFloatUniform("brightness", brightness) | |
| renderEffect = RenderEffect | |
| .createRuntimeShaderEffect(shader, "content") | |
| .asComposeRenderEffect() | |
| }, | |
| ) { | |
| RevealText() | |
| } | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .pointerInput(state) { | |
| awaitEachGesture { | |
| val down = awaitFirstDown() | |
| if (down.position in switchHitRect(state)) { | |
| handleSwitchTap(down.position, state) | |
| } else { | |
| handleBodyDrag(down.position, state) | |
| } | |
| } | |
| }, | |
| ) { | |
| drawTorchBody(state, nubProgress = nubProgress) | |
| } | |
| BrightnessSlider( | |
| value = brightness, | |
| onValueChange = { brightness = it }, | |
| modifier = Modifier | |
| .align(Alignment.BottomCenter) | |
| .fillMaxWidth(0.7f) | |
| .padding(bottom = 48.dp), | |
| ) | |
| } | |
| } | |
| private suspend fun AwaitPointerEventScope.handleSwitchTap( | |
| downPos: Offset, | |
| state: FlashlightState, | |
| ) { | |
| val cancelDistance = 8.dp.toPx() | |
| while (true) { | |
| val change = awaitPointerEvent().changes.first() | |
| if (change.changedToUp()) { | |
| state.toggle() | |
| change.consume() | |
| return | |
| } | |
| if ((change.position - downPos).getDistance() > cancelDistance) return | |
| change.consume() | |
| } | |
| } | |
| private suspend fun AwaitPointerEventScope.handleBodyDrag( | |
| downPos: Offset, | |
| state: FlashlightState, | |
| ) { | |
| var prev = downPos | |
| var prevDir: Float? = null | |
| while (true) { | |
| val change = awaitPointerEvent().changes.first() | |
| if (change.changedToUp()) { | |
| change.consume() | |
| return | |
| } | |
| val delta = change.position - prev | |
| prev = change.position | |
| state.translate(delta, size.toSize()) | |
| if (delta.getDistance() > 4f) { | |
| val dir = atan2(delta.y, delta.x) | |
| prevDir?.let { p -> | |
| var d = dir - p | |
| if (d > PI) d -= (2 * PI).toFloat() | |
| if (d < -PI) d += (2 * PI).toFloat() | |
| if (abs(d) > 0.02f) state.rotateBy(d.coerceIn(-0.15f, 0.15f)) | |
| } | |
| prevDir = dir | |
| } | |
| change.consume() | |
| } | |
| } | |
| @Composable | |
| private fun BrightnessSlider( | |
| value: Float, | |
| onValueChange: (Float) -> Unit, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| Column( | |
| modifier = modifier, | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| ) { | |
| Text( | |
| text = "Brightness", | |
| color = Color.White.copy(alpha = 0.55f), | |
| fontSize = 11.sp, | |
| letterSpacing = 2.sp, | |
| ) | |
| Slider( | |
| value = value, | |
| onValueChange = onValueChange, | |
| valueRange = 0.5f..3.0f, | |
| colors = SliderDefaults.colors( | |
| thumbColor = Color(0xFFFFD54F), | |
| activeTrackColor = Color(0xFFFFD54F).copy(alpha = 0.7f), | |
| inactiveTrackColor = Color.White.copy(alpha = 0.18f), | |
| activeTickColor = Color.Transparent, | |
| inactiveTickColor = Color.Transparent, | |
| ), | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun RevealText() { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .verticalScroll(rememberScrollState()) | |
| .padding(horizontal = 24.dp, vertical = 56.dp), | |
| verticalArrangement = Arrangement.spacedBy(20.dp), | |
| ) { | |
| Text( | |
| text = "Lorem ipsum", | |
| color = Color.White, | |
| fontSize = 28.sp, | |
| fontWeight = FontWeight.SemiBold, | |
| letterSpacing = 1.sp, | |
| textAlign = TextAlign.Center, | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(bottom = 8.dp), | |
| ) | |
| for (paragraph in LOREM_PARAGRAPHS) { | |
| Text( | |
| text = paragraph, | |
| color = Color.White, | |
| fontSize = 16.sp, | |
| lineHeight = 28.sp, | |
| letterSpacing = 0.5.sp, | |
| ) | |
| } | |
| } | |
| } | |
| private val LOREM_PARAGRAPHS = listOf( | |
| "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor " + | |
| "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis " + | |
| "nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", | |
| "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu " + | |
| "fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in " + | |
| "culpa qui officia deserunt mollit anim id est laborum.", | |
| "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium " + | |
| "doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore " + | |
| "veritatis et quasi architecto beatae vitae dicta sunt explicabo.", | |
| "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed " + | |
| "quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. " + | |
| "Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.", | |
| "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis " + | |
| "praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias " + | |
| "excepturi sint occaecati cupiditate non provident, similique sunt in culpa.", | |
| ) | |
| private val switchLocalY = 30.dp | |
| private val switchHitHalf = 28.dp | |
| private fun PointerInputScope.switchHitRect(state: FlashlightState): Rect { | |
| if (state.position.isUnspecified) return Rect.Zero | |
| val localY = switchLocalY.toPx() | |
| val rotation = state.headingRadians + (PI / 2.0).toFloat() | |
| val c = cos(rotation) | |
| val s = sin(rotation) | |
| val center = state.position + Offset(-localY * s, localY * c) | |
| val half = switchHitHalf.toPx() | |
| return Rect(center.x - half, center.y - half, center.x + half, center.y + half) | |
| } | |
| private fun DrawScope.drawTorchBody(state: FlashlightState, nubProgress: Float) { | |
| if (state.position.isUnspecified) return | |
| val rotationDegrees = Math.toDegrees(state.headingRadians + PI / 2.0).toFloat() | |
| val apex = state.position | |
| rotate(degrees = rotationDegrees, pivot = apex) { | |
| val bezelW = 38.dp.toPx() | |
| val bezelH = 16.dp.toPx() | |
| val collarW = 32.dp.toPx() | |
| val collarH = 5.dp.toPx() | |
| val bodyW = 26.dp.toPx() | |
| val bodyH = 64.dp.toPx() | |
| val tailCollarW = 30.dp.toPx() | |
| val tailCollarH = 5.dp.toPx() | |
| val tailW = 28.dp.toPx() | |
| val tailH = 10.dp.toPx() | |
| val bezelTop = apex.y - bezelH / 2f | |
| drawRoundRect( | |
| color = Color(0xFF3F3F3F), | |
| topLeft = Offset(apex.x - bezelW / 2f, bezelTop), | |
| size = Size(bezelW, bezelH), | |
| cornerRadius = CornerRadius(5.dp.toPx()), | |
| ) | |
| drawRoundRect( | |
| color = Color(0xFF6E6E6E), | |
| topLeft = Offset(apex.x - bezelW / 2f + 1.dp.toPx(), bezelTop + 1.dp.toPx()), | |
| size = Size(bezelW - 2.dp.toPx(), 3.dp.toPx()), | |
| cornerRadius = CornerRadius(3.dp.toPx()), | |
| ) | |
| var y = bezelTop + bezelH | |
| drawRoundRect( | |
| color = Color(0xFF2A2A2A), | |
| topLeft = Offset(apex.x - collarW / 2f, y), | |
| size = Size(collarW, collarH), | |
| cornerRadius = CornerRadius(2.dp.toPx()), | |
| ) | |
| y += collarH | |
| val bodyTop = y | |
| drawRoundRect( | |
| color = Color(0xFF252525), | |
| topLeft = Offset(apex.x - bodyW / 2f, bodyTop), | |
| size = Size(bodyW, bodyH), | |
| cornerRadius = CornerRadius(5.dp.toPx()), | |
| ) | |
| drawRoundRect( | |
| color = Color(0xFF3D3D3D), | |
| topLeft = Offset(apex.x - bodyW / 2f + 3.dp.toPx(), bodyTop + 4.dp.toPx()), | |
| size = Size(2.dp.toPx(), bodyH - 8.dp.toPx()), | |
| cornerRadius = CornerRadius(1.dp.toPx()), | |
| ) | |
| val ridgeStartY = bodyTop + bodyH * 0.55f | |
| repeat(5) { i -> | |
| drawRoundRect( | |
| color = Color(0xFF101010), | |
| topLeft = Offset( | |
| apex.x - bodyW / 2f + 2.dp.toPx(), | |
| ridgeStartY + i * 4.dp.toPx(), | |
| ), | |
| size = Size(bodyW - 4.dp.toPx(), 1.5.dp.toPx()), | |
| cornerRadius = CornerRadius(0.5.dp.toPx()), | |
| ) | |
| } | |
| y = bodyTop + bodyH | |
| drawRoundRect( | |
| color = Color(0xFF2A2A2A), | |
| topLeft = Offset(apex.x - tailCollarW / 2f, y), | |
| size = Size(tailCollarW, tailCollarH), | |
| cornerRadius = CornerRadius(2.dp.toPx()), | |
| ) | |
| y += tailCollarH | |
| drawRoundRect( | |
| color = Color(0xFF1A1A1A), | |
| topLeft = Offset(apex.x - tailW / 2f, y), | |
| size = Size(tailW, tailH), | |
| cornerRadius = CornerRadius(4.dp.toPx()), | |
| ) | |
| val switchBaseW = 16.dp.toPx() | |
| val switchBaseH = 26.dp.toPx() | |
| val switchCenterY = apex.y + switchLocalY.toPx() | |
| drawRoundRect( | |
| color = Color(0xFF0A0A0A), | |
| topLeft = Offset(apex.x - switchBaseW / 2f, switchCenterY - switchBaseH / 2f), | |
| size = Size(switchBaseW, switchBaseH), | |
| cornerRadius = CornerRadius(3.dp.toPx()), | |
| ) | |
| drawRoundRect( | |
| color = Color(0xFF1F1F1F), | |
| topLeft = Offset( | |
| apex.x - switchBaseW / 2f + 1.5.dp.toPx(), | |
| switchCenterY - switchBaseH / 2f + 1.5.dp.toPx(), | |
| ), | |
| size = Size(switchBaseW - 3.dp.toPx(), switchBaseH - 3.dp.toPx()), | |
| cornerRadius = CornerRadius(2.dp.toPx()), | |
| ) | |
| val nubW = 12.dp.toPx() | |
| val nubH = 11.dp.toPx() | |
| val trackTop = switchCenterY - switchBaseH / 2f + 2.5.dp.toPx() | |
| val trackBottom = switchCenterY + switchBaseH / 2f - 2.5.dp.toPx() - nubH | |
| val nubY = trackBottom + (trackTop - trackBottom) * nubProgress | |
| val nubColor = lerp(Color(0xFFCFCFCF), Color(0xFFFFD54F), nubProgress) | |
| drawRoundRect( | |
| color = nubColor, | |
| topLeft = Offset(apex.x - nubW / 2f, nubY), | |
| size = Size(nubW, nubH), | |
| cornerRadius = CornerRadius(2.5.dp.toPx()), | |
| ) | |
| drawRoundRect( | |
| color = Color.White.copy(alpha = 0.35f), | |
| topLeft = Offset(apex.x - nubW / 2f + 1.5.dp.toPx(), nubY + 1.5.dp.toPx()), | |
| size = Size(nubW - 3.dp.toPx(), 2.dp.toPx()), | |
| cornerRadius = CornerRadius(1.dp.toPx()), | |
| ) | |
| drawCircle( | |
| color = Color(0xFF0A0A0A), | |
| radius = 14.dp.toPx(), | |
| center = apex, | |
| ) | |
| drawCircle( | |
| color = if (state.isOn) Color(0xFFFFE082) else Color(0xFF2A2520), | |
| radius = 11.dp.toPx(), | |
| center = apex, | |
| alpha = if (state.isOn) 1f else 0.85f, | |
| ) | |
| drawCircle( | |
| color = Color.White, | |
| radius = 4.dp.toPx(), | |
| center = apex, | |
| alpha = if (state.isOn) 0.95f else 0f, | |
| ) | |
| if (state.isOn) { | |
| drawCircle( | |
| color = Color.White.copy(alpha = 0.55f), | |
| radius = 2.5.dp.toPx(), | |
| center = Offset(apex.x - 4.dp.toPx(), apex.y - 4.dp.toPx()), | |
| ) | |
| } | |
| } | |
| } | |
| // Layoutlib in Android Studio doesn't fully render RuntimeShader effects, so the | |
| // preview will show the underlying text + torch crisply rather than masked. Still | |
| // useful for layout iteration; runtime rendering is correct. | |
| @Preview(showBackground = true, backgroundColor = 0xFF000000, widthDp = 380, heightDp = 800) | |
| @Composable | |
| private fun FlashlightOffPreview() { | |
| FlashlightTheme { FlashlightScreen() } | |
| } | |
| @Preview(showBackground = true, backgroundColor = 0xFF000000, widthDp = 380, heightDp = 800) | |
| @Composable | |
| private fun FlashlightOnPreview() { | |
| FlashlightTheme { | |
| val state = remember { | |
| FlashlightState( | |
| initialPosition = Offset(540f, 1700f), | |
| initialIsOn = true, | |
| ) | |
| } | |
| FlashlightScreen(state = state) | |
| } | |
| } |
TuleSimon
commented
Apr 29, 2026
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment