5
\$\begingroup\$

I've made an Android app that converts between cooking measurements, taking into account the type of product you use (that way it can convert between mass and volume by using the density). What are your thoughts on Android- and Kotlin-specific idioms I might be missing?

For brevity's sake, I've included the main types below. Screenshots for context can be found here.

In the MetricsService I'm using a shortest-path approach to convert from any arbitrary metric (say, Liter) to another (say, Ounces). The idea is that the only requirement to add a metric is that you define a conversion to a single other existing metric and it will plug into the rest of the graph.

Service

class MetricsService(
    private val metricsRepository: MetricsRepository,
    private val conversionRepository: ConversionRepository,
    private val productRepository: ProductRepository) {

    fun getMetrics() : List<Metric> = metricsRepository.getMetrics()
    fun getProducts() : List<Product> = productRepository.getProducts().sortedBy { product -> product.name }
    fun getMetric(metricUnit: MetricUnit) : Metric = metricsRepository.getMetrics().first { metric -> metric.id == metricUnit}

    private fun getConversion(source: Metric, destination: Metric) : Conversion {
        val conversions = conversionRepository.getConversions()
        val conversion = conversions.find { c -> c.from == source.id && c.to == destination.id }

        return conversion ?: getChainedConversions(source, destination)
    }

    private fun getChainedConversions(source: Metric, destination: Metric) : Conversion {
        val conversions = conversionRepository.getConversions()

        val edges = arrayListOf<Edge>()
        conversions.forEach { conversion ->
            edges.add(Edge(Vertex(conversion.from.id), Vertex(conversion.to.id), 1))
        }

        val graph = Graph(edges)
        val dijkstra = DijkstraAlgorithm(graph)
        val sourceVertex = dijkstra.execute(Vertex(source.id.id))
        val path = sourceVertex.getPath(Vertex(destination.id.id))

        val chainedConversions = arrayListOf<Conversion>()
        var index = 1
        var start = parseInt((path[0].payload.toString()))
        var end: Int

        do
        {
            end = parseInt((path[index].payload.toString()))

            var conversion = conversions.first { conv -> conv.from.id == start && conv.to.id == end }
            chainedConversions.add(conversion)

            index++;
            start = end;
        } while (chainedConversions.last().to.id != destination.id.id)

        return ComposedConversion(source.id, destination.id, chainedConversions)
    }

    fun calculate(amount: Double, product: Product, source: Metric, destination: Metric) : Double {
        if (source.id == destination.id) { return amount }

        val conversion = getConversion(source, destination)

        return conversion.calculate(amount, product)
    }
}

Viewmodel

class CalculatorViewmodel(private val metricsService: MetricsService) {
    var product: Product? = null
    var amount: Double? = null
    var sourceMetric: Metric? = null
    val results: ArrayList<CalculationResult> = arrayListOf()

    fun getMetrics() : List<Metric> = metricsService.getMetrics()
    fun getProducts() : List<Product> = listOf(Product.Generic).plus(metricsService.getProducts().toList())

    fun calculate() {
        if (product == null || amount == null || sourceMetric == null) {
            return
        }

        Answers.getInstance().logCustom(
            CustomEvent("MetricConversion")
                .putCustomAttribute("From", sourceMetric!!.name)
                .putCustomAttribute("Product", product!!.name)
                .putCustomAttribute("Amount", amount.toString()))

        val destinationMetrics = metricsService.getMetrics()

        for(destinationMetric in destinationMetrics)
        {
            val result = metricsService.calculate(amount!!, product!!, sourceMetric!!, destinationMetric);
            results.add(CalculationResult(result, destinationMetric))
        }
    }
}

Activity

class MainActivity : AppCompatActivity(), OnItemSelectedListener, TextWatcher, View.OnClickListener {
    private val viewmodel: CalculatorViewmodel = CalculatorViewmodel(
        MetricsService(
            MetricsRepository() ,
            ConversionRepository(),
            ProductRepository()
        )
    )

    private lateinit var sourceMetrics: Spinner
    private lateinit var products: Spinner
    private lateinit var table: TableLayout

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Fabric.with(this, Crashlytics())
        Fabric.with(this, Answers())
        setContentView(R.layout.activity_main)

        sourceMetrics = findViewById(R.id.sourceMetric)
        products = findViewById(R.id.product)
        table = findViewById(R.id.resultsTable)
        table.removeAllViews()

        sourceMetrics.onItemSelectedListener = this
        products.onItemSelectedListener = this
        findViewById<EditText>(R.id.quantity).addTextChangedListener(this)
        findViewById<Button>(R.id.calculateButton).setOnClickListener(this)

        loadMetrics()
        loadProducts()
    }

    private fun loadMetrics() {
        val adapter = ArrayAdapter(this, R.layout.spinner_item, viewmodel.getMetrics().map { metric -> metric.name })
        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
        sourceMetrics.adapter = adapter
    }

    private fun loadProducts() {
        val adapter = ArrayAdapter(this, R.layout.spinner_item, viewmodel.getProducts().map { product -> product.name })
        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
        products.adapter = adapter
    }

    override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
        reset()

        if (parent == sourceMetrics) {
            val metric = viewmodel.getMetrics().first { metric -> metric.name == sourceMetrics.getItemAtPosition(position)}
            viewmodel.sourceMetric = metric
        } else if (parent == products) {
            val product = viewmodel.getProducts().first { product -> product.name == products.getItemAtPosition(position)}
            viewmodel.product = product
        }
    }

    override fun onNothingSelected(parent: AdapterView<*>?) {

    }

    override fun afterTextChanged(p0: Editable?) {
    }

    override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        reset()
    }

    override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        if (p0 == null || p0.isEmpty()) {
            viewmodel.amount = 0.0
        } else {
            viewmodel.amount = parseDouble(p0.toString())
        }
    }

    override fun onClick(v: View?) {
        recalculate()
    }

    private fun reset() {
        table.visibility = View.INVISIBLE
    }

    private fun recalculate() {
        viewmodel.calculate()

        viewmodel.results.forEach { result ->
            var row = table.findViewWithTag<TableRow>(result.metric.id.id)
            if (row == null) {
                row = createRow(result)
                table.addView(row)
            } else {
                val viewGroup = row as ViewGroup
                val valueText = viewGroup.getChildAt(0) as TextView
                valueText.text = result.displayAmount
            }
        }
        hideKeyboard(this)
        table.visibility = View.VISIBLE
    }

    private fun createRow(calculationResult: CalculationResult) : TableRow {
        val row = TableRow(this)
        row.tag = calculationResult.metric.id.id
        val rowLayout = TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.MATCH_PARENT)
        rowLayout.topMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,5f, this.resources.displayMetrics).toInt()
        row.layoutParams = rowLayout

        val valueText = TextView(this)
        valueText.text = calculationResult.displayAmount
        valueText.setTypeface(valueText.typeface, Typeface.BOLD)
        valueText.setTextColor(Color.BLACK)
        valueText.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f);
        valueText.layoutParams = TableRow.LayoutParams(22, TableRow.LayoutParams.WRAP_CONTENT, 2f)
        val paddingPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,5f, this.resources.displayMetrics).toInt()
        valueText.setPadding(paddingPx, 0, 0, 0)

        val labelText = TextView(this)
        labelText.text = calculationResult.metric.name
        labelText.layoutParams = TableRow.LayoutParams(TableRow.LayoutParams.WRAP_CONTENT, TableRow.LayoutParams.WRAP_CONTENT, 3f)
        valueText.setTextColor(Color.BLACK)

        row.addView(valueText)
        row.addView(labelText)

        return row
    }

    // https://stackoverflow.com/a/17789187/1864167
    private fun hideKeyboard(activity: Activity) {
        val imm = activity.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager

        //Find the currently focused view, so we can grab the correct window token from it.
        var view = activity.currentFocus

        //If no view currently has focus, create a new one, just so we can grab a window token from it
        if (view == null) {
            view = View(activity)
        }

        view.clearFocus()
        imm.hideSoftInputFromWindow(view.windowToken, 0)
    }
}

Unit test(s):

@RunWith(Parameterized::class)
class CalculationTests(private val amount: Double, private val fromUnit: MetricUnit, private val toUnit: MetricUnit, private val expected: Double) {

    private val service = MetricsService(MetricsRepository(), ConversionRepository(), ProductRepository())

    companion object {
        @JvmStatic
        @Parameterized.Parameters
        fun data() = listOf(
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.Milliliter, 100.0),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.Kilogram, 0.100),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.Liter, 0.100),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.MetricCups, 0.400),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.USNutritionalCups, 0.4166666667),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.UsRecipeCups, 0.4226721332),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.MetricTeaspoons, 20.0),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.UsTeaspoons, 20.2884136211),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.MetricTablespoons, 6.6666666667),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.UsTablespoons, 6.7628045404),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.Pounds, 0.220462442),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.Ounces, 3.5278470608),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.UsFluidOunces, 3.3814022702),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.UKPint, 0.1706484642),
            arrayOf(100.0, MetricUnit.Grams, MetricUnit.USPint, 0.2114164905)
        )
    }

    @Test
    fun shouldCalculateTheCorrectConversion() {
        val from = service.getMetric(fromUnit)
        val to = service.getMetric(toUnit)

        val result = service.calculate(amount, Product.Generic, from, to)
        val formattedResult = java.lang.Double.valueOf(DecimalFormat("#.##########", DecimalFormatSymbols(Locale.UK)).format(result))

        assertThat(expected, equalTo(formattedResult))
    }
\$\endgroup\$
  • \$\begingroup\$ Did you write any unit tests? It would help a lot if you included these, too. \$\endgroup\$ – Roland Illig Apr 27 at 13:39
  • \$\begingroup\$ @RolandIllig I've added the one unit tests I've written to verify conversions are correct though I don't think it will help much. The other classes I've left out are simple data classes. Do you have any specific concerns about confusing parts? \$\endgroup\$ – Jeroen Vannevel Apr 27 at 13:43
  • \$\begingroup\$ No, no specific concerns. I just thought reading the unit tests would be a nice starting point for understanding the code. \$\endgroup\$ – Roland Illig Apr 27 at 17:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.