Photo Storage & Kotlin Integration

In the last segment, you built a complete Swift persistence layer: TaskManager with CRUD operations, PhotoStorage for managing photo files, and TaskStorage for JSON serialization. Your Swift code is production-ready and thoroughly tested.

But there’s a critical gap: your Kotlin UI can’t use these Swift methods yet. The Swift methods are compiled and exposed via swift-java, but you haven’t wired them up to your Kotlin Repository layer. The UI still displays mock data, and edits don’t persist.

In this segment, you’ll complete the integration:

  • Wire TaskRepository methods to Swift CRUD operations via swift-java.
  • Implement photo display in TaskCard using cross-platform path resolution.
  • Add getPhotoPath() bridge method to TaskManager.
  • Build edit functionality with pre-populated forms.
  • Test the complete workflow from UI tap to disk persistence.

By the end, your app will have a fully functional CRUD system where UI changes flow through Swift and persist to disk, complete with photo support and reactive updates via StateFlow.

Understanding the Integration Architecture

Before wiring up Kotlin to Swift, let’s understand how the layers fit together and why this architecture provides clean separation of concerns.

The Complete Stack

When a user taps “Edit” on a task card, data flows through five layers:

1. Compose UI (TaskCard)
   ↓
2. TaskRepository (Kotlin single source of truth)
   ↓
3. swift-java bindings (automatic type marshaling)
   ↓
4. TaskManager (Swift business logic)
   ↓
5. TaskStorage (Swift file I/O)

Each layer has a clear responsibility:

  • Compose UI: Displays data, captures user input, handles gestures
  • TaskRepository: Coordinates data access, manages StateFlow, caches data
  • swift-java: Converts Kotlin types to Swift types automatically (you don’t write this code)
  • TaskManager: Validates data, manages in-memory tasks array, orchestrates operations
  • TaskStorage: Handles file I/O, JSON encoding/decoding, atomic writes

This layering provides several benefits:

1. Clear separation of concerns

Each layer owns its domain. Compose doesn’t know about file I/O, Swift doesn’t know about Compose. This makes code easier to test, debug, and modify. You can replace TaskStorage with a database without changing TaskManager or Repository.

2. Platform independence

Swift code works on both iOS and Android. Platform differences (file paths, memory management) are abstracted away by swift-java and conditional compilation. Your business logic stays clean.

3. Single source of truth

TaskRepository is the only place Kotlin code accesses tasks. All reads and writes go through Repository, which delegates to Swift. This prevents sync bugs where different parts of the UI have inconsistent data.

4. Automatic type safety

swift-java generates type-safe bindings. If you change a Swift method signature, Kotlin code won’t compile until you update call sites. This catches integration bugs at build time, not runtime.

Building the Photo Save Helper

To begin, you need a helper method that bridges Android’s photo system with Swift’s photo storage. When users add or change task photos, the photo data comes from Android (camera, gallery, file picker) but needs to be saved via Swift’s PhotoStorage.

Photos arrive in various formats—content URIs, file:// schemes, absolute paths—but Swift expects a stable file path it can read. Your helper will normalize the photo, stage it in a temp location, call Swift to persist it, and clean up afterward.

Implementing savePhotoViaPath()

Open TaskRepository.kt and add this private helper method after the init block and before other methods:

private fun savePhotoViaPath(taskId: String, photoUri: String, context: Context): String? {
  try {
    // 1
    val sourceFile = when {
      photoUri.startsWith("file://") -> File(photoUri.removePrefix("file://"))
      photoUri.startsWith("/") -> File(photoUri)
      else -> {
        println("Invalid photo URI format: $photoUri")
        return null
      }
    }
    
    // 2
    if (!sourceFile.exists()) {
      println("Source photo file doesn't exist: ${sourceFile.absolutePath}")
      return null
    }
    
    // 3
    val tempFile = File(context.cacheDir, "temp_photo_$taskId.jpg")
    try {
      // 4
      sourceFile.copyTo(tempFile, overwrite = true)
      
      // 5
      val documentsPath = context.filesDir.absolutePath
      
      // 6
      val savedPath = TaskManager.savePhotoFromPath(taskId, tempFile.absolutePath, documentsPath)
      
      // 7
      return savedPath.orElse(null)
    } finally {
      // 8
      if (tempFile.exists()) {
        tempFile.delete()
      }
    }
  } catch (e: Exception) {
    // 9
    e.printStackTrace()
    println("Failed to save photo: ${e.message}")
    return null
  }
}

Remember to add imports for any missing references after pasting the code above.

What each section does:

  1. Normalize URI: Android returns photos in different formats depending on source (camera vs gallery vs file picker). The when expression handles three cases:

    • file:// scheme: Common from camera/gallery pickers. Strip prefix to get path.
    • Absolute path (/...): Already a path, use directly.
    • Invalid format: Log error and return null (fail-safe).
  2. Validate source: Check if the file actually exists before proceeding. Photos might be deleted between selection and save, or the URI might reference inaccessible content. Using .absolutePath in the log message helps debug issues.

  3. Create temp file: Use context.cacheDir for temporary storage. The cache directory is designed for this—Android may clear it when storage is low. Embedding taskId in the filename keeps concurrent saves from interfering with each other.

  4. Copy to temp: Use Kotlin’s copyTo() extension function to copy bytes. The overwrite = true parameter handles cases where a previous save left a temp file behind. Copying to a stable location ensures Swift can read it even if the source is on an external storage or content provider.

  5. Get documents path: context.filesDir.absolutePath gives the app’s private persistent storage directory (e.g., /data/data/com.kodeco.../files). This is different from cache (which is temporary). Pass to Swift so it knows where to save.

  6. Call Swift via swift-java: TaskManager.savePhotoFromPath() is a static method you’ll verify exists. It reads the temp file, delegates to PhotoStorage.savePhoto(), and returns the saved filename. The method is exposed via swift-java automatically.

  7. Convert Optional: swift-java returns Java’s Optional<String>. The .orElse(null) converts to Kotlin’s nullable String?, which is more idiomatic.

  8. Cleanup in finally: The finally block ensures temp file deletion happens even if Swift’s save throws an exception or returns null. This prevents cache directory from filling with abandoned temp files. Resource cleanup in finally is a best practice.

  9. Comprehensive error handling: File operations can throw FileNotFoundException, IOException, SecurityException, etc. Catch all exceptions, log stack trace for debugging, and return null to signal failure. The app stays stable even if photo save fails.

Verifying Swift’s savePhotoFromPath Method

The helper calls TaskManager.savePhotoFromPath(), which doesn’t exist yet. You’ll add that now.

Open taskmanager-lib/Sources/TaskManagerKit/TaskManager.swift and add this method:

public static func savePhotoFromPath(taskId: String, sourcePath: String, documentsPath: String) -> String? {
  let fileURL = URL(fileURLWithPath: sourcePath)
  
  guard let photoData = try? Data(contentsOf: fileURL) else {
    print("Failed to read photo from path: \(sourcePath)")
    return nil
  }
  
  let filename = "\(taskId).jpg"
  
  switch PhotoStorage.savePhoto(data: photoData, withFilename: filename, documentsPath: documentsPath) {
  case .success(let path):
    return filename
  case .failure(let error):
    print("Failed to save photo: \(error)")
    return nil
  }
}

This method helps read the temp file Kotlin created, generates a filename using the task ID, and delegates to PhotoStorage.

Save TaskRepository.kt. Your photo save helper is ready. Next, you’ll use it in updateTask() and addTask().

Implementing TaskRepository.updateTask()

With the architecture clear and PhotoStorage understood, you’re ready to wire up Kotlin’s Repository layer to Swift’s CRUD operations. Start with the update operation, which is the most complex due to photo handling.

Updating a task requires careful coordination because you need to:

  1. Preserve existing data: Keep isCompleted status and photoFilename if not changing them
  2. Handle optional photos: User might add, change, or keep existing photo
  3. Construct Task object: Swift’s updateTask() expects a complete Task object, not individual fields
  4. Convert types: Kotlin Priority → Swift Priority via swift-java

Let’s implement this step by step.

Open app/src/main/java/com/kodeco/android/swiftsdkforandroid/taskmanager/repository/TaskRepository.kt. You should see a TODO in the Repository object:

object TaskRepository {
  private val _tasks = MutableStateFlow<List<Task>>(emptyList())
  val tasks: StateFlow<List<Task>> = _tasks
  
  private val arena = SwiftArena.ofAuto()
  private val manager = TaskManager.getShared(arena)
  
  init {
    loadTasks()
  }
  
  // ... addTask() exists here ...
  
  // TODO: Implement updateTask() to support editing tasks
}

The TODO marks where you’ll add update functionality.

Implementing updateTask()

Add this method after addTask():

fun updateTask(
  id: String,
  title: String,
  description: String,
  priority: Priority,
  photoUri: String? = null,
  context: Context? = null
): Result<Unit> {
  // 1
  val existingTask = manager.getTask(id, arena).orElse(null) 
    ?: return Result.failure(Exception("Task not found"))
  
  // 2
  val photoFilename = if (photoUri != null && photoUri.isNotEmpty() && context != null) {
    savePhotoViaPath(id, photoUri, context)
  } else {
    existingTask.getPhotoFilename().orElse(null)
  }
  
  // 3
  val task = Task.init(
    id,
    title,
    description,
    priority,
    existingTask.isCompleted(),
    Optional.ofNullable(photoFilename),
    arena
  )
  
  // 4
  val success = manager.updateTask(task)
  
  if (success) {
    // 5  
    loadTasks() 
    
    // 6
    return Result.success(Unit)
  } else {
    return Result.failure(Exception("Update failed"))
  }
}

What each section does:

  1. Fetch existing task: Calls manager.getTask(id, arena) to retrieve the current task from Swift. You implemented this method in Segment 03. The .orElse(null) converts Java’s Optional to Kotlin’s nullable type. If task doesn’t exist, returns failure immediately.

  2. Handle photo logic: Checks if user provided a new photo URI. If yes, calls savePhotoViaPath() helper (exists from Lesson 2) to save it and get the filename. If no new photo, preserves existing photoFilename. This allows:

    • Adding photo to task that didn’t have one
    • Changing photo (old photo deleted automatically by Swift)
    • Keeping existing photo (just pass null)
  3. Construct Task object: Creates complete Task using swift-java’s generated initializer. Note Optional.ofNullable(photoFilename) wraps the nullable filename for Java interop. The arena parameter is required for swift-java memory management.

  4. Call Swift via swift-java: manager.updateTask(task) marshals the Kotlin Task object across the boundary to Swift. swift-java handles serialization automatically. Swift validates, updates in-memory array, and persists to disk.

  5. Check Boolean result: Swift returns true for success, false for validation failure. No exception handling needed—the Boolean is explicit.

  6. Reload on success: loadTasks() fetches updated data from Swift and updates StateFlow. UI observing tasks will recompose automatically. This ensures UI shows what’s actually persisted, preventing sync bugs.

  7. Return Result: Uses Kotlin’s Result type to communicate success/failure to UI layer. This is different from Swift’s Result type—it’s Kotlin’s built-in sealed class for representing computation results.

Understanding swift-java Object Marshaling

When you call manager.updateTask(task), swift-java:

  1. Serializes Task: Converts Kotlin Task to binary format
  2. Crosses JNI boundary: Passes bytes through JNI
  3. Deserializes to Swift: Reconstructs Swift Task struct
  4. Calls Swift method: TaskManager.updateTask(_ updatedTask: Task)
  5. Returns result: Converts Swift Bool to Kotlin Boolean

This is all automatic with no manual code needed! The type safety is preserved: if Task’s structure changes, the build fails until you update both sides.

Improving TaskRepository.addTask()

Now that you’ve implemented the updateTask() operation, you’ll improve addTask() for creating new tasks. It follows the same photo-handling pattern you just learned but is simpler because you’re creating the task from scratch, there’s no existing fields to preserve.

The key difference is that addTask() generates a new UUID for the task ID, while updateTask() works with an existing ID.

Updating the Method

Replace the TaskRepository.addTask() implementation with the following:

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

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

  // 3
  val taskId = UUID.randomUUID().toString()

  // 4
  val photoFilename = if (photoUri != null && photoUri.isNotEmpty() && context != null) {
    savePhotoViaPath(taskId, photoUri, context)
  } else {
    null
  }
  
  // 5
  val task = Task.init(
    taskId,
    title,
    description,
    priority,
    false,  // New tasks start uncompleted
    Optional.ofNullable(photoFilename),
    arena
  )
  
  // 6
  val success = manager.addTask(task)

  // 7
  if (success) {
    loadTasks()
    return Result.success(Unit)
  } else {
    return Result.failure(Exception("Validation failed"))
  }
}

What each section does:

  1. Validate title: Calls Swift’s TaskValidator.validateTitle() through swift-java. Returns failure immediately if validation fails, preventing invalid data from reaching Swift.

  2. Validate description: Same pattern as title validation. Swift’s validation rules are the source of truth—Kotlin just exposes them to the UI.

  3. Generate task ID: Creates unique identifier using Java’s UUID.randomUUID(). Unlike updateTask() where ID is provided (editing existing task), addTask() creates new ID.

  4. Handle photo logic: Identical to updateTask(): if user provided photo URI and context, call savePhotoViaPath() helper to stage and save. Returns nullable filename.

  5. Construct Task object: Creates complete Task with:

    • New UUID as ID
    • User-provided title, description, priority
    • false for isCompleted (new tasks start uncompleted)
    • Optional.ofNullable(photoFilename) for Java interop
    • arena for swift-java memory management
  6. Call Swift via swift-java: manager.addTask(task) marshals the Kotlin Task object to Swift. Swift validates again (belt-and-suspenders), adds to in-memory array, and persists to disk via TaskStorage.

  7. Reload on success: Same pattern as updateTask(): call loadTasks() to refresh StateFlow with latest data from Swift. UI observing tasks will recompose showing the new task.

Implementing TaskRepository.deleteTask()

Deleting a task is fairly straight-forward because you don’t need to construct a Task object, you just pass the ID. However, you still follow the same pattern: call Swift, check result, reload on success.

Adding the Method

Add this method to TaskRepository.kt after updateTask():

fun deleteTask(taskId: String): Result<Unit> {
  val success = manager.deleteTask(taskId)
  
  if (success) {
    loadTasks()
    return Result.success(Unit)
  } else {
    return Result.failure(Exception("Failed to delete task"))
  }
}

In short, this method will:

  • Call Swift via swift-java: manager.deleteTask(taskId) calls Swift’s delete method. Swift finds the task, deletes its photo (if it has one), removes from array, and persists to disk. All in one call.

  • Check Boolean result: Returns true if task was found and deleted, false if task doesn’t exist (idempotent operation—safe to call multiple times).

  • Reload on success: Calls loadTasks() to fetch updated list from Swift. StateFlow emits, UI recomposes, task disappears from list.

  • Return Result: Uses Kotlin’s Result type to communicate outcome to UI. UI can show “Task deleted” snackbar or handle failure gracefully.

Understanding Cascading Delete

The beauty of this implementation i that you don’t manage photo cleanup in Kotlin. It happens automatically in Swift:

// In TaskManager.deleteTask()
if let photoFilename = task.photoFilename {
  _ = PhotoStorage.deletePhoto(filename: photoFilename)
}
tasks.remove(at: index)
_ = storage.saveTasks(tasks)

From Kotlin’s perspective:

taskRepository.deleteTask("abc-123")
// Task deleted AND photo deleted (if task had one)
// No additional code needed

Kotlin doesn’t need FileManager, doesn’t check photo paths, doesn’t handle cleanup errors. Swift owns file management completely.

Adding getPhotoPath() to TaskManager

Next, TaskCard needs to display task photos, but it only knows task IDs, not photo filenames or paths. You need a bridge method in TaskManager that resolves the following: task ID → photo filename → full file path.

You could do all this in Kotlin, but that couples UI to storage implementation details. Instead you’ll add one method to TaskManager that does it all.

You might be thinking “TaskCard knows the task, just call PhotoStorage.photoPath(task.photoFilename).” But there are problems with this:

  1. Task might not have photo (photoFilename is Optional)
  2. Need to handle nil case
  3. Need documentsPath from Android context
  4. Couples UI to PhotoStorage internals

It’s better to encapsulate this logic within TaskManager. This keeps the UI simple and puts the business logic in one place.

Implementing getPhotoPath()

Open taskmanager-lib/Sources/TaskManagerKit/TaskManager.swift. Add this method after getTask(by:):

public func getPhotoPath(for taskId: String, documentsPath: String) -> String? {
  guard let task = tasks.first(where: { $0.id == taskId }),
        let filename = task.photoFilename else {
    return nil
  }
  
  return PhotoStorage.photoPath(for: filename, documentsPath: documentsPath)
}

This code does the following:

  • Looks up the task: Uses tasks.first(where:) to find the task by ID. Same pattern as getTask(by:) but inline instead of delegating.

  • Extracts the filename: Gets task.photoFilename via optional chaining. If task doesn’t exist or has no photo, guard fails and returns nil.

  • Delegates to PhotoStorage: Calls PhotoStorage.photoPath(for:documentsPath:) which you implemented in Segment 03. This constructs the full path like /data/.../files/photos/abc-123.jpg.

  • Returns Optional: Returns String? because the task might not exist or might not have a photo. Caller handles nil gracefully (show placeholder instead of photo).

Why documentsPath Parameter?

PhotoStorage needs documentsPath because Swift’s FileManager.default.urls(for: .documentDirectory) doesn’t work correctly on Android. You must pass the path from Kotlin. In TaskCard, you’ll get it from LocalContext:

// In TaskCard composable
val context = LocalContext.current
TaskManager.getShared(arena).getPhotoPath(task.id, context.filesDir.absolutePath)

This is a limitation of current Swift SDK for Android, not a design choice. Future SDK versions may support FileManager fully. The key insight: Kotlin code easily accesses Android’s filesDir.absolutePath, while Swift code on Android cannot.

Displaying Photos in TaskCard

With getPhotoPath() available in TaskManager, you can now display task photos in your Compose UI. The TaskCard composable will fetch photo paths and load them using AsyncImage.

Understanding TaskCard’s Current State

Open app/src/main/java/com/kodeco/android/swiftsdkforandroid/taskmanager/ui/TaskCard.kt. The Starter project has a basic TaskCard that displays task information but has a TODO for photos:

@Composable
fun TaskCard(
  task: Task,
  onEdit: (Task) -> Unit = {},
  onDelete: (Task) -> Unit = {}
) {
  Card(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
    Column(modifier = Modifier.padding(16.dp)) {
      Text(task.title, style = MaterialTheme.typography.titleMedium)
      Text(task.description, style = MaterialTheme.typography.bodySmall)
      
      // TODO: Display photo if task has one
      
      // Action buttons...
    }
  }
}

You need to add photo display logic, a priority badge, and complete the action buttons.

Implementing TaskCard with Photo Display

Replace the TaskCard implementation with this complete version:

@Composable
fun TaskCard(
  task: Task,
  onEdit: (Task) -> Unit = {},
  onDelete: (Task) -> Unit = {}
) {
  // 1
  val arena = remember { SwiftArena.ofConfined() }
  val context = LocalContext.current
  
  // 2
  val photoUri = task.getPhotoFilename().orElse(null)?.let { filename ->
    TaskManager.getShared(arena).getPhotoPath(task.id, context.filesDir.absolutePath)?.get().let { path ->
      "file://$path"
    }
  }

  Card(
    modifier = Modifier
      .fillMaxWidth()
      .padding(horizontal = 16.dp, vertical = 8.dp),
    elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
  ) {
    Column(
      modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
    ) {
      // 3
      Text(
        text = task.title,
        style = MaterialTheme.typography.titleMedium,
        fontWeight = FontWeight.Bold
      )
      
      Spacer(modifier = Modifier.height(8.dp))
      
      Text(
        text = task.description,
        style = MaterialTheme.typography.bodyMedium,
        color = MaterialTheme.colorScheme.onSurfaceVariant
      )
      
      // 4
      if (photoUri != null) {
        Spacer(modifier = Modifier.height(12.dp))
        
        AsyncImage(
          model = photoUri,
          contentDescription = "Task photo",
          modifier = Modifier
            .fillMaxWidth()
            .height(200.dp),
          contentScale = ContentScale.Crop
        )
      }
      
      Spacer(modifier = Modifier.height(12.dp))
      
      // 5
      Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
      ) {
        PriorityBadge(priority = task.getPriority(arena).rawValue)
        
        // 6
        Row {
          IconButton(onClick = { onEdit(task) }) {
            Icon(
              imageVector = Icons.Default.Edit,
              contentDescription = "Edit task"
            )
          }
          
          IconButton(onClick = { onDelete(task) }) {
            Icon(
              imageVector = Icons.Default.Delete,
              contentDescription = "Delete task",
              tint = MaterialTheme.colorScheme.error
            )
          }
        }
      }
    }
  }
}

Make sure to add all required imports:

import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.kodeco.android.taskmanagerkit.Task
import com.kodeco.android.taskmanagerkit.TaskManager
import org.swift.swiftkit.core.SwiftArena

Understanding the Implementation

Let’s break down what each numbered section does:

  1. Arena and Context Setup:

    • SwiftArena.ofConfined() creates a memory-confined arena for swift-java calls. This is more efficient than .ofAuto() when the lifetime is scoped to the composable.
    • LocalContext.current gets Android context without passing it as a parameter. This is the Compose way—context is implicitly available.
  2. Inline Photo Path Resolution:

    • First checks if task has a photoFilename: task.getPhotoFilename().orElse(null)
    • If yes, calls getPhotoPath() with task ID and context.filesDir.absolutePath
    • The ?.get() unwraps the Optional from Swift
    • Constructs file URI: "file://$path" (Coil’s AsyncImage accepts file:// URIs directly)
    • If no filename, photoUri is null
  3. Task Information Display:

    • Title with bold font weight and titleMedium style
    • Description with bodyMedium style and onSurfaceVariant color for visual hierarchy
    • Proper spacing with Spacer components
  4. Conditional Photo Display:

    • Only renders AsyncImage if photoUri is not null
    • Full-width image with 200dp height
    • ContentScale.Crop ensures photo fills the space without distortion
    • Spacer adds breathing room before and after photo
  5. Priority Badge and Actions:

    • Row with SpaceBetween arrangement pushes badge left, buttons right
    • task.getPriority(arena) gets Swift Priority enum via swift-java
    • .rawValue converts Swift enum to String for display
    • PriorityBadge handles color coding
  6. Action Buttons:

    • Edit and Delete buttons in a Row
    • Callbacks receive the Task object: onEdit(task), onDelete(task)
    • Delete icon uses error color for visual warning
    • IconButton provides touch targets and ripple effects

Build and run the app. You should see edit and delete icons in each task card.

Adding Edit Functionality

With photos displaying, the last piece is edit functionality. Users should tap a task card’s edit button, see a dialog pre-populated with current values, make changes, and save.

Right now, CreateTaskDialog in the Starter project only creates new tasks. To support editing, you need to add an optional parameter and branching logic.

Updating CreateTaskDialog for Edit Mode

The Starter project’s CreateTaskDialog has this signature:

@Composable
fun CreateTaskDialog(
  onDismiss: () -> Unit
) {
  // ... only creates new tasks
}

You’ll enhance it to support both create and edit modes using a single composable with conditional logic.

Adding editTask Parameter

Open app/src/main/java/.../ui/CreateTaskDialog.kt and modify the function signature:

@Composable
fun CreateTaskDialog(
  onDismiss: () -> Unit,
  editTask: Task? = null  // NEW: null = create mode, Task = edit mode
) {

The default = null value means existing code calling CreateTaskDialog(onDismiss = { ... }) continues to work unchanged, getting create mode. When you pass a Task, it switches to edit mode.

Adding Mode Detection

Add this right after the function signature, before any state declarations:

val arena = remember { SwiftArena.ofConfined() }
val isEditMode = editTask != null

The isEditMode boolean simplifies conditional logic throughout the composable. The arena is needed to extract Priority from the Swift Task object.

Pre-Populating State from editTask

Find your existing state declarations and update them to use editTask values:

var title by remember { mutableStateOf(editTask?.title ?: "") }
var description by remember { mutableStateOf(editTask?.description ?: "") }
var priority by remember {
  mutableStateOf(
    editTask?.let { it.getPriority(arena) } ?: Priority.medium(arena)
  )
}

What each line does:

  • Title/Description: Elvis operator editTask?.title ?: "" loads existing value in edit mode, empty string in create mode
  • Priority: Uses editTask?.let { it.getPriority(arena) } to extract Swift Priority enum via swift-java. Falls back to medium priority for create mode. The arena is required for Swift-to-Kotlin type marshaling.

Keep your existing photo-related state (photoUri, originalBitmap, displayedBitmap, etc.) unchanged.

Adding Photo Loading Effect

After your state declarations, add this LaunchedEffect:

// Load existing photo when editing
LaunchedEffect(editTask) {
  // Note: photoUri not in Swift Task model - managed separately
}

This is a placeholder in the Final project. Photo management during edit happens through the photo capture flow, not pre-loading. The LaunchedEffect demonstrates where you’d add photo loading logic if needed.

Updating Dialog Title

Find the AlertDialog title parameter and update it:

AlertDialog(
  onDismissRequest = onDismiss,
  title = {
    Text(text = if (isEditMode) "Edit Task" else stringResource(R.string.add_task))
  },
  // ... rest of dialog
)

This gives users clear visual feedback about which mode they’re in.

Implementing Branching Save Logic

This is the critical piece. Find your save Button’s onClick handler and replace it with:

Button(
  onClick = {
    // 1
    val finalUri = if (displayedBitmap != null && displayedBitmap != originalBitmap) {
      saveBitmapAndGetUri(context, displayedBitmap!!)
    } else {
      photoUri
    }

    // 2
    val result = if (isEditMode) {
      TaskRepository.updateTask(
        id = editTask!!.id,
        title = title,
        description = description,
        priority = priority,
        photoUri = finalUri?.toString(),
        context = context
      )
    } else {
      TaskRepository.addTask(
        title = title,
        description = description,
        priority = priority,
        photoUri = finalUri?.toString(),
        context = context
      )
    }
    
    // 3
    result.onSuccess {
      onDismiss()
    }.onFailure { error ->
      errorMessage = error.message ?: "Validation failed"
    }
  }
) {
  Text(stringResource(R.string.save))
}

What each section does:

  1. Photo handling: If user applied filters (displayed bitmap differs from original), saves the filtered version and gets its URI. Otherwise uses the captured photoUri directly.

  2. Mode branching: The if (isEditMode) check determines which Repository method to call:

    • Edit mode: Calls updateTask() with editTask!!.id to preserve the task’s identity
    • Create mode: Calls addTask() which generates a new UUID internally
    • Shared parameters: Both branches pass the same user inputs (title, description, priority, photoUri, context). The only difference is edit mode includes the existing task ID.
  3. Result handling: Both methods return Result<Unit>. On success, dismisses dialog. On failure, displays error message in the dialog.

CreateTaskDialog now supports both create and edit modes with a single, unified implementation. The dialog:

  • Detects mode automatically based on editTask parameter
  • Pre-populates fields when editing
  • Branches save logic appropriately
  • Handles photo updates
  • Shows correct title

Next, you’ll wire this enhanced dialog to your TaskListScreen.

Wiring Edit and Delete in TaskListScreen

TaskCard’s action buttons are already implemented. Now wire them in your TaskListScreen to handle edit and delete operations. Open ui/TaskListScreen.kt and update the LazyColumn section:

@Composable
fun TaskListScreen() {
  val tasks by TaskRepository.tasks.collectAsState()
  var showCreateDialog by remember { mutableStateOf(false) }
  // 1
  var taskToEdit by remember { mutableStateOf<Task?>(null) }
  
  Scaffold(
    topBar = { /* Use existing TopAppBar with app name */ },
    floatingActionButton = { /* Use existing FAB for creating tasks */ }
  ) { paddingValues ->
    Box(
      modifier = Modifier
        .fillMaxSize()
        .padding(paddingValues)
    ) {
      if (tasks.isEmpty()) {
        Text("No tasks yet")
      } else {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
          items(tasks) { task ->
            // 2
            TaskCard(
              task = task,
              onEdit = { taskToEdit = it },
              onDelete = { TaskRepository.deleteTask(it.id) }
            )
          }
        }
      }
    }
    
    // 3
    if (showCreateDialog) {
      CreateTaskDialog(
        onDismiss = { showCreateDialog = false }
      )
    }
    
    // 4
    taskToEdit?.let { task ->
      CreateTaskDialog(
        onDismiss = { taskToEdit = null },
        editTask = task
      )
    }
  }
}

What each section does:

  1. State for edit mode: taskToEdit is nullable Task state. When null, no edit dialog shows. When set to a Task, the edit dialog appears.

  2. TaskCard callbacks:

    • onEdit = { taskToEdit = it }: Stores the Task to edit, which triggers the edit dialog (see section 4)
    • onDelete = { TaskRepository.deleteTask(it.id) }: Deletes immediately. No confirmation dialog in this simple version.
  3. Create dialog: Shown when FAB is tapped. Separate from edit dialog, controlled by showCreateDialog state.

  4. Edit dialog with ?.let pattern: This is idiomatic Kotlin. When taskToEdit is not null, the lambda executes and shows CreateTaskDialog with editTask = task for pre-population. When user dismisses, sets taskToEdit = null, which hides the dialog.

You’ve implemented the complete persistence system with photo support. Now let’s validate it works correctly with three test cases covering different aspects.

Ensure both builds succeed:

cd taskmanager-lib
swift build

cd ..
./gradlew :app:assembleDebug

Testing the Edit Functionality

It’s time for you to test and validate the full update workflow from UI to persistence and back.

Setup:

  • App with at least one existing task that has a photo

Action:

  1. Launch the app
  2. Tap a task card to open edit mode (or long-press if that’s your UI pattern)
  3. Change the title (e.g., “Buy groceries” → “Buy groceries and snacks”)
  4. Change the priority (e.g., High → Medium)
  5. Keep the existing photo (don’t change it)
  6. Tap “Save” or “Update”
  7. Verify the task updates in the list
  8. Close the app completely (swipe from recents)
  9. Reopen the app

Expected result:

  • Task displays updated title “Buy groceries and snacks”
  • Priority badge shows Medium (yellow) instead of High (red)
  • Photo remains the same (displays correctly)
  • All other tasks are unchanged
  • After restart, all changes persist

This test proves the complete update cycle works:

  1. UI to Repository: CreateTaskDialog passes updated data to TaskRepository.updateTask()
  2. Repository to Swift: JNI call to TaskManager.updateTask()
  3. Swift validation: Title and description pass validation
  4. Swift persistence: TaskStorage.saveTasks() writes JSON atomically
  5. Repository reload: loadTasks() fetches updated data
  6. StateFlow update: _tasks.value = triggers emission
  7. UI reactivity: Compose observes StateFlow and re-renders
  8. Restart validation: TaskManager.init() loads tasks on startup

The photo remaining unchanged proves you’re correctly preserving the photoFilename field through the update.

Full edit functionality
Full edit functionality

Now you have a fully-functional app utilize Swift code as the business layer and using swift-java to bridge the code to Kotlin!

Learning Resources

To deepen your understanding of the technologies used in this lesson:

Key Takeaways

You’ve completed the integration between Kotlin UI and Swift persistence layer, creating a fully functional CRUD system with photo support. Here’s what you learned:

  • Repository pattern provides single source of truth by centralizing all data access in one class. All UI code calls Repository, which delegates to Swift via swift-java. This prevents duplicate Swift setup code and inconsistent state across UI.
  • StateFlow enables reactive UI with zero manual synchronization code. Change _tasks.value once after mutations, and all observing composables recompose automatically. No refresh buttons, no sync logic, no stale UI.
  • Reload-on-mutate simplifies consistency. After calling Swift to update/delete, Repository reloads all tasks from Swift. This guarantees UI shows what’s persisted, even if Swift’s validation modified data. Simple and correct beats optimized but buggy.
  • swift-java handles type marshaling automatically. When you call manager.updateTask(task), swift-java serializes the Kotlin Task, crosses the JNI boundary, and deserializes to Swift Task. No manual JNI code, no type conversion bugs. Type safety preserved across languages.
  • Fetching existing tasks preserves data. When updating, Repository calls getTask(by:) to fetch current task, preserving fields that aren’t being edited (like isCompleted). This prevents data loss and decouples UI from full data model.
  • getPhotoPath() bridges layers. This method combines task lookup, filename extraction, and path construction in one call. UI doesn’t know about PhotoStorage internals—just passes task ID and gets back path or nil.
  • Boolean returns simplify success checking. Swift’s CRUD methods return Bool instead of Result types because success/failure is sufficient. swift-java marshals Boolean cleanly, and Kotlin checks with simple if (success) statements.
  • Edit functionality reuses create dialog by adding optional editTask parameter. Same composable, two modes. Pre-population via remember(editTask) ensures fields reset when task changes. Clean code reuse without duplication.
  • Cascading deletes stay in Swift layer. When Kotlin calls deleteTask(id), Swift automatically handles photo cleanup. Kotlin doesn’t know about photos, file paths, or PhotoStorage. Proper layering keeps concerns separated.

Congratulations on completing your task manager! You’ve mastered file-based persistence, cross-platform Swift development, and modern Android UI with Compose. These skills transfer directly to professional app development.

See forum comments
Download course materials from Github
Previous: CRUD Operations & Validation Next: Conclusion