The task manager works. You can add tasks, complete them, delete them, search, and filter. But it doesn’t feel polished. Screens change instantly. Deleting a task is jarring. There’s no feedback when you complete something.

This tutorial adds the polish that makes the difference between a homework project and a real app.

What We Add

FeatureWhat It Does
Navigation transitionsScreens slide in/out smoothly
Swipe to deleteSwipe a task left to delete it
Animated task completionCheckbox animates, strikethrough fades in
Animated list changesTasks slide in/out when added or removed
Empty state animationsGentle fade-in when list is empty
Dark modeFollows system theme
Snackbar with undo“Task deleted” with undo option

By default, screens appear instantly. Add slide transitions:

// ui/navigation/AppNavigation.kt

NavHost(
    navController = navController,
    startDestination = TaskListRoute,
    enterTransition = {
        slideInHorizontally(initialOffsetX = { it }) + fadeIn()
    },
    exitTransition = {
        slideOutHorizontally(targetOffsetX = { -it }) + fadeOut()
    },
    popEnterTransition = {
        slideInHorizontally(initialOffsetX = { -it }) + fadeIn()
    },
    popExitTransition = {
        slideOutHorizontally(targetOffsetX = { it }) + fadeOut()
    }
) {
    composable<TaskListRoute> { TaskListScreen(...) }
    composable<AddTaskRoute> { AddTaskScreen(...) }
    composable<SettingsRoute> { SettingsScreen(...) }
}

Now when you navigate forward, the new screen slides in from the right. When you go back, it slides out to the right. Natural and smooth.

Swipe to Delete

Material 3’s SwipeToDismissBox handles the swipe gesture:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeableTaskCard(
    task: Task,
    onToggle: () -> Unit,
    onDelete: () -> Unit
) {
    val dismissState = rememberSwipeToDismissBoxState(
        confirmValueChange = { dismissValue ->
            if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
                onDelete()
                true
            } else false
        }
    )

    SwipeToDismissBox(
        state = dismissState,
        backgroundContent = {
            // Red background with delete icon — shows when swiping
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(MaterialTheme.colorScheme.error)
                    .padding(horizontal = 24.dp),
                contentAlignment = Alignment.CenterEnd
            ) {
                Icon(
                    Icons.Default.Delete,
                    contentDescription = "Delete",
                    tint = MaterialTheme.colorScheme.onError
                )
            }
        },
        enableDismissFromStartToEnd = false,  // Only swipe left
        enableDismissFromEndToStart = true
    ) {
        TaskCard(task = task, onToggle = onToggle, onDelete = onDelete)
    }
}

Swipe left → red background appears → release → task deleted.

Animated List Changes

Make tasks animate when they are added, removed, or reordered:

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(8.dp),
    contentPadding = PaddingValues(16.dp)
) {
    items(
        items = state.tasks,
        key = { it.id }  // Keys required for animations
    ) { task ->
        SwipeableTaskCard(
            task = task,
            onToggle = { viewModel.onIntent(TaskListIntent.ToggleTask(task.id)) },
            onDelete = { viewModel.onIntent(TaskListIntent.DeleteTask(task.id)) },
            modifier = Modifier.animateItem()  // Smooth add/remove/reorder
        )
    }
}

Modifier.animateItem() makes tasks:

  • Slide in when added
  • Slide out when deleted
  • Move smoothly when the list order changes

Combined with swipe-to-delete, this feels great.

Animated Task Completion

When a task is completed, animate the changes:

@Composable
fun AnimatedTaskTitle(title: String, isCompleted: Boolean) {
    val textColor by animateColorAsState(
        targetValue = if (isCompleted)
            MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
        else MaterialTheme.colorScheme.onSurface,
        animationSpec = tween(300),
        label = "textColor"
    )

    Text(
        text = title,
        fontWeight = FontWeight.Medium,
        color = textColor,
        textDecoration = if (isCompleted)
            TextDecoration.LineThrough else TextDecoration.None
    )
}

When isCompleted changes, the text color fades smoothly and the strikethrough appears.

Snackbar with Undo

Show a snackbar when a task is deleted, with an “Undo” option:

@Composable
fun TaskListScreen(
    viewModel: TaskListViewModel = hiltViewModel()
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) }
    ) { padding ->
        TaskList(
            tasks = state.tasks,
            onDelete = { task ->
                viewModel.onIntent(TaskListIntent.DeleteTask(task.id))

                // Show snackbar with undo
                scope.launch {
                    val result = snackbarHostState.showSnackbar(
                        message = "Task deleted",
                        actionLabel = "Undo",
                        duration = SnackbarDuration.Short
                    )
                    if (result == SnackbarResult.ActionPerformed) {
                        viewModel.onIntent(TaskListIntent.UndoDelete(task))
                    }
                }
            },
            modifier = Modifier.padding(padding)
        )
    }
}

For undo to work, the ViewModel needs to save the deleted task temporarily:

// In ViewModel
private var lastDeletedTask: Task? = null

is TaskListIntent.DeleteTask -> {
    viewModelScope.launch {
        lastDeletedTask = repository.getTaskById(intent.taskId)
        deleteTask(intent.taskId)
    }
}

is TaskListIntent.UndoDelete -> {
    viewModelScope.launch {
        intent.task?.let { repository.addTask(it) }
    }
}

Dark Mode Support

If you set up theming correctly in Tutorial #7, dark mode already works. The key is using theme colors everywhere:

// GOOD — adapts to dark mode automatically
Surface(color = MaterialTheme.colorScheme.surfaceVariant) { ... }
Text(color = MaterialTheme.colorScheme.onSurface)

// BAD — hardcoded, breaks in dark mode
Surface(color = Color.White) { ... }
Text(color = Color.Black)

To let users toggle dark mode from settings, save the preference to DataStore:

// In SettingsScreen
Row(
    modifier = Modifier.fillMaxWidth(),
    verticalAlignment = Alignment.CenterVertically
) {
    Text("Dark Mode", modifier = Modifier.weight(1f))
    Switch(
        checked = isDarkMode,
        onCheckedChange = { viewModel.toggleDarkMode() }
    )
}
// In your root App composable
@Composable
fun TaskManagerApp() {
    val settingsViewModel: SettingsViewModel = hiltViewModel()
    val isDarkMode by settingsViewModel.isDarkMode.collectAsStateWithLifecycle()

    TaskManagerTheme(darkTheme = isDarkMode) {
        AppNavigation()
    }
}

Loading Skeleton

Instead of a spinner, show placeholder content while loading:

@Composable
fun TaskCardSkeleton() {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant
    ) {
        Row(modifier = Modifier.padding(16.dp)) {
            // Fake checkbox
            Box(
                modifier = Modifier
                    .size(24.dp)
                    .background(
                        MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
                        RoundedCornerShape(4.dp)
                    )
            )
            Spacer(modifier = Modifier.width(12.dp))
            Column(modifier = Modifier.weight(1f)) {
                // Fake title
                Box(
                    modifier = Modifier
                        .fillMaxWidth(0.7f)
                        .height(16.dp)
                        .background(
                            MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
                            RoundedCornerShape(4.dp)
                        )
                )
                Spacer(modifier = Modifier.height(8.dp))
                // Fake subtitle
                Box(
                    modifier = Modifier
                        .fillMaxWidth(0.4f)
                        .height(12.dp)
                        .background(
                            MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
                            RoundedCornerShape(4.dp)
                        )
                )
            }
        }
    }
}

// Show 5 skeletons while loading
if (state.isLoading) {
    LazyColumn(
        verticalArrangement = Arrangement.spacedBy(8.dp),
        contentPadding = PaddingValues(16.dp)
    ) {
        items(5) { TaskCardSkeleton() }
    }
}

Skeletons feel faster than spinners because the user sees the layout immediately.

The Polish Checklist

Before calling your app “done”, check these:

  • Navigation transitions (slide, no instant switch)
  • Swipe to delete with confirmation
  • Animated list item changes
  • Loading skeletons (not just a spinner)
  • Empty state with helpful message + action button
  • Error state with retry button
  • Snackbar for destructive actions (with undo)
  • Dark mode support
  • All colors from MaterialTheme (no hardcoded)
  • Edge-to-edge (enableEdgeToEdge)
  • Keyboard handling (imePadding)

Source Code

View source code on GitHub →

What’s Next?

In the final tutorial of the series, we will publish the app to Google Play — building a signed APK, creating the Play Store listing, and uploading.

See you there.