Stop trying to rotate the camera image based on device orientation. The activity is now locked to portrait (screenOrientation="portrait"), so the GL surface stays portrait-sized regardless of how the device is held, and the camera passthrough goes back to the simple texCoordsBack 90° rotation that was working before any of the v1.1.6–1.1.13 attempts at landscape support. Net effect: the camera image stays in the device's portrait frame and visually follows the phone as it tilts (since there is no inverse rotation cancelling it). The UI is also locked to the portrait layout for now — a follow-up will add Modifier.graphicsLayer rotations to the icon overlays so they stay readable when the phone is held sideways. screenOrientation switched from fullSensor to portrait; the rest of the file changes are reverts of the orientation plumbing introduced in v1.1.6 and its follow-ups. Bump to 1.1.14. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
602 lines
24 KiB
Kotlin
602 lines
24 KiB
Kotlin
package no.naiv.tiltshift.ui
|
|
|
|
import android.content.Intent
|
|
import android.graphics.SurfaceTexture
|
|
import android.opengl.GLSurfaceView
|
|
import android.util.Log
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
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.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.navigationBarsPadding
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
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.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.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.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
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.graphics.Color
|
|
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.semantics
|
|
import androidx.compose.ui.semantics.stateDescription
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.viewinterop.AndroidView
|
|
import androidx.lifecycle.Lifecycle
|
|
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.BlurParameters
|
|
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
|
import no.naiv.tiltshift.ui.theme.AppColors
|
|
|
|
/**
|
|
* Main camera screen with tilt-shift controls.
|
|
* Uses CameraViewModel to survive configuration changes.
|
|
*/
|
|
@Composable
|
|
fun CameraScreen(
|
|
modifier: Modifier = Modifier,
|
|
viewModel: CameraViewModel = viewModel()
|
|
) {
|
|
val context = LocalContext.current
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
|
|
// GL state (view-layer, not in ViewModel)
|
|
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
|
|
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
|
|
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
|
|
|
|
// Collect ViewModel state
|
|
val blurParams by viewModel.blurParams.collectAsState()
|
|
val isCapturing by viewModel.isCapturing.collectAsState()
|
|
val isProcessing by viewModel.isProcessing.collectAsState()
|
|
val showSaveSuccess by viewModel.showSaveSuccess.collectAsState()
|
|
val showSaveError by viewModel.showSaveError.collectAsState()
|
|
val showControls by viewModel.showControls.collectAsState()
|
|
val lastSavedUri by viewModel.lastSavedUri.collectAsState()
|
|
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
|
|
val galleryPreviewBitmap by viewModel.galleryPreviewBitmap.collectAsState()
|
|
val geotagEnabled by viewModel.geotagEnabled.collectAsState()
|
|
val isGalleryPreview = galleryPreviewBitmap != null
|
|
|
|
val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState()
|
|
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
|
|
val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState()
|
|
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
|
|
val previewResolution by viewModel.cameraManager.previewResolution.collectAsState()
|
|
val cameraError by viewModel.cameraManager.error.collectAsState()
|
|
|
|
// Gallery picker
|
|
val galleryLauncher = rememberLauncherForActivityResult(
|
|
contract = ActivityResultContracts.PickVisualMedia()
|
|
) { uri ->
|
|
if (uri != null) {
|
|
viewModel.loadGalleryImage(uri)
|
|
}
|
|
}
|
|
|
|
// Show camera errors
|
|
LaunchedEffect(cameraError) {
|
|
cameraError?.let { message ->
|
|
viewModel.showCameraError(message)
|
|
viewModel.cameraManager.clearError()
|
|
}
|
|
}
|
|
|
|
// Collect orientation updates
|
|
LaunchedEffect(Unit) {
|
|
viewModel.orientationDetector.orientationFlow().collectLatest { rotation ->
|
|
viewModel.updateRotation(rotation)
|
|
}
|
|
}
|
|
|
|
// Collect location updates
|
|
LaunchedEffect(Unit) {
|
|
viewModel.locationProvider.locationFlow().collectLatest { location ->
|
|
viewModel.updateLocation(location)
|
|
}
|
|
}
|
|
|
|
// Update renderer with blur params
|
|
LaunchedEffect(blurParams) {
|
|
renderer?.updateParameters(blurParams)
|
|
glSurfaceView?.requestRender()
|
|
}
|
|
|
|
// Update renderer when camera switches (front/back)
|
|
LaunchedEffect(isFrontCamera) {
|
|
renderer?.setFrontCamera(isFrontCamera)
|
|
glSurfaceView?.requestRender()
|
|
}
|
|
|
|
// Update renderer with camera preview resolution for crop-to-fill
|
|
LaunchedEffect(previewResolution) {
|
|
if (previewResolution.width > 0) {
|
|
renderer?.setCameraResolution(previewResolution.width, previewResolution.height)
|
|
glSurfaceView?.requestRender()
|
|
}
|
|
}
|
|
|
|
// Start camera when surface texture is available
|
|
LaunchedEffect(surfaceTexture) {
|
|
surfaceTexture?.let {
|
|
viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
|
|
}
|
|
}
|
|
|
|
// Pause/resume GLSurfaceView when entering/leaving gallery preview
|
|
LaunchedEffect(isGalleryPreview) {
|
|
if (isGalleryPreview) {
|
|
glSurfaceView?.onPause()
|
|
} else if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
|
glSurfaceView?.onResume()
|
|
}
|
|
}
|
|
|
|
// Tie GLSurfaceView lifecycle to Activity lifecycle to prevent background rendering
|
|
val currentIsGalleryPreview by rememberUpdatedState(isGalleryPreview)
|
|
DisposableEffect(lifecycleOwner) {
|
|
val observer = LifecycleEventObserver { _, event ->
|
|
when (event) {
|
|
Lifecycle.Event.ON_RESUME -> {
|
|
if (!currentIsGalleryPreview) {
|
|
glSurfaceView?.onResume()
|
|
}
|
|
}
|
|
Lifecycle.Event.ON_PAUSE -> glSurfaceView?.onPause()
|
|
else -> {}
|
|
}
|
|
}
|
|
lifecycleOwner.lifecycle.addObserver(observer)
|
|
onDispose {
|
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
|
glSurfaceView?.queueEvent { renderer?.release() }
|
|
}
|
|
}
|
|
|
|
Box(
|
|
modifier = modifier
|
|
.fillMaxSize()
|
|
.background(Color.Black)
|
|
) {
|
|
// Main view: gallery preview image or camera GL surface
|
|
if (isGalleryPreview) {
|
|
galleryPreviewBitmap?.let { bmp ->
|
|
Image(
|
|
bitmap = bmp.asImageBitmap(),
|
|
contentDescription = "Gallery image preview with tilt-shift effect. Adjust parameters then tap Apply.",
|
|
contentScale = ContentScale.Fit,
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(Color.Black)
|
|
)
|
|
}
|
|
} else {
|
|
// OpenGL Surface for camera preview with effect
|
|
AndroidView(
|
|
factory = { ctx ->
|
|
GLSurfaceView(ctx).apply {
|
|
setEGLContextClientVersion(2)
|
|
|
|
val view = this
|
|
val newRenderer = TiltShiftRenderer(
|
|
context = ctx,
|
|
onSurfaceTextureAvailable = { st -> surfaceTexture = st },
|
|
onFrameAvailable = { view.requestRender() }
|
|
)
|
|
renderer = newRenderer
|
|
|
|
setRenderer(newRenderer)
|
|
renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
|
|
|
|
glSurfaceView = this
|
|
}
|
|
},
|
|
modifier = Modifier.fillMaxSize()
|
|
)
|
|
}
|
|
|
|
// Tilt-shift overlay (gesture handling + visualization)
|
|
TiltShiftOverlay(
|
|
params = blurParams,
|
|
onParamsChange = { newParams ->
|
|
viewModel.updateBlurParams(newParams)
|
|
},
|
|
onZoomChange = { zoomDelta ->
|
|
if (!isGalleryPreview) {
|
|
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
|
viewModel.cameraManager.setZoom(newZoom)
|
|
}
|
|
},
|
|
modifier = Modifier.fillMaxSize()
|
|
)
|
|
|
|
// Top bar with controls
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.statusBarsPadding()
|
|
.padding(16.dp)
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
if (!isGalleryPreview) {
|
|
ZoomIndicator(currentZoom = zoomRatio)
|
|
} else {
|
|
Spacer(modifier = Modifier.width(1.dp))
|
|
}
|
|
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
// GPS geotagging toggle
|
|
IconButton(
|
|
onClick = {
|
|
viewModel.toggleGeotag()
|
|
viewModel.haptics.tick()
|
|
}
|
|
) {
|
|
Icon(
|
|
imageVector = if (geotagEnabled) Icons.Default.LocationOn else Icons.Default.LocationOff,
|
|
contentDescription = if (geotagEnabled) "Disable GPS geotagging" else "Enable GPS geotagging",
|
|
tint = if (geotagEnabled) Color.White else Color.White.copy(alpha = 0.5f)
|
|
)
|
|
}
|
|
|
|
if (!isGalleryPreview) {
|
|
// Camera flip button
|
|
IconButton(
|
|
onClick = {
|
|
viewModel.cameraManager.switchCamera()
|
|
viewModel.haptics.click()
|
|
}
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.FlipCameraAndroid,
|
|
contentDescription = "Switch between front and back camera",
|
|
tint = Color.White
|
|
)
|
|
}
|
|
}
|
|
|
|
// Toggle controls button (tune icon instead of cryptic "Ctrl")
|
|
IconButton(
|
|
onClick = {
|
|
viewModel.toggleControls()
|
|
viewModel.haptics.tick()
|
|
},
|
|
modifier = Modifier.semantics {
|
|
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
|
|
}
|
|
) {
|
|
Icon(
|
|
imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune,
|
|
contentDescription = if (showControls) "Hide blur controls" else "Show blur controls",
|
|
tint = Color.White
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mode toggle
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 8.dp),
|
|
horizontalArrangement = Arrangement.Center
|
|
) {
|
|
ModeToggle(
|
|
currentMode = blurParams.mode,
|
|
onModeChange = { mode ->
|
|
viewModel.updateBlurParams(blurParams.copy(mode = mode))
|
|
viewModel.haptics.click()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// Control panel (sliders)
|
|
AnimatedVisibility(
|
|
visible = showControls,
|
|
enter = fadeIn(),
|
|
exit = fadeOut(),
|
|
modifier = Modifier
|
|
.align(Alignment.CenterEnd)
|
|
.padding(end = 16.dp)
|
|
) {
|
|
ControlPanel(
|
|
params = blurParams,
|
|
onParamsChange = { newParams ->
|
|
viewModel.updateBlurParams(newParams)
|
|
},
|
|
onReset = { viewModel.resetBlurParams() }
|
|
)
|
|
}
|
|
|
|
// Bottom controls
|
|
Column(
|
|
modifier = Modifier
|
|
.align(Alignment.BottomCenter)
|
|
.navigationBarsPadding()
|
|
.padding(bottom = 48.dp)
|
|
.systemGestureExclusion(),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
if (isGalleryPreview) {
|
|
// Gallery preview mode: Cancel | Apply (matched layout to camera mode)
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
|
) {
|
|
// Cancel button (same 52dp as gallery button for layout consistency)
|
|
IconButton(
|
|
onClick = { viewModel.cancelGalleryPreview() },
|
|
modifier = Modifier
|
|
.size(52.dp)
|
|
.clip(CircleShape)
|
|
.background(AppColors.OverlayDark)
|
|
.semantics { contentDescription = "Cancel gallery import" }
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.Close,
|
|
contentDescription = null,
|
|
tint = Color.White,
|
|
modifier = Modifier.size(28.dp)
|
|
)
|
|
}
|
|
|
|
// Apply button (same 72dp as capture button for layout consistency)
|
|
Box(
|
|
modifier = Modifier
|
|
.size(72.dp)
|
|
.clip(CircleShape)
|
|
.border(4.dp, Color.White, CircleShape)
|
|
.clickable(
|
|
enabled = !isCapturing,
|
|
role = Role.Button,
|
|
onClick = { viewModel.applyGalleryEffect() }
|
|
)
|
|
.semantics {
|
|
contentDescription = "Apply tilt-shift effect to gallery image"
|
|
if (isCapturing) stateDescription = "Processing"
|
|
},
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(if (isCapturing) 48.dp else 60.dp)
|
|
.clip(CircleShape)
|
|
.background(if (isCapturing) AppColors.Accent.copy(alpha = 0.5f) else AppColors.Accent),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
if (isProcessing) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.size(24.dp),
|
|
color = Color.Black,
|
|
strokeWidth = 3.dp
|
|
)
|
|
} else {
|
|
Icon(
|
|
imageVector = Icons.Default.Check,
|
|
contentDescription = null,
|
|
tint = Color.Black,
|
|
modifier = Modifier.size(28.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spacer for visual symmetry
|
|
Spacer(modifier = Modifier.size(52.dp))
|
|
}
|
|
} else {
|
|
// Camera mode: Zoom presets + Gallery | Capture | Spacer
|
|
if (!isFrontCamera) {
|
|
ZoomControl(
|
|
currentZoom = zoomRatio,
|
|
minZoom = minZoom,
|
|
maxZoom = maxZoom,
|
|
onZoomSelected = { zoom ->
|
|
viewModel.cameraManager.setZoom(zoom)
|
|
viewModel.haptics.click()
|
|
}
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
}
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
|
) {
|
|
// Gallery picker button (with background for discoverability)
|
|
IconButton(
|
|
onClick = {
|
|
if (!isCapturing) {
|
|
galleryLauncher.launch(
|
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
|
)
|
|
}
|
|
},
|
|
enabled = !isCapturing,
|
|
modifier = Modifier
|
|
.size(52.dp)
|
|
.clip(CircleShape)
|
|
.background(AppColors.OverlayDark)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.PhotoLibrary,
|
|
contentDescription = "Pick image from gallery",
|
|
tint = Color.White,
|
|
modifier = Modifier.size(28.dp)
|
|
)
|
|
}
|
|
|
|
// Capture button
|
|
CaptureButton(
|
|
isCapturing = isCapturing,
|
|
isProcessing = isProcessing,
|
|
onClick = { viewModel.capturePhoto() }
|
|
)
|
|
|
|
// Spacer for visual symmetry with gallery button
|
|
Spacer(modifier = Modifier.size(52.dp))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Last captured photo thumbnail (hidden in gallery preview mode)
|
|
if (!isGalleryPreview) LastPhotoThumbnail(
|
|
thumbnail = lastThumbnailBitmap,
|
|
onTap = {
|
|
lastSavedUri?.let { uri ->
|
|
try {
|
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
setDataAndType(uri, "image/jpeg")
|
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
}
|
|
context.startActivity(intent)
|
|
} catch (e: android.content.ActivityNotFoundException) {
|
|
Log.w("CameraScreen", "No activity found to view image", e)
|
|
viewModel.showCameraError("No app available to view photos")
|
|
}
|
|
}
|
|
},
|
|
modifier = Modifier
|
|
.align(Alignment.BottomEnd)
|
|
.navigationBarsPadding()
|
|
.padding(bottom = 48.dp, end = 16.dp)
|
|
)
|
|
|
|
// Success indicator (announced to accessibility)
|
|
AnimatedVisibility(
|
|
visible = showSaveSuccess,
|
|
enter = fadeIn(),
|
|
exit = fadeOut(),
|
|
modifier = Modifier
|
|
.align(Alignment.Center)
|
|
.semantics { liveRegion = LiveRegionMode.Polite }
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(80.dp)
|
|
.clip(CircleShape)
|
|
.background(AppColors.Success),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.Check,
|
|
contentDescription = "Photo saved successfully",
|
|
tint = Color.White,
|
|
modifier = Modifier.size(48.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Error indicator (announced to accessibility)
|
|
AnimatedVisibility(
|
|
visible = showSaveError != null,
|
|
enter = fadeIn(),
|
|
exit = fadeOut(),
|
|
modifier = Modifier
|
|
.align(Alignment.Center)
|
|
.padding(32.dp)
|
|
.semantics { liveRegion = LiveRegionMode.Assertive }
|
|
) {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
modifier = Modifier
|
|
.clip(RoundedCornerShape(16.dp))
|
|
.background(AppColors.Error)
|
|
.padding(24.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.Close,
|
|
contentDescription = null,
|
|
tint = Color.White,
|
|
modifier = Modifier.size(32.dp)
|
|
)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Text(
|
|
text = showSaveError ?: "Error",
|
|
color = Color.White,
|
|
fontSize = 14.sp,
|
|
maxLines = 3
|
|
)
|
|
}
|
|
}
|
|
|
|
// Processing overlay
|
|
AnimatedVisibility(
|
|
visible = isProcessing,
|
|
enter = fadeIn(),
|
|
exit = fadeOut(),
|
|
modifier = Modifier.align(Alignment.Center)
|
|
) {
|
|
if (!showSaveSuccess) {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(80.dp)
|
|
.clip(CircleShape)
|
|
.background(AppColors.OverlayDark),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
CircularProgressIndicator(
|
|
color = AppColors.Accent,
|
|
strokeWidth = 3.dp,
|
|
modifier = Modifier.size(36.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|