Replace hardcoded portrait-only texture coordinate rotation with SurfaceTexture.getTransformMatrix(), so the camera preview and capture re-orient correctly when the device rotates. Also drive Preview/ImageCapture targetRotation from the live display rotation, fix the crop-to-fill aspect math to swap effective camera dimensions between portrait and landscape, and make the slider control panel scroll if it doesn't fit the shorter landscape height. Bump to 1.1.6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
610 lines
24 KiB
Kotlin
610 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()
|
|
val currentRotation by viewModel.currentRotation.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()
|
|
}
|
|
}
|
|
|
|
// Forward device rotation to renderer (aspect math) and CameraX (target rotation)
|
|
LaunchedEffect(currentRotation, renderer) {
|
|
renderer?.setDisplayRotation(currentRotation)
|
|
viewModel.cameraManager.setTargetRotation(currentRotation)
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|