Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Android: ArrivalView, supporting formatters and updated testing #134

Merged
merged 18 commits into from
Jul 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ jobs:
run: ./gradlew test
working-directory: android

- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
if: success() || failure()
with:
name: test-reports
path: |
android/**/build/reports
retention-days: 5

verify-snapshots:

runs-on: macos-13
Expand Down Expand Up @@ -157,9 +166,12 @@ jobs:

- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
if: success() || failure()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, interesting... I think the intent here is more closely matched by "not cancelled?" If so, I'm not quite sure why the job would be running anyways ; shouldn't cancellation stop all other jobs in the workflow? Or does GH Actions work differently? ;)

with:
name: paparazzi-report.html
path: android/composeui/build/reports/paparazzi/debug/index.html
name: snapshot-reports
path: |
android/**/build/reports
android/**/build/paparazzi
retention-days: 5

connected-check:
Expand Down Expand Up @@ -203,4 +215,13 @@ jobs:
arch: x86
target: aosp_atd
script: ./gradlew connectedCheck
working-directory: android
working-directory: android

- name: 'Upload Artifact'
uses: actions/upload-artifact@v4
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

if: success() || failure()
with:
name: connected-reports
path: |
android/**/build/reports
retention-days: 5
12 changes: 7 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,18 +172,20 @@ Run unit tests as usual from within Xcode.

### Android

At the moment, we need to use Android tests,
but want to remove this requirement in the future as it is extremely expensive.
So, the recommended way to run all tests is `./gradlew connectedCheck`.
Android uses both tests and androidTests (connectedChecks) to verify functionality. Included in normal unit tests are paparazzi snapshot tests for UI components.

#### Recording Snapshots

Run the gradle task `recordPaparazziDebug`. This can be done from the gradle menu under `verification`.

## Code Conventions

* Format all Rust code using `cargo fmt`
* Run `cargo clippy` and either fix any warnings or document clearly why you think the linter should be ignored
* All iOS code must be written in Swift
* TODO: Swiftlint and swift-format?
* SwiftFormat is used to automatically format all swift code. This must be run manually from within the project folder using the command line tool `swiftformat .`. For more information on installation see [SwiftFormat/Installation](https://github.com/nicklockwood/SwiftFormat?tab=readme-ov-file#how-do-i-install-it)
* All Android code must be written in Kotlin
* TODO: ktlint
* ktfmt is used to automatically format all kotlin code. This can be run using the `ktfmtFormat` step under `formatting` in the gradle menu.

## Changelog Conventions

Expand Down
1 change: 1 addition & 0 deletions android/composeui/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies {

implementation 'androidx.core:core-ktx:1.13.1'
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.9.24')
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.6.0'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.activity:activity-compose:1.9.0'
implementation platform('androidx.compose:compose-bom:2024.06.00')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.stadiamaps.ferrostar.composeui.formatting

import android.icu.util.ULocale
import java.util.Locale
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toJavaLocalDateTime

interface DateTimeFormatter {
fun format(dateTime: LocalDateTime): String
}

class EstimatedArrivalDateTimeFormatter(
private var localeOverride: ULocale? = null,
) : DateTimeFormatter {
override fun format(dateTime: LocalDateTime): String {
val locale = localeOverride?.let { Locale(it.language, it.country) } ?: Locale.getDefault()
val formatter =
java.time.format.DateTimeFormatter.ofLocalizedTime(java.time.format.FormatStyle.SHORT)
.withLocale(locale)
return formatter.format(dateTime.toJavaLocalDateTime())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.stadiamaps.ferrostar.composeui.formatting

import kotlin.time.DurationUnit

enum class UnitStyle {
SHORT,
LONG
}

fun interface DurationFormatter {
/** Formats a duration in seconds into a human-readable string. */
fun format(durationSeconds: Double): String
}

class LocalizedDurationFormatter(
private val units: List<DurationUnit> = listOf(DurationUnit.HOURS, DurationUnit.MINUTES),
private val unitStyle: UnitStyle = UnitStyle.SHORT
) : DurationFormatter {

private fun calculate(durationSeconds: Double): Map<DurationUnit, Int> {
var remainingDuration = durationSeconds
val result: MutableMap<DurationUnit, Int> = mutableMapOf()

if (units.contains(DurationUnit.NANOSECONDS) ||
units.contains(DurationUnit.MICROSECONDS) ||
units.contains(DurationUnit.MILLISECONDS)) {
throw IllegalArgumentException("Unsupported duration unit")
}

// Extract the days from the duration
if (units.contains(DurationUnit.DAYS)) {
val days = (remainingDuration / (24 * 60 * 60)).toInt()
remainingDuration %= (24 * 60 * 60)
result += DurationUnit.DAYS to days
}

// Extract the hours from the duration
if (units.contains(DurationUnit.HOURS)) {
val hours = (remainingDuration / (60 * 60)).toInt()
remainingDuration %= (60 * 60)
result += DurationUnit.HOURS to hours
}

// Extract the minutes from the duration
if (units.contains(DurationUnit.MINUTES)) {
val minutes = (remainingDuration / 60).toInt()
remainingDuration %= 60
result += DurationUnit.MINUTES to minutes
}

// Extract the seconds from the duration
if (units.contains(DurationUnit.SECONDS)) {
result += DurationUnit.SECONDS to remainingDuration.toInt()
}

// Return a map of the non-null values and their corresponding units
return result
}

// TODO: Localize the unit strings
private fun getUnitString(unit: DurationUnit, value: Int): String {
val plural = if (value != 1) "s" else ""

return when (unitStyle) {
UnitStyle.SHORT ->
when (unit) {
DurationUnit.SECONDS -> "s"
DurationUnit.MINUTES -> "m"
DurationUnit.HOURS -> "h"
DurationUnit.DAYS -> "d"
else -> ""
}
UnitStyle.LONG ->
when (unit) {
DurationUnit.SECONDS -> " second$plural"
DurationUnit.MINUTES -> " minute$plural"
DurationUnit.HOURS -> " hour$plural"
DurationUnit.DAYS -> " day$plural"
else -> " "
}
}
}

override fun format(durationSeconds: Double): String {
val durationMap = calculate(durationSeconds)

return durationMap.entries
.filter { it.value > 0 }
.joinToString(separator = " ") { "${it.value}${getUnitString(it.key, it.value)}" }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.stadiamaps.ferrostar.composeui.formatting

interface FormatterCollection {

/** The formatter for the distance to the next step. */
val distanceFormatter: DistanceFormatter

/** The formatter for the estimated arrival date and time. */
val estimatedArrivalFormatter: DateTimeFormatter

/** The formatter for the remaining duration. */
val durationFormatter: DurationFormatter
}

/** TODO: add description and consider naming for android. */
data class StandardFormatterCollection(
override val distanceFormatter: DistanceFormatter = LocalizedDistanceFormatter(),
override val estimatedArrivalFormatter: DateTimeFormatter = EstimatedArrivalDateTimeFormatter(),
override val durationFormatter: DurationFormatter = LocalizedDurationFormatter()
) : FormatterCollection
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.stadiamaps.ferrostar.composeui.theme

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight

enum class ArrivalViewStyle {
/** A simple arrival view with only values. */
SIMPLIFIED,
/** An arrival view with label captions in addition to values. */
INFORMATIONAL
}

/** Themes for arrival view components */
interface ArrivalViewTheme {
/** The text style for the step distance (or distance to step). */
@get:Composable val style: ArrivalViewStyle
/** The color for the measurement/value. */
@get:Composable val measurementColor: Color
/** The text style for the measurement/value. */
@get:Composable val measurementTextStyle: TextStyle
/** The color for the secondary content (label caption). */
@get:Composable val secondaryColor: Color
/** The text style for the secondary content (label caption). */
@get:Composable val secondaryTextStyle: TextStyle
/** The exit button icon color. */
@get:Composable val exitIconColor: Color
/** The exit button background color. */
@get:Composable val exitButtonBackgroundColor: Color
/** The background color for the view. */
@get:Composable val backgroundColor: Color
}

object DefaultArrivalViewTheme : ArrivalViewTheme {
override val style: ArrivalViewStyle
@Composable get() = ArrivalViewStyle.SIMPLIFIED

override val measurementColor: Color
@Composable get() = MaterialTheme.colorScheme.onBackground

override val measurementTextStyle: TextStyle
@Composable get() = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)

override val secondaryColor: Color
@Composable get() = MaterialTheme.colorScheme.secondary

override val secondaryTextStyle: TextStyle
@Composable get() = MaterialTheme.typography.labelSmall

override val exitIconColor: Color
@Composable get() = MaterialTheme.colorScheme.onSecondary

override val exitButtonBackgroundColor: Color
@Composable get() = MaterialTheme.colorScheme.secondary

override val backgroundColor: Color
@Composable get() = MaterialTheme.colorScheme.background
}
Loading
Loading