From be3c8859880bdb4174a693efb4153aed30537c78 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 30 Jul 2026 12:07:05 +0200 Subject: [PATCH] =?UTF-8?q?Dodaj=20seryjne=20zdj=C4=99cia=20za=C5=82adunku?= =?UTF-8?q?=20i=20roz=C5=82adunku?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 185 ++++++++++++- .../kierowca/ui/MultiplePhotoCameraScreen.kt | 252 ++++++++++++++++++ 2 files changed, 423 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/ui/MultiplePhotoCameraScreen.kt diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 0339120..9b205a5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -62,6 +62,7 @@ import androidx.compose.material.icons.outlined.CalendarToday import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Collections import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Delete @@ -190,6 +191,11 @@ private enum class RouteLocationPermissionPurpose { RestoreRequiredTracking, } +private enum class RouteStageCameraMode { + Single, + Multiple, +} + @Composable fun DriverApp( viewModel: DriverViewModel, @@ -4425,6 +4431,8 @@ private fun RouteStageScreen( val context = LocalContext.current val coroutineScope = rememberCoroutineScope() var cameraUri by remember { mutableStateOf(null) } + var pendingCameraMode by remember { mutableStateOf(null) } + var showMultiplePhotoCamera by remember(stage) { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var voiceDictationActive by remember(stage) { mutableStateOf(false) } val route = state.displaySelectedRoute @@ -4479,6 +4487,7 @@ private fun RouteStageScreen( cameraUri = null } val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> + val requestedMode = pendingCameraMode val pendingUri = cameraUri val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA) val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || @@ -4486,14 +4495,43 @@ private fun RouteStageScreen( if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) { showPreciseLocationPermissionDialog = true cameraUri = null + pendingCameraMode = null return@rememberLauncherForActivityResult } - if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri) + if (cameraGranted) { + when (requestedMode) { + RouteStageCameraMode.Single -> { + if (pendingUri != null) cameraLauncher.launch(pendingUri) + } + RouteStageCameraMode.Multiple -> { + showMultiplePhotoCamera = true + } + null -> Unit + } + } else { + cameraUri = null + } + pendingCameraMode = null } val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata()) } + if (showMultiplePhotoCamera) { + MultiplePhotoCameraScreen( + stage = stage, + onPhotoCaptured = { uri -> + coroutineScope.launch { + onUpload(uri, "camera", cameraPhotoMetadata(context)) + } + }, + onClose = { + showMultiplePhotoCamera = false + }, + ) + return + } + BoxWithConstraints(Modifier.fillMaxSize()) { val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp Scaffold( @@ -4573,19 +4611,37 @@ private fun RouteStageScreen( if (showPhotoActions) { item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) { - val newUri = createCameraUri(context) - cameraUri = newUri - val missingPermissions = cameraCapturePermissions( - context = context, - requirePreciseLocation = state.requirePreciseLocationForPhotos, - ) - if (missingPermissions.isEmpty()) { - cameraLauncher.launch(newUri) - } else { - cameraPermissionLauncher.launch(missingPermissions) - } - } + RouteStageCameraActions( + onSinglePhoto = { + val newUri = createCameraUri(context) + cameraUri = newUri + pendingCameraMode = RouteStageCameraMode.Single + val missingPermissions = cameraCapturePermissions( + context = context, + requirePreciseLocation = state.requirePreciseLocationForPhotos, + ) + if (missingPermissions.isEmpty()) { + pendingCameraMode = null + cameraLauncher.launch(newUri) + } else { + cameraPermissionLauncher.launch(missingPermissions) + } + }, + onMultiplePhotos = { + cameraUri = null + pendingCameraMode = RouteStageCameraMode.Multiple + val missingPermissions = cameraCapturePermissions( + context = context, + requirePreciseLocation = state.requirePreciseLocationForPhotos, + ) + if (missingPermissions.isEmpty()) { + pendingCameraMode = null + showMultiplePhotoCamera = true + } else { + cameraPermissionLauncher.launch(missingPermissions) + } + }, + ) if (state.allowGalleryUploads) { CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) { pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) @@ -5492,6 +5548,107 @@ private fun RouteStageDocumentationBlock( private fun formatRouteWeight(weight: Double): String = if (weight % 1.0 == 0.0) "${weight.toInt()} t" else String.format(Locale("pl", "PL"), "%.3f t", weight) +@Composable +private fun RouteStageCameraActions( + onSinglePhoto: () -> Unit, + onMultiplePhotos: () -> Unit, +) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth >= 300.dp) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RouteStageCameraActionButton( + label = "Jedno zdjęcie", + icon = Icons.Outlined.CameraAlt, + color = TppTheme.colors.forest, + compact = true, + modifier = Modifier.weight(1f), + onClick = onSinglePhoto, + ) + RouteStageCameraActionButton( + label = "Wiele zdjęć", + icon = Icons.Outlined.Collections, + color = TppTheme.colors.navy, + compact = true, + modifier = Modifier.weight(1f), + onClick = onMultiplePhotos, + ) + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + RouteStageCameraActionButton( + label = "Jedno zdjęcie", + icon = Icons.Outlined.CameraAlt, + color = TppTheme.colors.forest, + compact = false, + modifier = Modifier.fillMaxWidth(), + onClick = onSinglePhoto, + ) + RouteStageCameraActionButton( + label = "Wiele zdjęć", + icon = Icons.Outlined.Collections, + color = TppTheme.colors.navy, + compact = false, + modifier = Modifier.fillMaxWidth(), + onClick = onMultiplePhotos, + ) + } + } + } +} + +@Composable +private fun RouteStageCameraActionButton( + label: String, + icon: ImageVector, + color: Color, + compact: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier.height(if (compact) 76.dp else 64.dp), + colors = ButtonDefaults.buttonColors(containerColor = color), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.medium, + ) { + if (compact) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp), + ) + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } else { + Icon(icon, contentDescription = null, tint = Color.White) + Spacer(Modifier.width(10.dp)) + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } +} + @Composable private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) { Button( diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/MultiplePhotoCameraScreen.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/MultiplePhotoCameraScreen.kt new file mode 100644 index 0000000..6b82c2f --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/MultiplePhotoCameraScreen.kt @@ -0,0 +1,252 @@ +package pl.firmatpp.kierowca.ui + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.view.CameraController +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.CameraAlt +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.lifecycle.compose.LocalLifecycleOwner +import java.io.File +import pl.firmatpp.kierowca.ui.theme.TppTheme + +@Composable +internal fun MultiplePhotoCameraScreen( + stage: String, + onPhotoCaptured: (Uri) -> Unit, + onClose: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnPhotoCaptured by rememberUpdatedState(onPhotoCaptured) + val cameraController = remember(context) { + LifecycleCameraController(context).apply { + setEnabledUseCases(CameraController.IMAGE_CAPTURE) + imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY + } + } + var capturedCount by remember { mutableIntStateOf(0) } + var isCapturing by remember { mutableStateOf(false) } + var cameraUnavailable by remember { mutableStateOf(false) } + var cameraError by remember { mutableStateOf(null) } + + DisposableEffect(cameraController, lifecycleOwner) { + runCatching { + cameraController.bindToLifecycle(lifecycleOwner) + }.onFailure { + cameraUnavailable = true + cameraError = "Nie udało się uruchomić aparatu." + } + + onDispose { + cameraController.unbind() + } + } + + BackHandler { + if (!isCapturing) onClose() + } + + Box( + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + AndroidView( + factory = { previewContext -> + PreviewView(previewContext).apply { + controller = cameraController + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + scaleType = PreviewView.ScaleType.FILL_CENTER + } + }, + modifier = Modifier.fillMaxSize(), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .background(Color.Black.copy(alpha = 0.62f)) + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onClose, + enabled = !isCapturing, + ) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Wróć", + tint = Color.White, + ) + } + Column { + Text( + text = if (stage == "loading") "Zdjęcia załadunku" else "Zdjęcia rozładunku", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Wykonano: $capturedCount", + color = Color.White.copy(alpha = 0.78f), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .background(Color.Black.copy(alpha = 0.68f)) + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + cameraError?.let { message -> + Text( + text = message, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF8C1D18), RoundedCornerShape(6.dp)) + .padding(10.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Spacer(Modifier.size(112.dp, 52.dp)) + Button( + onClick = { + if (isCapturing || cameraUnavailable) return@Button + + val photoFile = createMultipleCameraFile(context) + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() + isCapturing = true + cameraController.takePicture( + outputOptions, + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + isCapturing = false + cameraError = null + capturedCount += 1 + currentOnPhotoCaptured(cameraFileUri(context, photoFile)) + } + + override fun onError(exception: ImageCaptureException) { + isCapturing = false + photoFile.delete() + cameraError = "Nie udało się zapisać zdjęcia. Spróbuj ponownie." + } + }, + ) + }, + enabled = !isCapturing && !cameraUnavailable, + modifier = Modifier.size(78.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + disabledContainerColor = Color.White.copy(alpha = 0.55f), + ), + contentPadding = ButtonDefaults.ContentPadding, + ) { + if (isCapturing) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = TppTheme.colors.forest, + strokeWidth = 3.dp, + ) + } else { + Icon( + Icons.Outlined.CameraAlt, + contentDescription = "Zrób zdjęcie", + tint = TppTheme.colors.forest, + modifier = Modifier.size(32.dp), + ) + } + } + Button( + onClick = onClose, + enabled = !isCapturing, + modifier = Modifier.size(width = 112.dp, height = 52.dp), + colors = ButtonDefaults.buttonColors( + containerColor = TppTheme.colors.forest, + ), + shape = RoundedCornerShape(6.dp), + ) { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + Text( + text = "Gotowe", + color = Color.White, + modifier = Modifier.padding(start = 6.dp), + ) + } + } + } + } +} + +private fun createMultipleCameraFile(context: Context): File { + val directory = File(context.cacheDir, "camera").apply { mkdirs() } + return File.createTempFile("ladunek-", ".jpg", directory) +} + +private fun cameraFileUri(context: Context, file: File): Uri = + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)