Your assignment: Mini Recipe Book
Build a searchable six-recipe catalog, navigate to a details screen using an item ID, and share the selected recipe with another Android app.
Intermediate · 3–4 hours of building
Before you begin
- Complete the shopping checklist or understand LazyColumn and stable keys.
- Read Kotlin data classes, lists, nullable values, and lambdas.
- Understand rememberSaveable and string resources.
By the end, you can…
- Model screen content with stable IDs and Android resource references.
- Build a bounded lazy list with an empty search state.
- Use Navigation Compose to move between list and details destinations.
- Read local vector and string-array resources.
- Send plain text through the Android Sharesheet and handle a missing receiver.
Required features
- Include six local recipes with a name, local image, ingredients, and numbered preparation steps.
- Filter recipe names without case sensitivity and explain an empty result.
- Open the matching details screen using its numeric recipe ID.
- Support both the on-screen Back action and the system Back action.
- Share the selected name, ingredients, and preparation as text.
- Keep the list query when rotating the device or returning from details; no network permission is needed.
Set up the project and run the reference app
- Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing
settings.gradle.kts. Open that folder rather than just a Kotlin file. - Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
- Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
- Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the
apprun configuration and press Run. - Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
- Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and
CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the toolchain consistent
TOOLCHAIN.md for setup details.Understand the topics before coding
For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.
Resource-backed data classes
Recipe describes one recipe, while strings.xml contains the English content. The @StringRes, @DrawableRes, and @ArrayRes annotations help the IDE catch accidental use of the wrong resource category. R.string.recipe_pasta is an integer identifier, not the text itself; stringResource resolves it for the current configuration.
Navigation and stable identity
A navigation controller owns the back stack. The list pushes recipe/3, and recipe/{id} declares that its argument is an Int. Details look up that ID in the catalog. Passing only identity avoids copying a complete object into a route and keeps the details source of truth in one place.
Lazy layout constraints
LazyColumn composes visible rows as needed, but it must know the available viewport height. Here a full-height Column allocates the remaining height with weight(1f). Wrapping the list inside a vertically scrolling parent would give it an unbounded height and can fail at runtime.
Saved UI state
The search query is small UI state, so rememberSaveable is appropriate. Navigation manages state for destinations on its back stack. Saving a query is different from storing the recipe catalog: the catalog is compiled into resources and requires neither Room nor a network request.
Inter-app communication
ACTION_SEND with type text/plain asks another Android application to handle a text payload. EXTRA_TEXT holds the complete recipe, and createChooser presents the available targets. Opening the chooser does not send a message automatically; the user chooses the destination and finishes sharing there.
Accessible decorative images
Every recipe uses the included local bowl vector as a visual cue. Its contentDescription is null because it repeats information already supplied by the nearby recipe title and adds no unique meaning. If you replace it with an informative photograph, give that image a useful localized description.
Three different lifetimes
rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.Build the app step by step
Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.
Step 1. 1. Run the two-screen project shell
Your task: Open the downloaded recipe-book folder as an Android Studio project, allow Gradle sync, and run it on an API 23+ emulator. In app/src/main/java/com/mastercoding/miniprojects/recipes/MainActivity.kt, find setContent and trace MiniTheme into RecipeApp. Keep the package name aligned with the app module namespace.
Open: app/src/main/java/com/mastercoding/miniprojects/recipes/MainActivity.kt
How it works: The activity is Android's entry point. setContent starts the Compose tree; composables describe the current interface rather than creating views once. The shared theme applies consistent Material styling, while RecipeApp chooses which screen is displayed.
import androidx.navigation.navArgument
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MiniTheme { RecipeApp() } }
}
}
@Composable
private fun RecipeApp() {
val controller = rememberNavController()
NavHost(controller, startDestination = "recipes") {
composable("recipes") {
RecipeList(onOpen = { id -> controller.navigate("recipe/$id") })
}
composable(
route = "recipe/{id}",
arguments = listOf(navArgument("id") { type = NavType.IntType }),
) { entry ->
val recipe = recipes.find { it.id == entry.arguments?.getInt("id") }
if (recipe == null) {
MiniScreen(stringResource(R.string.missing_recipe)) {
Text(stringResource(R.string.missing_recipe_help))
Button(onClick = { controller.popBackStack() }) { Text(stringResource(R.string.back)) }
}Stop and check
Step 2. 2. Define and inspect the recipe catalog
Your task: Open Recipe.kt. Identify id, name, image, ingredients, and steps in the Recipe data class. Follow the resource references for recipe 1 and recipe 6. Add a temporary seventh recipe only after creating all its resource entries, then remove it before the six-recipe acceptance check.
Open: app/src/main/java/com/mastercoding/miniprojects/recipes/Recipe.kt
How it works: A data model keeps each recipe's related values together. An immutable list is suitable because this assignment browses fixed content. IDs are deliberately independent of list positions: sorting or filtering the list must never change which recipe an ID identifies.
// Store resource IDs so Android can choose the correct language at runtime.
data class Recipe(
val id: Int,
@StringRes val name: Int,
@DrawableRes val image: Int,
@ArrayRes val ingredients: Int,
@ArrayRes val steps: Int,
)
val recipes = listOf(
Recipe(1, R.string.recipe_pasta, R.drawable.recipe_bowl, R.array.pasta_ingredients, R.array.pasta_steps),
Recipe(2, R.string.recipe_oats, R.drawable.recipe_bowl, R.array.oats_ingredients, R.array.oats_steps),
Recipe(3, R.string.recipe_salad, R.drawable.recipe_bowl, R.array.salad_ingredients, R.array.salad_steps),
Recipe(4, R.string.recipe_wrap, R.drawable.recipe_bowl, R.array.wrap_ingredients, R.array.wrap_steps),
Recipe(5, R.string.recipe_yogurt, R.drawable.recipe_bowl, R.array.yogurt_ingredients, R.array.yogurt_steps),
Recipe(6, R.string.recipe_toast, R.drawable.recipe_bowl, R.array.toast_ingredients, R.array.toast_steps),
)Stop and check
Step 3. 3. Store text and a drawable locally
Your task: Open app/src/main/res/values/strings.xml. Read the pasta ingredient and preparation arrays and change one instruction to your own wording. Open app/src/main/res/drawable/recipe_bowl.xml and change its bowl fill color, then run again. Keep valid XML and keep the existing resource names.
Open: app/src/main/res/values/strings.xml
How it works: Resources are packaged with the application and are available offline. string-array groups related text entries; stringArrayResource returns those entries to a composable. The bowl is a vector drawable, so Android scales it without requiring a large bitmap. Resource names become generated R identifiers during the build.
<string name="recipe_yogurt">Berry Yogurt Bowl</string>
<string name="recipe_toast">Avocado Toast</string>
<string-array name="pasta_ingredients"><item>100 g dry pasta</item><item>150 g canned chopped tomatoes</item><item>1 teaspoon olive oil</item><item>1 teaspoon dried basil</item></string-array>
<string-array name="pasta_steps"><item>Cook pasta in boiling water according to its packet instructions.</item><item>Warm the olive oil, tomatoes, and basil in a separate pan for 5 minutes.</item><item>Drain the pasta, combine with the sauce, and serve warm.</item></string-array>
<string-array name="oats_ingredients"><item>Half a cup of rolled oats</item><item>Half a cup of milk or a suitable alternative</item><item>1 sliced banana</item><item>1 teaspoon honey, optional</item></string-array>
<string-array name="oats_steps"><item>Mix the oats and milk in a covered container.</item><item>Refrigerate overnight.</item><item>Add the banana and optional honey just before serving.</item></string-array>
<string-array name="salad_ingredients"><item>1 cup canned chickpeas, drained and rinsed</item><item>1 chopped cucumber</item><item>1 chopped tomato</item><item>1 tablespoon lemon juice</item><item>1 teaspoon olive oil</item></string-array>
<string-array name="salad_steps"><item>Place the chickpeas, cucumber, and tomato in a bowl.</item><item>Add lemon juice and olive oil.</item><item>Toss gently and serve.</item></string-array>
<string-array name="wrap_ingredients"><item>1 tortilla</item><item>2 tablespoons hummus</item><item>Half a cup shredded lettuce</item><item>Half a sliced cucumber</item><item>Half a grated carrot</item></string-array>
<string-array name="wrap_steps"><item>Spread hummus over the tortilla.</item><item>Arrange the vegetables across the center.</item><item>Fold in the sides, roll tightly, and cut in half.</item></string-array>
<string-array name="yogurt_ingredients"><item>1 cup plain yogurt</item><item>Half a cup washed berries</item><item>2 tablespoons granola</item></string-array>
<string-array name="yogurt_steps"><item>Spoon yogurt into a bowl.</item><item>Arrange the berries on top.</item><item>Add granola just before serving.</item></string-array>
<string-array name="toast_ingredients"><item>2 slices of bread</item><item>Half a ripe avocado</item><item>1 teaspoon lemon juice</item><item>A pinch of black pepper</item></string-array>
<string-array name="toast_steps"><item>Toast the bread.</item><item>Mash the avocado with the lemon juice and pepper.</item><item>Spread over the toast and serve.</item></string-array>
</resources>Stop and check
Step 4. 4. Implement search and the list viewport
Your task: In RecipeList, trace the query from OutlinedTextField.onValueChange to the filtered list. Type pasta, PASTA, a leading-space query, and an unknown word. Inspect the stable key in items and the weight on LazyColumn. Keep this screen outside MiniScreen because MiniScreen already scrolls.
Open: app/src/main/java/com/mastercoding/miniprojects/recipes/MainActivity.kt
How it works: Changing query triggers recomposition, which recalculates the filtered list. trim removes accidental outer spaces, and ignoreCase makes a student's search forgiving. Stable IDs help Compose associate the correct row with an item after filtering. The empty message explains a successful search that simply has no matches.
@Composable
private fun RecipeList(onOpen: (Int) -> Unit) {
var query by rememberSaveable { mutableStateOf("") }
val resources = LocalContext.current.resources
val filtered = recipes.filter { resources.getString(it.name).contains(query.trim(), ignoreCase = true) }
// LazyColumn owns the remaining bounded height; do not wrap it in a scrolling Column.
Column(
Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).imePadding().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(R.string.recipe_book_title), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = query, onValueChange = { query = it },
label = { Text(stringResource(R.string.search_recipes)) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
if (filtered.isEmpty()) Text(stringResource(R.string.no_recipes))
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(filtered, key = { it.id }) { recipe ->
Card(onClick = { onOpen(recipe.id) }, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
// This repeated bowl is decorative; the adjacent title identifies the recipe.
Image(painterResource(recipe.image), contentDescription = null, modifier = Modifier.size(56.dp))
Column {
Text(stringResource(recipe.name), style = MaterialTheme.typography.titleMedium)Stop and check
Step 5. 5. Connect list and details with an ID route
Your task: Find the NavHost in RecipeApp. Follow onOpen from a card click to controller.navigate, then inspect the recipe/{id} declaration and NavType.IntType. Open each card and compare its title to the selected row. Use both Back controls to return.
Open: app/src/main/java/com/mastercoding/miniprojects/recipes/MainActivity.kt
How it works: The host declares valid destinations and the controller changes the current destination. A typed argument converts the route segment to an integer. The catalog lookup is nullable, so the code includes an unavailable-recipe screen rather than assuming every ID exists. popBackStack removes details and reveals the previous list destination.
@Composable
private fun RecipeApp() {
val controller = rememberNavController()
NavHost(controller, startDestination = "recipes") {
composable("recipes") {
RecipeList(onOpen = { id -> controller.navigate("recipe/$id") })
}
composable(
route = "recipe/{id}",
arguments = listOf(navArgument("id") { type = NavType.IntType }),
) { entry ->
val recipe = recipes.find { it.id == entry.arguments?.getInt("id") }
if (recipe == null) {
MiniScreen(stringResource(R.string.missing_recipe)) {
Text(stringResource(R.string.missing_recipe_help))
Button(onClick = { controller.popBackStack() }) { Text(stringResource(R.string.back)) }
}
} else {
RecipeDetails(recipe, onBack = { controller.popBackStack() })
}
}
}
}
@ComposableStop and check
Step 6. 6. Render ingredient and preparation sections
Your task: Inspect RecipeDetails. Read the two string arrays, render ingredients as bullets, and read how forEachIndexed creates step numbers. Rotate the device while reading the final recipe, then use a narrow emulator to inspect scrolling and button access.
Open: app/src/main/java/com/mastercoding/miniprojects/recipes/MainActivity.kt
How it works: Details contain a small, bounded number of text elements, so a normal scrolling Column is sufficient. The number displayed to the reader is index + 1 because Kotlin list indexes start at zero. Keeping recipe IDs in the route lets navigation restore which details screen is open after configuration changes.
@Composable
private fun RecipeDetails(recipe: Recipe, onBack: () -> Unit) {
val context = LocalContext.current
var shareError by rememberSaveable(recipe.id) { mutableStateOf(false) }
val ingredients = stringArrayResource(recipe.ingredients)
val steps = stringArrayResource(recipe.steps)
MiniScreen(stringResource(recipe.name)) {
TextButton(onClick = onBack) { Text(stringResource(R.string.back)) }
Image(painterResource(recipe.image), contentDescription = null, modifier = Modifier.size(140.dp))
Text(stringResource(R.string.ingredients), style = MaterialTheme.typography.titleLarge)
ingredients.forEach { Text("• $it") }
Text(stringResource(R.string.preparation), style = MaterialTheme.typography.titleLarge)
steps.forEachIndexed { index, step -> Text("${index + 1}. $step") }
Button(onClick = { shareError = !shareRecipe(context, recipe) }) { Text(stringResource(R.string.share_recipe)) }
if (shareError) Text(stringResource(R.string.no_share_app), color = MaterialTheme.colorScheme.error)
}
}
private fun shareRecipe(context: Context, recipe: Recipe): Boolean {
val resources = context.resources
val text = buildString {
appendLine(resources.getString(recipe.name))
appendLine()
appendLine(resources.getString(R.string.ingredients))
resources.getStringArray(recipe.ingredients).forEach { appendLine("• $it") }Stop and check
Step 7. 7. Add and inspect Android sharing
Your task: Read shareRecipe from buildString through createChooser. Tap Share recipe and choose a text-capable app or inspect the chooser preview. Check that the payload includes the selected title, both section headings, every ingredient, and every numbered preparation step. Cancel the chooser and return to details.
Open: app/src/main/java/com/mastercoding/miniprojects/recipes/MainActivity.kt
How it works: The explicit MIME type tells Android which applications can handle the content. Subject and body are separate extras because receiving apps may display them differently. ActivityNotFoundException is converted into a visible error so an unusual emulator without a compatible receiver does not crash. No email account, backend, or sharing permission is required to open the chooser.
}
private fun shareRecipe(context: Context, recipe: Recipe): Boolean {
val resources = context.resources
val text = buildString {
appendLine(resources.getString(recipe.name))
appendLine()
appendLine(resources.getString(R.string.ingredients))
resources.getStringArray(recipe.ingredients).forEach { appendLine("• $it") }
appendLine()
appendLine(resources.getString(R.string.preparation))
resources.getStringArray(recipe.steps).forEachIndexed { index, step -> appendLine("${index + 1}. $step") }
}
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, resources.getString(recipe.name))
putExtra(Intent.EXTRA_TEXT, text)
}
return try {
context.startActivity(Intent.createChooser(sendIntent, resources.getString(R.string.share_recipe)))
true
} catch (_: ActivityNotFoundException) {
false
}
}Stop and check
Step 8. 8. Review and explain your implementation
Your task: Run every completion check below. Capture the list, a no-result search, and one details screen. In your README, explain the difference between a recipe ID and a resource ID, why this list uses LazyColumn, and why sharing uses an Android intent. State that all catalog content is local and only the search query is mutable.
Open: app/src/main/java/com/mastercoding/miniprojects/recipes/MainActivity.kt
How it works: A good demonstration covers transitions and empty states as well as the first screen. Explaining identity and ownership shows that you understand why the app keeps working when rows disappear during filtering or Android recreates the activity.
arguments = listOf(navArgument("id") { type = NavType.IntType }),
) { entry ->
val recipe = recipes.find { it.id == entry.arguments?.getInt("id") }
if (recipe == null) {
MiniScreen(stringResource(R.string.missing_recipe)) {
Text(stringResource(R.string.missing_recipe_help))
Button(onClick = { controller.popBackStack() }) { Text(stringResource(R.string.back)) }
}
} else {
RecipeDetails(recipe, onBack = { controller.popBackStack() })
}
}
}
}
@Composable
private fun RecipeList(onOpen: (Int) -> Unit) {
var query by rememberSaveable { mutableStateOf("") }
val resources = LocalContext.current.resources
val filtered = recipes.filter { resources.getString(it.name).contains(query.trim(), ignoreCase = true) }
// LazyColumn owns the remaining bounded height; do not wrap it in a scrolling Column.
Column(
Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).imePadding().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(R.string.recipe_book_title), style = MaterialTheme.typography.headlineMedium)Stop and check
Complete source files
Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.
| Start here | Responsibility |
|---|---|
| MainActivity.kt | The launcher Activity installs the Compose UI with setContent. |
| CommonUi.kt | Material 3 theme and reusable screen container with scrolling and system insets. |
| Application Kotlin files | State, rules, navigation or persistence introduced in the guide. |
| app/src/main/res | Images, text resources, and the launch theme. |
| app/build.gradle.kts | SDK settings and the exact libraries used by this app. |
| README.md / TOOLCHAIN.md | Student instructions and setup, usable without the website. |
Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.
20 complete text files. Copy the selected file with the code panel button, or download the full project above.
package com.mastercoding.miniprojects.recipes
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MiniTheme { RecipeApp() } }
}
}
@Composable
private fun RecipeApp() {
val controller = rememberNavController()
NavHost(controller, startDestination = "recipes") {
composable("recipes") {
RecipeList(onOpen = { id -> controller.navigate("recipe/$id") })
}
composable(
route = "recipe/{id}",
arguments = listOf(navArgument("id") { type = NavType.IntType }),
) { entry ->
val recipe = recipes.find { it.id == entry.arguments?.getInt("id") }
if (recipe == null) {
MiniScreen(stringResource(R.string.missing_recipe)) {
Text(stringResource(R.string.missing_recipe_help))
Button(onClick = { controller.popBackStack() }) { Text(stringResource(R.string.back)) }
}
} else {
RecipeDetails(recipe, onBack = { controller.popBackStack() })
}
}
}
}
@Composable
private fun RecipeList(onOpen: (Int) -> Unit) {
var query by rememberSaveable { mutableStateOf("") }
val resources = LocalContext.current.resources
val filtered = recipes.filter { resources.getString(it.name).contains(query.trim(), ignoreCase = true) }
// LazyColumn owns the remaining bounded height; do not wrap it in a scrolling Column.
Column(
Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).imePadding().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(R.string.recipe_book_title), style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = query, onValueChange = { query = it },
label = { Text(stringResource(R.string.search_recipes)) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
if (filtered.isEmpty()) Text(stringResource(R.string.no_recipes))
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(filtered, key = { it.id }) { recipe ->
Card(onClick = { onOpen(recipe.id) }, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
// This repeated bowl is decorative; the adjacent title identifies the recipe.
Image(painterResource(recipe.image), contentDescription = null, modifier = Modifier.size(56.dp))
Column {
Text(stringResource(recipe.name), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.open_recipe))
}
}
}
}
}
}
}
@Composable
private fun RecipeDetails(recipe: Recipe, onBack: () -> Unit) {
val context = LocalContext.current
var shareError by rememberSaveable(recipe.id) { mutableStateOf(false) }
val ingredients = stringArrayResource(recipe.ingredients)
val steps = stringArrayResource(recipe.steps)
MiniScreen(stringResource(recipe.name)) {
TextButton(onClick = onBack) { Text(stringResource(R.string.back)) }
Image(painterResource(recipe.image), contentDescription = null, modifier = Modifier.size(140.dp))
Text(stringResource(R.string.ingredients), style = MaterialTheme.typography.titleLarge)
ingredients.forEach { Text("• $it") }
Text(stringResource(R.string.preparation), style = MaterialTheme.typography.titleLarge)
steps.forEachIndexed { index, step -> Text("${index + 1}. $step") }
Button(onClick = { shareError = !shareRecipe(context, recipe) }) { Text(stringResource(R.string.share_recipe)) }
if (shareError) Text(stringResource(R.string.no_share_app), color = MaterialTheme.colorScheme.error)
}
}
private fun shareRecipe(context: Context, recipe: Recipe): Boolean {
val resources = context.resources
val text = buildString {
appendLine(resources.getString(recipe.name))
appendLine()
appendLine(resources.getString(R.string.ingredients))
resources.getStringArray(recipe.ingredients).forEach { appendLine("• $it") }
appendLine()
appendLine(resources.getString(R.string.preparation))
resources.getStringArray(recipe.steps).forEachIndexed { index, step -> appendLine("${index + 1}. $step") }
}
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, resources.getString(recipe.name))
putExtra(Intent.EXTRA_TEXT, text)
}
return try {
context.startActivity(Intent.createChooser(sendIntent, resources.getString(R.string.share_recipe)))
true
} catch (_: ActivityNotFoundException) {
false
}
}Test the app with concrete scenarios
Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.
| Do this | Expected result |
|---|---|
| Launch without an internet connection. | All six recipes and the local bowl illustration are available. |
| Search pasta, PASTA, and a query with outer spaces. | Each search finds Tomato Pasta. |
| Search for a name absent from the catalog. | The no-recipes message appears and the app remains responsive. |
| Open each recipe and compare its title and content. | The selected ID always resolves to the matching recipe. |
| Search for salad, open its details, and return using system Back and then the on-screen Back action. | The filtered list and search text remain available on return. |
| Rotate once on the list with a query and once on a details screen. | The query and current recipe destination are retained. |
| Open Share recipe and inspect the content or complete sharing in another app. | The payload contains the selected title, all ingredients, and numbered steps; canceling is harmless. |
| Use an emulator without a suitable sharing activity, if available. | An unavailable-target message appears instead of an uncaught ActivityNotFoundException. |
| Use large font and a narrow portrait emulator. | Cards and details scroll; the search field and share button remain reachable. |
Your completion checklist
Check an item only after demonstrating it. Progress is saved in this browser.
Troubleshooting: understand the cause
The app reports an infinitely sized scrolling component.
Cause: A LazyColumn was placed inside a vertically scrolling Column or MiniScreen.
Repair: Keep RecipeList in its full-height Column and give LazyColumn weight(1f); let only the list own vertical scrolling.
Opening a filtered row shows another recipe.
Cause: A list position was passed instead of the stable recipe ID.
Repair: Navigate with recipe.id and look it up with recipes.find; filtering changes positions, not identity.
The share target receives only a title or an empty message.
Cause: EXTRA_SUBJECT was populated without EXTRA_TEXT, or the MIME type was missing.
Repair: Set type to text/plain and put the complete buildString output into EXTRA_TEXT before opening createChooser.
A text or array reference is unresolved after editing resources.
Cause: The XML name and generated R identifier no longer match, or the XML is invalid.
Repair: Restore matching names, escape XML special characters, fix resource errors first, then rebuild.
Submit your app and explain your decisions
- Submit the Android Studio project without build caches,
.gradleor machine-specificlocal.properties. - Include two screenshots demonstrating normal behavior and an edge case.
- Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
- Answer the questions below using function and state names from your own implementation.
- Why does the route contain recipe.id instead of the complete Recipe object or the row index?
- What state would survive reinstalling the app, and what would be lost? Explain the difference between packaged resources and rememberSaveable.
- Which parts of sharing are controlled by your application, and which are controlled by the receiving application?
| Criterion | Points | What to demonstrate |
|---|---|---|
| Core behavior | 40 | All required actions give the expected result. |
| Validation and lifecycle | 20 | Invalid inputs, empty states, rotation and the project's persistence rules work. |
| Interface and accessibility | 20 | Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets. |
| Code and explanation | 20 | Small named functions, clear state ownership and a correct explanation of one event-to-result trace. |
Extra challenges after the core works
- Add a category resource and filter recipes by breakfast, lunch, and snack.
- Replace the shared decorative vector with six local photos and write meaningful image descriptions where needed.
- Add favorites with Room or DataStore and document the persistence behavior.
- Extract the search rule into a pure function and test accented names and empty queries.
Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.