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))
}