UI Integration & Testing

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In this segment, you’ll connect your camera and image processing components to the task creation UI. You’ll add buttons for capturing photos and applying filters, then test the complete flow from camera capture to filtered display.

The UI layer orchestrates the entire process, calling Android camera APIs and Swift image processing functions through the repositories you created.

Updating the Task Model for Photo Storage

Before adding photo capture to the UI, you need to update the Swift Task model to support storing photo URIs.

public struct Task: Codable {
  public let id: String
  public let title: String
  public let description: String
  public let priority: Priority
  public var isCompleted: Bool
  // 1
  public let photoUri: String?

  // 2
  public init(
    id: String,
    title: String,
    description: String,
    priority: Priority,
    isCompleted: Bool = false,
    photoUri: String? = nil
  ) {
    self.id = id
    self.title = title
    self.description = description
    self.priority = priority
    self.isCompleted = isCompleted
    // 3
    self.photoUri = photoUri
  }
}

Updating the Task Repository

Now that the Task model supports photo URIs, you need to update the repository to accept and pass this data when creating tasks.

import java.util.Optional
// 1
fun addTask(
  title: String,
  description: String,
  priority: Priority,
  photoUri: String? = null
): Result<Unit> {
  // 2
  if (!TaskValidator.validateTitle(title)) {
    return Result.failure(Exception("Title must be between 3 and 50 characters"))
  }

  if (!TaskValidator.validateDescription(description)) {
    return Result.failure(Exception("Description must be between 10 and 200 characters"))
  }

  // 3
  val task = Task.init(
    UUID.randomUUID().toString(),
    title,
    description,
    priority,
    false,
    Optional.ofNullable(photoUri),
    arena
  )

  // 4
  _tasks.value += task
  return Result.success(Unit)
}

Updating the Create Task Dialog

Now you’ll integrate photo capture, preview, and filtering into the task creation dialog. This requires adding state management, camera integration, filter buttons, and helper functions.

package com.kodeco.android.swiftsdkforandroid.taskmanager.ui

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.kodeco.android.swiftsdkforandroid.taskmanager.R
import com.kodeco.android.taskmanagerkit.Priority
import org.swift.swiftkit.core.SwiftArena
import com.kodeco.android.swiftsdkforandroid.taskmanager.repository.ImageProcessingRepository
import com.kodeco.android.swiftsdkforandroid.taskmanager.repository.TaskRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateTaskDialog(
  onDismiss: () -> Unit
) {
  val arena = remember { SwiftArena.ofConfined() }

  var title by remember { mutableStateOf("") }
  var description by remember { mutableStateOf("") }
  var priority by remember { mutableStateOf(Priority.medium(arena)) }
  var expanded by remember { mutableStateOf(false) }
  var errorMessage by remember { mutableStateOf<String?>(null) }
  // 1
  var photoUri by remember { mutableStateOf<Uri?>(null) }
  var showCamera by remember { mutableStateOf(false) }
  // 2
  var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
  var displayedBitmap by remember { mutableStateOf<Bitmap?>(null) }
  var isProcessing by remember { mutableStateOf(false) }

  val coroutineScope = rememberCoroutineScope()
  val context = LocalContext.current


  val priorities = remember(arena) {
    listOf(
      Priority.low(arena),
      Priority.medium(arena),
      Priority.high(arena)
    )
  }

  // 3
  if (showCamera) {
    CameraPermissionHandler(
      onPermissionGranted = {
        CameraScreen(
          onPhotoCaptured = { uri ->
            photoUri = uri
            // 4
            originalBitmap = loadBitmapFromUri(context, uri)
            displayedBitmap = originalBitmap
            showCamera = false
          },
          onDismiss = {
            showCamera = false
          }
        )
      },
      onPermissionDenied = {
        showCamera = false
      }
    )
    return
  }


  AlertDialog(
    onDismissRequest = onDismiss,
    title = {
      Text(text = stringResource(R.string.add_task))
    },
    text = {
      Column(
        modifier = Modifier
          .fillMaxWidth()
          .padding(vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
      ) {
        OutlinedTextField(
          value = title,
          onValueChange = { title = it },
          label = { Text(stringResource(R.string.task_title)) },
          modifier = Modifier.fillMaxWidth(),
          singleLine = true
        )

        OutlinedTextField(
          value = description,
          onValueChange = { description = it },
          label = { Text(stringResource(R.string.task_description)) },
          modifier = Modifier.fillMaxWidth(),
          minLines = 3,
          maxLines = 5
        )

        ExposedDropdownMenuBox(
          expanded = expanded,
          onExpandedChange = { expanded = !expanded }
        ) {
          OutlinedTextField(
            value = priority.rawValue,
            onValueChange = {},
            readOnly = true,
            label = { Text(stringResource(R.string.task_priority)) },
            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
            modifier = Modifier
              .menuAnchor(MenuAnchorType.PrimaryNotEditable)
              .fillMaxWidth()
          )

          ExposedDropdownMenu(
            expanded = expanded,
            onDismissRequest = { expanded = false }
          ) {
            priorities.forEach { option ->
              DropdownMenuItem(
                text = { Text(option.rawValue) },
                onClick = {
                  priority = option
                  expanded = false
                }
              )
            }
          }
        }

        // 5
        Button(
          onClick = { showCamera = true },
          modifier = Modifier.fillMaxWidth()
        ) {
          Icon(
            imageVector = Icons.Default.CameraAlt,
            contentDescription = null,
            modifier = Modifier.padding(end = 8.dp)
          )
          Text("Add Photo")
        }

        // 6
        displayedBitmap?.let { bitmap ->
          AsyncImage(
            model = bitmap,
            contentDescription = "Task photo preview",
            modifier = Modifier
              .fillMaxWidth()
              .height(150.dp),
            contentScale = ContentScale.Crop
          )

          if (isProcessing) {
            Text(
              text = "Processing...",
              style = MaterialTheme.typography.bodySmall,
              modifier = Modifier.padding(vertical = 4.dp)
            )
          }

          // 7
          Row(
            modifier = Modifier
              .fillMaxWidth()
              .horizontalScroll(rememberScrollState()),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
          ) {
            // 8
            Button(
              onClick = {
                displayedBitmap = originalBitmap
              }
            ) {
              Text("Original")
            }

            // 9
            Button(
              onClick = {
                originalBitmap?.let { original ->
                  isProcessing = true
                  coroutineScope.launch {
                    val result = withContext(Dispatchers.Default) {
                      ImageProcessingRepository.applyFilter(
                        original,
                        ImageProcessingRepository.FilterType.GRAYSCALE,
                        context
                      )
                    }
                    displayedBitmap = result
                    isProcessing = false
                  }
                }
              },
              enabled = !isProcessing
            ) {
              Text("Grayscale")
            }

            // 10
            Button(
              onClick = {
                originalBitmap?.let { original ->
                  isProcessing = true
                  coroutineScope.launch {
                    val result = withContext(Dispatchers.Default) {
                      ImageProcessingRepository.applyFilter(
                        original,
                        ImageProcessingRepository.FilterType.BLUR,
                        context
                      )
                    }
                    displayedBitmap = result
                    isProcessing = false
                  }
                }
              },
              enabled = !isProcessing
            ) {
              Text("Blur")
            }

            // 11
            Button(
              onClick = {
                originalBitmap?.let { original ->
                  isProcessing = true
                  coroutineScope.launch {
                    val result = withContext(Dispatchers.Default) {
                      ImageProcessingRepository.applyFilter(
                        original,
                        ImageProcessingRepository.FilterType.BRIGHTER,
                        context
                      )
                    }
                    displayedBitmap = result
                    isProcessing = false
                  }
                }
              },
              enabled = !isProcessing
            ) {
              Text("Brighter")
            }

            // 12
            Button(
              onClick = {
                originalBitmap?.let { original ->
                  isProcessing = true
                  coroutineScope.launch {
                    val result = withContext(Dispatchers.Default) {
                      ImageProcessingRepository.applyFilter(
                        original,
                        ImageProcessingRepository.FilterType.DARKER,
                        context
                      )
                    }
                    displayedBitmap = result
                    isProcessing = false
                  }
                }
              },
              enabled = !isProcessing
            ) {
              Text("Darker")
            }
          }
        }


        errorMessage?.let { error ->
          Text(
            text = error,
            color = MaterialTheme.colorScheme.error,
            style = MaterialTheme.typography.bodySmall
          )
        }
      }
    },
    confirmButton = {
      Button(
        onClick = {
          // 13
          val finalUri = if (displayedBitmap != null && displayedBitmap != originalBitmap) {
            saveBitmapAndGetUri(context, displayedBitmap!!)
          } else {
            photoUri
          }

          // 14
          val result = TaskRepository.addTask(
            title = title,
            description = description,
            priority = priority,
            photoUri = finalUri?.toString()
          )

          result.onSuccess {
            onDismiss()
          }.onFailure { error ->
            errorMessage = error.message ?: "Validation failed"
          }
        }
      ) {
        Text(stringResource(R.string.save))
      }
    },
    dismissButton = {
      TextButton(onClick = onDismiss) {
        Text(stringResource(R.string.cancel))
      }
    }
  )
}

// 15
private fun loadBitmapFromUri(context: Context, uri: Uri): Bitmap? {
  return try {
    // 15a
    val bitmap = context.contentResolver.openInputStream(uri)?.use { inputStream ->
      BitmapFactory.decodeStream(inputStream)
    } ?: return null

    // 15b
    correctBitmapOrientation(context, uri, bitmap)
  } catch (e: Exception) {
    e.printStackTrace()
    null
  }
}

// 16
private fun correctBitmapOrientation(context: Context, uri: Uri, bitmap: Bitmap): Bitmap {
  return try {
    context.contentResolver.openInputStream(uri)?.use { inputStream ->
      // 16a
      val exif = ExifInterface(inputStream)
      val orientation = exif.getAttributeInt(
        ExifInterface.TAG_ORIENTATION,
        ExifInterface.ORIENTATION_NORMAL
      )

      // 16b
      when (orientation) {
        ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f)
        ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f)
        ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f)
        else -> bitmap
      }
    } ?: bitmap
  } catch (e: Exception) {
    e.printStackTrace()
    bitmap
  }
}

// 17
private fun rotateBitmap(bitmap: Bitmap, degrees: Float): Bitmap {
  val matrix = android.graphics.Matrix()
  matrix.postRotate(degrees)
  return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}

// 18
private fun saveBitmapAndGetUri(context: Context, bitmap: Bitmap): Uri? {
  return try {
    val photoDir = context.getExternalFilesDir("photos") ?: return null
    val file = File(photoDir, "FILTERED_${System.currentTimeMillis()}.jpg")

    FileOutputStream(file).use { out ->
      bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
    }

    Uri.fromFile(file)
  } catch (e: Exception) {
    e.printStackTrace()
    null
  }
}

Testing the Complete Flow

You can now verify that everything works correctly.

Test Case 1: Camera Capture

  1. Open the app and tap the floating action button.
  2. In the Add Task dialog, tap the “Add Photo” button.
  3. Grant camera permission when prompted.
  4. See the camera preview screen.
  5. Tap the capture button.
Camera capture flow
Titemu nemdalu rpij

Test Case 2: Grayscale Filter

  1. With a photo captured, tap the “Grayscale” button.
  2. Wait for processing to complete.
Grayscale filter
Gwidchico yopmow

Test Case 3: Blur Filter

  1. Tap the “Original” button to reset the photo.
  2. Tap the “Blur” button.
  3. Wait for processing (this takes longer than other filters).
Blur filter
Dkes fusfaz

Test Case 4: Brightness Filters

  1. Tap “Brighter”.
Brighter filter
Qyakkxiq vagwir

Darker filter
Wavriv duwmuq

Test Case 5: Creating the Task

  1. Fill in title and description.
  2. Apply your preferred filter.
  3. Tap “Save”.

Updating the Task Card

To display photos in the task list, you need to update TaskCard.kt to show the task’s photo when one exists.

Text(
  text = task.description,
  style = MaterialTheme.typography.bodyMedium,
  color = MaterialTheme.colorScheme.onSurfaceVariant
)

// 1
if (task.photoUri.isPresent) {
  val uri = task.photoUri.get()
  Spacer(modifier = Modifier.height(12.dp))

  AsyncImage(
    model = uri,
    contentDescription = "Task photo",
    modifier = Modifier
      .fillMaxWidth()
      .height(200.dp),
    contentScale = ContentScale.Crop
  )
}

Spacer(modifier = Modifier.height(12.dp))

PriorityBadge(priority = task.getPriority(arena).rawValue)
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage

Common Issues and Solutions

Issue: Camera permission denied. Solution: Go to device Settings > Apps > Task Manager > Permissions and grant camera access manually.

Performance Considerations

Image processing is computationally expensive. Here’s what you’re doing to manage performance:

Key Takeaways

In this segment, you’ve:

Where to Go From Here?

Congratulations on completing the UI integration for your photo-enabled Task Manager! You’ve successfully built a complete feature that spans Android’s camera APIs, custom binary data formats, Swift image processing algorithms, and a polished Compose UI.

What’s Next

Your Task Manager currently stores tasks in memory, which means they disappear when the app closes. In Lesson 3: Data Persistence and Testing, you’ll add permanent storage and comprehensive testing to create a production-ready application.

See forum comments
Download course materials from Github
Previous: Implementing Image Filters in Swift Next: Conclusion