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
TaskRepositorymethods to Swift CRUD operations via swift-java. - Implement photo display in
TaskCardusing 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:
-
Normalize URI: Android returns photos in different formats depending on source (camera vs gallery vs file picker). The
whenexpression 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).
-
-
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
.absolutePathin the log message helps debug issues. -
Create temp file: Use
context.cacheDirfor temporary storage. The cache directory is designed for this—Android may clear it when storage is low. EmbeddingtaskIdin the filename keeps concurrent saves from interfering with each other. -
Copy to temp: Use Kotlin’s
copyTo()extension function to copy bytes. Theoverwrite = trueparameter 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. -
Get documents path:
context.filesDir.absolutePathgives 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. -
Call Swift via swift-java:
TaskManager.savePhotoFromPath()is a static method you’ll verify exists. It reads the temp file, delegates toPhotoStorage.savePhoto(), and returns the saved filename. The method is exposed via swift-java automatically. -
Convert Optional: swift-java returns Java’s
Optional<String>. The.orElse(null)converts to Kotlin’s nullableString?, which is more idiomatic. -
Cleanup in finally: The
finallyblock 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 infinallyis a best practice. -
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:
-
Preserve existing data: Keep
isCompletedstatus andphotoFilenameif not changing them - Handle optional photos: User might add, change, or keep existing photo
-
Construct Task object: Swift’s
updateTask()expects a complete Task object, not individual fields - 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:
-
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. -
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)
-
Construct Task object: Creates complete Task using swift-java’s generated initializer. Note
Optional.ofNullable(photoFilename)wraps the nullable filename for Java interop. Thearenaparameter is required for swift-java memory management. -
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. -
Check Boolean result: Swift returns
truefor success,falsefor validation failure. No exception handling needed—the Boolean is explicit. -
Reload on success:
loadTasks()fetches updated data from Swift and updates StateFlow. UI observingtaskswill recompose automatically. This ensures UI shows what’s actually persisted, preventing sync bugs. -
Return Result: Uses Kotlin’s
Resulttype to communicate success/failure to UI layer. This is different from Swift’sResulttype—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:
-
Serializes Task: Converts Kotlin
Taskto binary format - Crosses JNI boundary: Passes bytes through JNI
- Deserializes to Swift: Reconstructs Swift Task struct
-
Calls Swift method:
TaskManager.updateTask(_ updatedTask: Task) -
Returns result: Converts Swift
Boolto KotlinBoolean
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:
-
Validate title: Calls Swift’s
TaskValidator.validateTitle()through swift-java. Returns failure immediately if validation fails, preventing invalid data from reaching Swift. -
Validate description: Same pattern as title validation. Swift’s validation rules are the source of truth—Kotlin just exposes them to the UI.
-
Generate task ID: Creates unique identifier using Java’s
UUID.randomUUID(). UnlikeupdateTask()where ID is provided (editing existing task),addTask()creates new ID. -
Handle photo logic: Identical to
updateTask(): if user provided photo URI and context, callsavePhotoViaPath()helper to stage and save. Returns nullable filename. -
Construct Task object: Creates complete Task with:
- New UUID as ID
- User-provided title, description, priority
-
falsefor isCompleted (new tasks start uncompleted) -
Optional.ofNullable(photoFilename)for Java interop -
arenafor swift-java memory management
-
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. -
Reload on success: Same pattern as
updateTask(): callloadTasks()to refresh StateFlow with latest data from Swift. UI observingtaskswill 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
trueif task was found and deleted,falseif 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:
- Task might not have photo (photoFilename is Optional)
- Need to handle nil case
- Need documentsPath from Android context
- 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 asgetTask(by:)but inline instead of delegating. -
Extracts the filename: Gets
task.photoFilenamevia optional chaining. If task doesn’t exist or has no photo, guard fails and returnsnil. -
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:
-
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.currentgets Android context without passing it as a parameter. This is the Compose way—context is implicitly available.
-
-
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,
photoUriis null
- First checks if task has a photoFilename:
-
Task Information Display:
- Title with bold font weight and titleMedium style
- Description with bodyMedium style and onSurfaceVariant color for visual hierarchy
- Proper spacing with
Spacercomponents
-
Conditional Photo Display:
- Only renders AsyncImage if
photoUriis not null - Full-width image with 200dp height
-
ContentScale.Cropensures photo fills the space without distortion - Spacer adds breathing room before and after photo
- Only renders AsyncImage if
-
Priority Badge and Actions:
- Row with
SpaceBetweenarrangement pushes badge left, buttons right -
task.getPriority(arena)gets Swift Priority enum via swift-java -
.rawValueconverts Swift enum to String for display - PriorityBadge handles color coding
- Row with
-
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:
-
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.
-
Mode branching: The
if (isEditMode)check determines which Repository method to call:-
Edit mode: Calls
updateTask()witheditTask!!.idto 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.
-
Edit mode: Calls
-
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
editTaskparameter - 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:
-
State for edit mode:
taskToEditis nullable Task state. When null, no edit dialog shows. When set to a Task, the edit dialog appears. -
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.
-
-
Create dialog: Shown when FAB is tapped. Separate from edit dialog, controlled by
showCreateDialogstate. -
Edit dialog with
?.letpattern: This is idiomatic Kotlin. WhentaskToEditis not null, the lambda executes and shows CreateTaskDialog witheditTask = taskfor pre-population. When user dismisses, setstaskToEdit = 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:
- Launch the app
- Tap a task card to open edit mode (or long-press if that’s your UI pattern)
- Change the title (e.g., “Buy groceries” → “Buy groceries and snacks”)
- Change the priority (e.g., High → Medium)
- Keep the existing photo (don’t change it)
- Tap “Save” or “Update”
- Verify the task updates in the list
- Close the app completely (swipe from recents)
- 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:
-
UI to Repository: CreateTaskDialog passes updated data to
TaskRepository.updateTask() -
Repository to Swift: JNI call to
TaskManager.updateTask() - Swift validation: Title and description pass validation
-
Swift persistence:
TaskStorage.saveTasks()writes JSON atomically -
Repository reload:
loadTasks()fetches updated data -
StateFlow update:
_tasks.value =triggers emission - UI reactivity: Compose observes StateFlow and re-renders
-
Restart validation:
TaskManager.init()loads tasks on startup
The photo remaining unchanged proves you’re correctly preserving the photoFilename field through the update.
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:
- Swift SDK for Android: swift.org/android, official documentation and guides
- Jetpack Compose: developer.android.com/compose, Google’s modern UI toolkit tutorials
- Kotlin StateFlow: kotlinlang.org/docs/flow.html, reactive state management with coroutines
- JNI Development: developer.android.com/ndk, native code integration on Android
- Swift FileManager: developer.apple.com/documentation/foundation/filemanager, Apple’s file I/O documentation
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.valueonce 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 (likeisCompleted). 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
Boolinstead ofResulttypes because success/failure is sufficient. swift-java marshals Boolean cleanly, and Kotlin checks with simpleif (success)statements. -
Edit functionality reuses create dialog by adding optional
editTaskparameter. Same composable, two modes. Pre-population viaremember(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.