Stage 1 · Inputs & stateProject 4

Quadratic Equation Solver

Build a real-root solver for ax² + bx + c = 0. Learn how input validation, explicit result types, reusable fields, and mathematical branching turn a formula into a reliable small Android app.

Your assignment: Quadratic Equation Solver

Build a real-root solver for ax² + bx + c = 0. Learn how input validation, explicit result types, reusable fields, and mathematical branching turn a formula into a reliable small Android app.

Beginner · 3–4 hours of building

The download is a complete reference app. Read the brief, predict the result, then implement each step in a separate working copy. Code runs in Android Studio on a device or emulator; the website displays and explains the source.

Before you begin

  • Use saved Compose text-field state and pure calculation functions.
  • Understand Kotlin nullable types, sealed results, and when expressions.
  • Know the quadratic formula and the discriminant b² − 4ac.
  • Run local JVM tests in Android Studio.

By the end, you can…

  • Model two roots, a repeated root, and no real roots as distinct results.
  • Handle linear and constant equations before using the quadratic formula.
  • Accept signed decimal coefficients and safely reject incomplete or nonfinite text.
  • Keep mathematical logic independent of composable functions.
  • Explain how events change inputs and produce a new derived result.
  • Use tests to verify mathematical branches and numeric limits.

Required features

  • Accept a, b, and c as signed numeric text and show the equation being solved.
  • Distinguish two real roots, a repeated real root, and no real roots.
  • When a is zero, solve a remaining linear equation if b is nonzero.
  • When both a and b are zero, distinguish infinitely many solutions from no solution.
  • Show clear guidance for blank, malformed, nonfinite, and unsupported numeric-range input.
  • Keep coefficients through rotation and derive the displayed result from the restored values.
  • Round displayed roots to six significant digits and state that complex roots are outside the required scope.
  • Provide Solve and Clear buttons, show the current equation, and display the scaled discriminant with its relationship to the original discriminant.
  • Hide a previous result when any coefficient changes; Clear empties every coefficient and removes the result.

Set up the project and run the reference app

  1. 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.
  2. 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.
  3. Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
  4. Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the app run configuration and press Run.
  5. 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.
  6. 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.
PowerShell — in the project folder
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest
macOS/Linux: chmod +x gradlew, then ./gradlew :app:assembleDebug :app:testDebugUnitTest. Unit tests run without a device; manual UI checks need a device or emulator.

Keep the toolchain consistent

The projects use the existing course’s pinned toolchain: Kotlin 2.2.21, AGP 8.13.2, Gradle 8.13, and Compose BOM 2025.12.01. The original collection uses lifecycle 2.9.4. Projects 8 and 10 add Navigation 2.9.6; project 10 adds Room 2.8.4. All build files and the Gradle wrapper are included. Open 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.

TOPIC 01

A formula needs a decision tree

The quadratic formula divides by 2a, so a must be checked first. For a = 0, b = 2, c = -4, the problem becomes 2x − 4 = 0 and x = 2. If a and b are both zero, 0 = 0 allows every real x while 5 = 0 allows none. These cases are meaningful outcomes, not parser errors.

TOPIC 02

The discriminant controls the real-root cases

After confirming a is nonzero, the discriminant determines whether the real solution set has two distinct roots, one repeated root, or no roots. Only a nonnegative discriminant can be passed to the real square-root function. No real roots does not mean the equation is invalid; it means its solutions are outside this app's real-number scope.

TOPIC 03

Result types keep mathematical meaning explicit

TwoRoots carries two values, RepeatedRoot and Linear each carry one value, and NoSolution or InfiniteSolutions carry no numeric value. A when expression maps each case to a precise message. This avoids using an empty list or zero as an ambiguous code for several different mathematical situations.

TOPIC 04

Floating-point arithmetic has limits

Double calculations approximate real arithmetic. The reference solver scales all coefficients by the same positive value to reduce overflow, then uses an equivalent stable two-root formula to avoid subtracting nearly equal numbers. Roots remain approximate; extremely imbalanced coefficients can exceed the supported range, and discriminants very close to zero require care in more advanced numerical work.

Three different lifetimes

Recomposition redraws UI from state. 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. Define the mathematical outcomes

Your task: Create EquationSolver.kt and define the EquationResult cases before drawing the screen. Include TwoRoots, RepeatedRoot, Linear, NoRealRoots, InfiniteSolutions, NoSolution, and Error.

Open: app/src/main/java/com/mastercoding/miniprojects/quadratic/EquationSolver.kt

How it works: Start by describing what the function can legitimately return. For example, x² + 1 = 0 returns NoRealRoots, while missing coefficient text returns Error. Keeping these cases separate lets the UI teach the mathematical difference instead of displaying one generic failure message for everything.

EquationSolver.kt
import kotlin.math.sqrt

sealed interface EquationResult {
    data class TwoRoots(val first: Double, val second: Double) : EquationResult
    data class RepeatedRoot(val root: Double) : EquationResult
    data class Linear(val root: Double) : EquationResult
    data object NoRealRoots : EquationResult
    data object InfiniteSolutions : EquationResult
    data object NoSolution : EquationResult
    data class Error(val message: String) : EquationResult
}

fun solveEquation(aText: String, bText: String, cText: String): EquationResult {
    val a = aText.trim().toDoubleOrNull()?.takeIf { it.isFinite() }
    val b = bText.trim().toDoubleOrNull()?.takeIf { it.isFinite() }
    val c = cText.trim().toDoubleOrNull()?.takeIf { it.isFinite() }
    if (a == null || b == null || c == null) {
        return EquationResult.Error("Enter a finite number for every coefficient.")
    }
    if (a == 0.0) {
        if (b == 0.0) return if (c == 0.0) EquationResult.InfiniteSolutions else EquationResult.NoSolution
        val root = -c / b
        return if (root.isFinite()) EquationResult.Linear(root) else rangeError()
    }

    // Scaling preserves the roots and avoids overflowing b*b for large coefficients.
Reference excerpt · lines 5–30. The full file, with imports, is in Complete source files.

Stop and check

Every required mathematical outcome has a clear representation.

Step 2. Parse all three coefficients safely

Your task: Write solveEquation with three string parameters. Trim each value, parse with toDoubleOrNull, reject nonfinite numbers, and return Error if any coefficient is unavailable.

Open: app/src/main/java/com/mastercoding/miniprojects/quadratic/EquationSolver.kt

How it works: While the user changes -3 to -4, the field may temporarily contain only a minus sign. The parser returns null and the UI can show guidance until typing is complete. Zero is a valid coefficient and must not be confused with missing input; negative coefficients must also pass validation.

EquationSolver.kt
}

fun solveEquation(aText: String, bText: String, cText: String): EquationResult {
    val a = aText.trim().toDoubleOrNull()?.takeIf { it.isFinite() }
    val b = bText.trim().toDoubleOrNull()?.takeIf { it.isFinite() }
    val c = cText.trim().toDoubleOrNull()?.takeIf { it.isFinite() }
    if (a == null || b == null || c == null) {
        return EquationResult.Error("Enter a finite number for every coefficient.")
    }
    if (a == 0.0) {
        if (b == 0.0) return if (c == 0.0) EquationResult.InfiniteSolutions else EquationResult.NoSolution
        val root = -c / b
        return if (root.isFinite()) EquationResult.Linear(root) else rangeError()
    }

    // Scaling preserves the roots and avoids overflowing b*b for large coefficients.
    val scale = max(abs(a), max(abs(b), abs(c)))
    val normalizedA = a / scale
    val normalizedB = b / scale
    val normalizedC = c / scale
    if (normalizedA == 0.0 || (b != 0.0 && normalizedB == 0.0) || (c != 0.0 && normalizedC == 0.0)) {
        return rangeError()
    }
    val discriminant = normalizedB * normalizedB - 4.0 * normalizedA * normalizedC
    if (discriminant < 0.0) return EquationResult.NoRealRoots
    if (discriminant == 0.0) {
Reference excerpt · lines 15–40. The full file, with imports, is in Complete source files.

Stop and check

Blank text, letters, NaN, and Infinity produce a safe message, while signed numbers and zero are accepted.

Step 3. Resolve linear and constant equations first

Your task: Before computing a discriminant, branch on a == 0. If b is nonzero, return -c / b as a linear solution after checking finiteness. Otherwise return InfiniteSolutions for c == 0 and NoSolution for nonzero c.

Open: app/src/main/java/com/mastercoding/miniprojects/quadratic/EquationSolver.kt

How it works: Trace 0x² + 2x − 4 = 0: the a branch is taken, b is nonzero, and -(-4) / 2 gives 2. Trace 0x² + 0x + 5 = 0: both variable terms disappear and the constant is nonzero, so no x can satisfy the equation. This ordering avoids division by zero in the quadratic formula.

EquationSolver.kt
        return EquationResult.Error("Enter a finite number for every coefficient.")
    }
    if (a == 0.0) {
        if (b == 0.0) return if (c == 0.0) EquationResult.InfiniteSolutions else EquationResult.NoSolution
        val root = -c / b
        return if (root.isFinite()) EquationResult.Linear(root) else rangeError()
    }

    // Scaling preserves the roots and avoids overflowing b*b for large coefficients.
    val scale = max(abs(a), max(abs(b), abs(c)))
    val normalizedA = a / scale
    val normalizedB = b / scale
    val normalizedC = c / scale
    if (normalizedA == 0.0 || (b != 0.0 && normalizedB == 0.0) || (c != 0.0 && normalizedC == 0.0)) {
        return rangeError()
    }
    val discriminant = normalizedB * normalizedB - 4.0 * normalizedA * normalizedC
    if (discriminant < 0.0) return EquationResult.NoRealRoots
    if (discriminant == 0.0) {
        val root = -normalizedB / (2.0 * normalizedA)
        return if (root.isFinite()) EquationResult.RepeatedRoot(root) else rangeError()
    }

    // This equivalent form avoids subtracting nearly equal numbers for the smaller root.
    val squareRoot = sqrt(discriminant)
    val signedRoot = if (normalizedB >= 0.0) squareRoot else -squareRoot
Reference excerpt · lines 22–47. The full file, with imports, is in Complete source files.

Stop and check

Linear, contradictory, and identity equations all receive the correct message.

Step 4. Classify the discriminant

Your task: For a nonzero quadratic, scale a, b, and c by their largest absolute coefficient, then compute the discriminant from the scaled values. Return NoRealRoots for a negative value and RepeatedRoot for zero.

Open: app/src/main/java/com/mastercoding/miniprojects/quadratic/EquationSolver.kt

How it works: Dividing the whole equation by the same nonzero number preserves its solutions while reducing the chance of overflowing b × b. The sign of the discriminant still classifies the roots. For (1, 2, 1), the discriminant is zero and -b / (2a) yields -1. For (1, 0, 1), it is negative, so the real square-root step is skipped.

EquationSolver.kt
        return rangeError()
    }
    val discriminant = normalizedB * normalizedB - 4.0 * normalizedA * normalizedC
    if (discriminant < 0.0) return EquationResult.NoRealRoots
    if (discriminant == 0.0) {
        val root = -normalizedB / (2.0 * normalizedA)
        return if (root.isFinite()) EquationResult.RepeatedRoot(root) else rangeError()
    }

    // This equivalent form avoids subtracting nearly equal numbers for the smaller root.
    val squareRoot = sqrt(discriminant)
    val signedRoot = if (normalizedB >= 0.0) squareRoot else -squareRoot
    val q = -0.5 * (normalizedB + signedRoot)
    val first = q / normalizedA
    val second = normalizedC / q
    if (!first.isFinite() || !second.isFinite()) return rangeError()
    return EquationResult.TwoRoots(minOf(first, second), maxOf(first, second))
}

private fun rangeError() = EquationResult.Error("These coefficients exceed the reliable numeric range. Try values closer in size.")

fun normalizedDiscriminant(aText: String, bText: String, cText: String): Double? {
    val a = aText.trim().toDoubleOrNull()?.takeIf { it.isFinite() && it != 0.0 } ?: return null
    val b = bText.trim().toDoubleOrNull()?.takeIf { it.isFinite() } ?: return null
    val c = cText.trim().toDoubleOrNull()?.takeIf { it.isFinite() } ?: return null
    val scale = max(abs(a), max(abs(b), abs(c)))
Reference excerpt · lines 36–61. The full file, with imports, is in Complete source files.

Stop and check

A repeated root and a negative discriminant produce different, correct results.

Step 5. Calculate and order two roots

Your task: For a positive discriminant, implement the provided q form of the quadratic formula, calculate q / a and c / q using normalized coefficients, verify both roots are finite, and return them in ascending order.

Open: app/src/main/java/com/mastercoding/miniprojects/quadratic/EquationSolver.kt

How it works: The ordinary plus/minus formula is mathematically correct, but subtracting almost equal floating-point values can erase a small root. Choosing q with the same sign contribution as b avoids that subtraction, and the product-of-roots identity supplies the other root. For the required beginner exercise, understand the discriminant branches first, then use the documented helper for this numerical refinement.

EquationSolver.kt

    // This equivalent form avoids subtracting nearly equal numbers for the smaller root.
    val squareRoot = sqrt(discriminant)
    val signedRoot = if (normalizedB >= 0.0) squareRoot else -squareRoot
    val q = -0.5 * (normalizedB + signedRoot)
    val first = q / normalizedA
    val second = normalizedC / q
    if (!first.isFinite() || !second.isFinite()) return rangeError()
    return EquationResult.TwoRoots(minOf(first, second), maxOf(first, second))
}

private fun rangeError() = EquationResult.Error("These coefficients exceed the reliable numeric range. Try values closer in size.")

fun normalizedDiscriminant(aText: String, bText: String, cText: String): Double? {
    val a = aText.trim().toDoubleOrNull()?.takeIf { it.isFinite() && it != 0.0 } ?: return null
    val b = bText.trim().toDoubleOrNull()?.takeIf { it.isFinite() } ?: return null
    val c = cText.trim().toDoubleOrNull()?.takeIf { it.isFinite() } ?: return null
    val scale = max(abs(a), max(abs(b), abs(c)))
    val normalizedA = a / scale
    val normalizedB = b / scale
    val normalizedC = c / scale
    return normalizedB * normalizedB - 4.0 * normalizedA * normalizedC
}
Reference excerpt · lines 44–67. The full file, with imports, is in Complete source files.

Stop and check

(1, -3, 2) returns roots 1 and 2; the zero-constant case also returns a valid zero root.

Step 6. Build reusable coefficient fields

Your task: In QuadraticSolver, declare saved a, b, and c strings and a saved showResult Boolean. Derive result with solveEquation and call CoefficientField three times. Give each field a label and a keyboard that permits negative values; hide the previous result in every edit callback.

Open: app/src/main/java/com/mastercoding/miniprojects/quadratic/MainActivity.kt

How it works: Changing c from 2 to 1 updates c and sets showResult to false, removing stale roots. Solve sets showResult to true so the current derived outcome becomes visible. The field receives its value and callback as parameters rather than owning another coefficient copy. The equation preview reads the same strings as the fields.

MainActivity.kt
@Composable
fun QuadraticSolver() {
    var a by rememberSaveable { mutableStateOf("1") }
    var b by rememberSaveable { mutableStateOf("-3") }
    var c by rememberSaveable { mutableStateOf("2") }
    var showResult by rememberSaveable { mutableStateOf(false) }
    val result = solveEquation(a, b, c)

    Text("Solve ax² + bx + c = 0 for real x.", style = MaterialTheme.typography.titleMedium)
    Text("Enter positive, negative, or zero coefficients. Use a dot for decimals.")
    CoefficientField(label = "a", value = a, onChange = { a = it; showResult = false })
    CoefficientField(label = "b", value = b, onChange = { b = it; showResult = false })
    CoefficientField(label = "c", value = c, onChange = { c = it; showResult = false })
    Text("Current equation: (${a.ifBlank { "?" }})x² + (${b.ifBlank { "?" }})x + (${c.ifBlank { "?" }}) = 0")
    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(onClick = { showResult = true }) { Text("Solve") }
        OutlinedButton(onClick = { a = ""; b = ""; c = ""; showResult = false }) { Text("Clear") }
    }
    val message = when (result) {
        is EquationResult.TwoRoots -> "Two real roots: x₁ = ${number(result.first)}, x₂ = ${number(result.second)}"
        is EquationResult.RepeatedRoot -> "One repeated real root: x = ${number(result.root)}"
        is EquationResult.Linear -> "Linear equation: x = ${number(result.root)}"
        EquationResult.NoRealRoots -> "No real roots. The discriminant is negative."
        EquationResult.InfiniteSolutions -> "Infinitely many solutions: every real x works."
        EquationResult.NoSolution -> "No solution: a nonzero constant cannot equal zero."
        is EquationResult.Error -> result.message
Reference excerpt · lines 34–59. The full file, with imports, is in Complete source files.

Stop and check

All coefficients are editable, the equation preview follows them, and editing hides a previous solution until Solve is tapped.

Step 7. Add Solve, Clear, and a complete result explanation

Your task: Add Solve to reveal the current result and Clear to empty the coefficients and hide output. Use an exhaustive when expression for every EquationResult. For valid quadratics, display normalizedDiscriminant and explain that scaling preserves the sign of b squared minus 4ac.

Open: app/src/main/java/com/mastercoding/miniprojects/quadratic/MainActivity.kt

How it works: For a = 0 and b = 2, Solve says Linear equation rather than pretending the app found a quadratic root. For (1, -3, 2), the normalized discriminant is about 0.111111: all coefficients were divided by 3, so its value is the original discriminant divided by 9. The sign still indicates two roots. Clear removes the equation inputs and result together.

MainActivity.kt
        OutlinedButton(onClick = { a = ""; b = ""; c = ""; showResult = false }) { Text("Clear") }
    }
    val message = when (result) {
        is EquationResult.TwoRoots -> "Two real roots: x₁ = ${number(result.first)}, x₂ = ${number(result.second)}"
        is EquationResult.RepeatedRoot -> "One repeated real root: x = ${number(result.root)}"
        is EquationResult.Linear -> "Linear equation: x = ${number(result.root)}"
        EquationResult.NoRealRoots -> "No real roots. The discriminant is negative."
        EquationResult.InfiniteSolutions -> "Infinitely many solutions: every real x works."
        EquationResult.NoSolution -> "No solution: a nonzero constant cannot equal zero."
        is EquationResult.Error -> result.message
    }
    if (!showResult) Text("Tap Solve to calculate the current equation.")
    if (showResult) Text(
        message,
        style = MaterialTheme.typography.titleLarge,
        color = if (result is EquationResult.Error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
    )
    val discriminant = normalizedDiscriminant(a, b, c)
    if (showResult && result !is EquationResult.Error && discriminant != null) {
        Text("Normalized discriminant D′ = ${number(discriminant)}")
        Text("D′ = (b/s)² − 4(a/s)(c/s), with s = max(|a|, |b|, |c|). Its sign matches D = b² − 4ac; scaling avoids overflow for large coefficients.")
    }
    Text("Results use floating-point arithmetic and are rounded to six significant digits. Complex roots are outside this exercise.")
}

@Composable
Reference excerpt · lines 50–75. The full file, with imports, is in Complete source files.

Stop and check

Solve displays the correct branch, current equation, and a labelled scaled discriminant for quadratics; Clear resets all coefficients.

Step 8. Verify branches and restore the form

Your task: Run EquationSolverTest, add a decimal-coefficient example, and manually test each acceptance scenario with Solve. Solve a different equation, rotate the device, verify the result, and test Clear.

Open: app/src/test/java/com/mastercoding/miniprojects/quadratic/EquationSolverTest.kt

How it works: One happy-path example cannot verify the decision tree. The tests cover two roots, one root, no real roots, a linear equation, both constant cases, zero roots, invalid input, and large coefficients. Rotation separately checks that the UI restores source state; the solver itself has no Android lifecycle dependency.

EquationSolverTest.kt
    }

    @Test fun handlesEveryDegenerateEquation() {
        assertEquals(2.0, (solveEquation("0", "2", "-4") as EquationResult.Linear).root, 0.0)
        assertEquals(EquationResult.InfiniteSolutions, solveEquation("0", "0", "0"))
        assertEquals(EquationResult.NoSolution, solveEquation("0", "0", "5"))
    }

    @Test fun acceptsZeroAndNegativeCoefficients() {
        val result = solveEquation("-1", "0", "4") as EquationResult.TwoRoots
        assertEquals(-2.0, result.first, 0.000001)
        assertEquals(2.0, result.second, 0.000001)
        val zeroRoot = solveEquation("1", "-2", "0") as EquationResult.TwoRoots
        assertEquals(0.0, zeroRoot.first, 0.0)
        assertEquals(2.0, zeroRoot.second, 0.0)
    }

    @Test fun scalesLargeCoefficientsAndPreservesSmallRoot() {
        val large = solveEquation("1e200", "-3e200", "2e200") as EquationResult.TwoRoots
        assertEquals(1.0, large.first, 0.000001)
        assertEquals(2.0, large.second, 0.000001)
        val imbalanced = solveEquation("1", "1e16", "1") as EquationResult.TwoRoots
        assertEquals(-1e16, imbalanced.first, 1.0)
        assertEquals(-1e-16, imbalanced.second, 1e-25)
    }
Reference excerpt · lines 18–43. The full file, with imports, is in Complete source files.

Stop and check

All local tests pass, every mathematical branch is demonstrated, and rotation preserves the chosen equation.

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 hereResponsibility
MainActivity.ktThe launcher Activity installs the Compose UI with setContent.
CommonUi.ktMaterial 3 theme and reusable screen container with scrolling and system insets.
Application Kotlin filesState, rules, navigation or persistence introduced in the guide.
app/src/main/resImages, text resources, and the launch theme.
app/build.gradle.ktsSDK settings and the exact libraries used by this app.
README.md / TOOLCHAIN.mdStudent instructions and setup, usable without the website.

Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.

19 complete text files. Copy the selected file with the code panel button, or download the full project above.

MainActivity.kt
package com.mastercoding.miniprojects.quadratic

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import java.util.Locale

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MiniTheme { MiniScreen("Quadratic Equation Solver") { QuadraticSolver() } }
        }
    }
}

@Composable
fun QuadraticSolver() {
    var a by rememberSaveable { mutableStateOf("1") }
    var b by rememberSaveable { mutableStateOf("-3") }
    var c by rememberSaveable { mutableStateOf("2") }
    var showResult by rememberSaveable { mutableStateOf(false) }
    val result = solveEquation(a, b, c)

    Text("Solve ax² + bx + c = 0 for real x.", style = MaterialTheme.typography.titleMedium)
    Text("Enter positive, negative, or zero coefficients. Use a dot for decimals.")
    CoefficientField(label = "a", value = a, onChange = { a = it; showResult = false })
    CoefficientField(label = "b", value = b, onChange = { b = it; showResult = false })
    CoefficientField(label = "c", value = c, onChange = { c = it; showResult = false })
    Text("Current equation: (${a.ifBlank { "?" }})x² + (${b.ifBlank { "?" }})x + (${c.ifBlank { "?" }}) = 0")
    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(onClick = { showResult = true }) { Text("Solve") }
        OutlinedButton(onClick = { a = ""; b = ""; c = ""; showResult = false }) { Text("Clear") }
    }
    val message = when (result) {
        is EquationResult.TwoRoots -> "Two real roots: x₁ = ${number(result.first)}, x₂ = ${number(result.second)}"
        is EquationResult.RepeatedRoot -> "One repeated real root: x = ${number(result.root)}"
        is EquationResult.Linear -> "Linear equation: x = ${number(result.root)}"
        EquationResult.NoRealRoots -> "No real roots. The discriminant is negative."
        EquationResult.InfiniteSolutions -> "Infinitely many solutions: every real x works."
        EquationResult.NoSolution -> "No solution: a nonzero constant cannot equal zero."
        is EquationResult.Error -> result.message
    }
    if (!showResult) Text("Tap Solve to calculate the current equation.")
    if (showResult) Text(
        message,
        style = MaterialTheme.typography.titleLarge,
        color = if (result is EquationResult.Error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
    )
    val discriminant = normalizedDiscriminant(a, b, c)
    if (showResult && result !is EquationResult.Error && discriminant != null) {
        Text("Normalized discriminant D′ = ${number(discriminant)}")
        Text("D′ = (b/s)² − 4(a/s)(c/s), with s = max(|a|, |b|, |c|). Its sign matches D = b² − 4ac; scaling avoids overflow for large coefficients.")
    }
    Text("Results use floating-point arithmetic and are rounded to six significant digits. Complex roots are outside this exercise.")
}

@Composable
private fun CoefficientField(label: String, value: String, onChange: (String) -> Unit) {
    OutlinedTextField(
        value = value,
        onValueChange = onChange,
        label = { Text("Coefficient $label") },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
        singleLine = true,
        modifier = Modifier.fillMaxWidth()
    )
}

private fun number(value: Double): String = String.format(Locale.US, "%.6g", if (value == 0.0) 0.0 else value)

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 thisExpected result
Enter a = 1, b = -3, c = 2. Tap Solve for each equation.The app identifies two real roots, 1 and 2, in ascending order.
Enter a = 1, b = 2, c = 1. Tap Solve for each equation.The app identifies the repeated real root -1.
Enter a = 1, b = 0, c = 1. Tap Solve for each equation.The app says there are no real roots because the discriminant is negative.
Enter a = 0, b = 2, c = -4. Tap Solve for each equation.The app identifies a linear equation with x = 2.
Enter (0, 0, 0), then (0, 0, 5). Tap Solve for each equation.The first has infinitely many solutions; the second has no solution.
Enter a = -1, b = 0, c = 4, then (1, -2, 0). Tap Solve for each equation.The roots are -2 and 2 in the first case and 0 and 2 in the second.
Clear a field, enter only a minus sign, paste letters, or try NaN and Infinity. Tap Solve for each equation.A finite-number message appears and no stale roots remain visible.
Enter (1, 0, -9), tap Solve, rotate, and inspect every field.The inputs remain 1, 0, and -9 and the visible result remains roots -3 and 3.
Enter (1e200, -3e200, 2e200). Tap Solve for each equation.Scaling allows the solver to return approximately 1 and 2 instead of overflowing the discriminant.
Solve (1, -3, 2), inspect D-prime, then edit c and tap Clear.The equation preview matches the fields. D-prime is approximately 0.111111 with an explanation of scaling. Editing hides old roots; Clear empties every coefficient and result.

Your completion checklist

Project acceptance checklist0 / 9 verified

Check an item only after demonstrating it. Progress is saved in this browser.

Troubleshooting: understand the cause

Setting a to zero produces Infinity or NaN.

Cause: The quadratic formula divides by 2a before the degenerate equation is checked.

Repair: Handle linear and constant equations before entering the quadratic branch.

A negative discriminant produces an unexplained NaN.

Cause: sqrt is called before checking the discriminant sign.

Repair: Return NoRealRoots for a negative discriminant and call the real square-root function only for a positive value.

The app rejects b = 0 or c = 0 as missing input.

Cause: Validation treats zero as false or uses zero as a sentinel for parse failure.

Repair: Use nullable parsing and check null separately from the legitimate number zero.

One root disappears when b is much larger than the other coefficients.

Cause: The ordinary formula subtracts nearly equal floating-point values.

Repair: Use the documented q form in the helper and test an imbalanced example; keep a clear numerical-range error for unsupported cases.

The field resets or a previous equation's roots remain after rotation.

Cause: Inputs were ordinary local variables or the result was stored separately from its dependencies.

Repair: Save only the coefficient strings with rememberSaveable and derive the current result from them.

Submit your app and explain your decisions

  1. Submit the Android Studio project without build caches, .gradle or machine-specific local.properties.
  2. Include two screenshots demonstrating normal behavior and an edge case.
  3. Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
  4. Answer the questions below using function and state names from your own implementation.
  • Why must the a = 0 case be checked before using the quadratic formula?
  • How are NoRealRoots, NoSolution, and Error different from one another? Give an example of each.
  • Which values must survive rotation, and why can the result be calculated again instead of saved?
  • Why can two algebraically equivalent formulas behave differently with floating-point numbers?
CriterionPointsWhat to demonstrate
Core behavior40All required actions give the expected result.
Validation and lifecycle20Invalid inputs, empty states, rotation and the project's persistence rules work.
Interface and accessibility20Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets.
Code and explanation20Small named functions, clear state ownership and a correct explanation of one event-to-result trace.

Extra challenges after the core works

  • Add complex roots with separate real and imaginary components and corresponding tests.
  • Show a step-by-step discriminant explanation using the original coefficients.
  • Add a local history of solved equations after learning persistence.
  • Investigate near-zero discriminants and document a scale-aware numerical tolerance policy before changing classification.

Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.

Further reading