Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ fun distanceInKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double
sin(deg2rad(lat1)) * sin(deg2rad(lat2)) + cos(deg2rad(lat1)) * cos(deg2rad(lat2)) * cos(
deg2rad(theta)
)
dist = acos(dist)
// Rounding can push identical points just past 1, where acos is NaN.
dist = acos(dist.coerceIn(-1.0, 1.0))
dist = rad2deg(dist)
dist *= 60 * 1.1515
dist *= 1.609344
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ class LocationUtilsTest {
assertEquals(0.0, distance, 0.001)
}

@Test
fun `distanceInKm is zero, not NaN, where rounding pushes the cosine past 1`() {
// Same point at latitude 12: the floating-point sum comes out just above 1.0.
val distance = distanceInKm(12.0, 27.0, 12.0, 27.0)

assertEquals(0.0, distance, 0.001)
}

@Test
fun `distanceInKm matches the known great-circle distance between two cities`() {
// Minsk to Grodno, roughly 260 km apart in a straight line.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,15 @@ internal class FakeStationsRepository constructor(
}

private fun List<StationResource>.applySorting(query: StationResourceQuery): List<StationResource> {
// Mirrors StationResourceDao: DISTANCE is sorted in memory, so the database orders by title.
return when (query.sortingType) {
StationSortingType.STATION_NAME -> {
if (query.sortingOrder == SortingOrder.ASC) {
this.sortedBy { it.title }
} else {
this.sortedByDescending { it.title }
}
StationSortingType.STATION_NAME,
StationSortingType.DISTANCE,
-> if (query.sortingOrder == SortingOrder.ASC) {
this.sortedBy { it.title }
} else {
this.sortedByDescending { it.title }
}

else -> this
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,70 @@ class CompositeStationResourcesWithFavoritesRepositoryTest {

// endregion

// region observeAll - distance sorting

@Test
fun `DISTANCE ASC puts the nearest station first`() = runTest {
userDataFlow.value = userData(sortingOrder = SortingOrder.ASC, sortingType = StationSortingType.DISTANCE)
stationsRepository.emit(listOf(station("far", lat = "12.0"), station("near", lat = "10.1"), station("mid", lat = "11.0")))
locationsRepository.emit(location(lat = 10.0, lon = 27.0))

val result = repository.observeAll(StationResourceQuery()).first()

assertEquals(listOf("near", "mid", "far"), result.map { it.code })
}

@Test
fun `DISTANCE DESC puts the farthest station first`() = runTest {
userDataFlow.value = userData(sortingOrder = SortingOrder.DESC, sortingType = StationSortingType.DISTANCE)
stationsRepository.emit(listOf(station("near", lat = "10.1"), station("far", lat = "12.0"), station("mid", lat = "11.0")))
locationsRepository.emit(location(lat = 10.0, lon = 27.0))

val result = repository.observeAll(StationResourceQuery()).first()

assertEquals(listOf("far", "mid", "near"), result.map { it.code })
}

@Test
fun `DISTANCE keeps the database order while the location is unknown`() = runTest {
userDataFlow.value = userData(sortingOrder = SortingOrder.ASC, sortingType = StationSortingType.DISTANCE)
stationsRepository.emit(listOf(station("b", lat = "12.0"), station("a", lat = "10.1")))
locationsRepository.emit(null)

val result = repository.observeAll(StationResourceQuery()).first()

assertEquals(listOf("b", "a"), result.map { it.code })
}

@Test
fun `STATION_NAME keeps the database order even when distances are known`() = runTest {
userDataFlow.value = userData(sortingOrder = SortingOrder.ASC, sortingType = StationSortingType.STATION_NAME)
stationsRepository.emit(listOf(station("far", lat = "12.0"), station("near", lat = "10.1")))
locationsRepository.emit(location(lat = 10.0, lon = 27.0))

val result = repository.observeAll(StationResourceQuery()).first()

assertEquals(listOf("far", "near"), result.map { it.code })
}

@Test
fun `an active subscriber sees the list re-sorted when the location changes`() = runTest {
userDataFlow.value = userData(sortingOrder = SortingOrder.ASC, sortingType = StationSortingType.DISTANCE)
stationsRepository.emit(listOf(station("north", lat = "12.0"), station("south", lat = "10.0")))
locationsRepository.emit(location(lat = 10.0, lon = 27.0))

repository.observeAll(StationResourceQuery()).test {
assertEquals(listOf("south", "north"), awaitItem().map { it.code })

locationsRepository.emit(location(lat = 12.0, lon = 27.0))

assertEquals(listOf("north", "south"), awaitItem().map { it.code })
cancelAndIgnoreRemainingEvents()
}
}

// endregion

// region observeAll - reactivity

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.ngapp.metanmobile.core.data.repository.user.UserDataRepository
import com.ngapp.metanmobile.core.model.station.UserStationResource
import com.ngapp.metanmobile.core.model.station.mapToUserStationResources
import com.ngapp.metanmobile.core.model.userdata.SortingOrder
import com.ngapp.metanmobile.core.model.userdata.StationSortingType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
Expand Down Expand Up @@ -53,7 +54,7 @@ class CompositeStationResourcesWithFavoritesRepository(

val locationFlow = locationsRepository.getLocationResource()

val stationWithDistanceFlow = stationResourcesFlow.flatMapLatest { stationResources ->
stationResourcesFlow.flatMapLatest { stationResources ->
locationFlow.map { location ->
stationResources.mapToUserStationResources(userData).map { userStation ->
// null location = we don't know the user's position yet β€” leave
Expand All @@ -67,15 +68,26 @@ class CompositeStationResourcesWithFavoritesRepository(
)
}
userStation.copy(distanceBetween = distanceBetween)
}
}.sortedByConfig(sortingOrderQuery.sortingType, userData.stationSortingConfig.sortingOrder)
}
}
}
}

// val sortedStationsFlow = when (sortingOrderQuery.sortingType) {
// StationSortingType.DISTANCE -> stationWithDistanceFlow.map { it.sortedBy { it.distanceBetween } }
// StationSortingType.STATION_NAME -> stationWithDistanceFlow.map { it.sortedBy { it.title } }
// }
stationWithDistanceFlow
/**
* The database already orders by name; distance depends on the user's position, so it is
* sorted here. Stations without a known distance go last, keeping their name order.
*/
private fun List<UserStationResource>.sortedByConfig(
sortingType: StationSortingType,
sortingOrder: SortingOrder,
): List<UserStationResource> = when (sortingType) {
StationSortingType.STATION_NAME -> this
StationSortingType.DISTANCE -> {
val (known, unknown) = partition { it.distanceBetween != null }
val byDistance = known.sortedBy { it.distanceBetween }
val ordered = if (sortingOrder == SortingOrder.DESC) byDistance.reversed() else byDistance
ordered + unknown
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ class StationResourceDaoTest {
assertEquals(listOf("Beta", "Alpha"), result.map { it.title })
}

@Test
fun `DISTANCE sorting falls back to title order (distance itself is sorted in memory)`() = runTest {
dao.upsertStationResources(listOf(station(code = "b", title = "Beta"), station(code = "a", title = "Alpha")))

val result = dao.getStationResourcesAsc(sortingType = "DISTANCE").first()

assertEquals(listOf("Alpha", "Beta"), result.map { it.title })
}

private fun station(code: String, title: String) = StationResourceEntity(
id = code,
code = code,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ interface StationResourceDao {
ORDER BY
CASE
WHEN :sortingType = 'STATION_NAME' THEN title
ELSE NULL
-- DISTANCE is sorted in memory (it needs the user's position); the title keeps
-- stations with an unknown distance in a stable order.
ELSE title
END
DESC
""",
Expand Down Expand Up @@ -86,7 +88,9 @@ interface StationResourceDao {
ORDER BY
CASE
WHEN :sortingType = 'STATION_NAME' THEN title
ELSE NULL
-- DISTANCE is sorted in memory (it needs the user's position); the title keeps
-- stations with an unknown distance in a stable order.
ELSE title
END
ASC
""",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ internal fun StationSortingConfig.toProto() = StationSortingConfigProto(
)

private fun StationsSortingTypeProto.toModel() = when (this) {
StationsSortingTypeProto.DISTANCE,
StationsSortingTypeProto.STATION_NAME,
-> StationSortingType.STATION_NAME
StationsSortingTypeProto.DISTANCE -> StationSortingType.DISTANCE
StationsSortingTypeProto.STATION_NAME -> StationSortingType.STATION_NAME
}

private fun StationSortingType.toProto() = when (this) {
StationSortingType.DISTANCE -> StationsSortingTypeProto.DISTANCE
StationSortingType.STATION_NAME -> StationsSortingTypeProto.STATION_NAME
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,9 @@ private fun SearchField(
value = searchText,
onValueChange = onSearchTextChanged,
singleLine = true,
textStyle = MMTypography.bodyLarge.copy(color = MaterialTheme.colorScheme.onBackground),
// Typography already carries the theme's text color; colorScheme.onBackground is the
// card/background color in this palette (see Theme.kt), so text drawn with it vanished.
textStyle = MMTypography.bodyLarge,
cursorBrush = SolidColor(Blue),
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ package com.ngapp.metanmobile.core.model.userdata

enum class StationSortingType {
STATION_NAME,
// DISTANCE,
DISTANCE,
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,13 @@ private fun AdMobNativeBanner(slotKey: String, style: NativeBannerStyle, modifie
setImageScaleType(ImageView.ScaleType.CENTER_CROP)
}
val views = NativeBannerViews.create(viewContext, style, mediaView)
// AdMob validates the registered MediaView: it must stay visible and at least
// 120x120dp, so the thumbnail is always the media and the icon goes to the meta line.
views.icon.visibility = View.GONE
adView.addView(views.root)
adView.headlineView = views.title
adView.bodyView = views.description
adView.iconView = views.icon
adView.iconView = views.favicon
adView.callToActionView = views.callToAction
adView.advertiserView = views.domain
adView.mediaView = mediaView
Expand All @@ -154,17 +157,12 @@ private fun AdMobNativeBanner(slotKey: String, style: NativeBannerStyle, modifie
views.callToAction.setTextOrGone(ad.callToAction)
views.domain.setTextOrGone(ad.advertiser)
views.sponsored.text = style.adLabel
// The thumbnail shows the ad's icon at the list rows' own size; ads without an icon
// show their media there instead, at the 120dp minimum MediaView size.
val icon = ad.icon?.drawable
views.icon.setImageDrawable(icon)
views.icon.visibility = if (icon != null) View.VISIBLE else View.GONE
views.media.visibility = if (icon != null) View.GONE else View.VISIBLE
views.setMediaSized(icon == null)
views.favicon.setImageDrawable(icon)
views.favicon.visibility = if (icon != null) View.VISIBLE else View.GONE
views.age.visibility = View.GONE
views.warning.visibility = View.GONE
views.feedback.visibility = View.GONE
views.favicon.visibility = View.GONE
adView.setNativeAd(ad)
},
)
Expand Down Expand Up @@ -202,8 +200,6 @@ private fun YandexNativeBanner(slotKey: String, style: NativeBannerStyle, modifi
factory = { viewContext ->
val adView = YandexNativeAdView(viewContext)
val views = NativeBannerViews.create(viewContext, style, YandexMediaView(viewContext))
// Any Yandex ad may carry media, so the thumbnail always has media size.
views.setMediaSized(true)
adView.addView(views.root)
adView.tag = views
adView
Expand Down Expand Up @@ -250,15 +246,12 @@ private class NativeBannerStyle(
/**
* The NativeBanner row, built in code (neither SDK ships a ready template in the versions we use)
* to match NewsRow / StationRow: the same asymmetric-rounded thumbnail on the left, title
* (+ description) and a meta line with the "Ad" label, advertiser and the call-to-action. With an
* icon it's exactly a list row's size; showing media it grows to fit a 120x120dp thumbnail - the
* minimum media size the ad networks allow - and uses the extra height for the ad's text.
* (+ description) and a meta line with the "Ad" label, advertiser and the call-to-action. The
* thumbnail is the ad's media at 120x120dp - the minimum media size the ad networks allow - so the
* row is taller than a list row and uses the extra height for the ad's text.
*/
private class NativeBannerViews(
val root: LinearLayout,
val thumbnail: FrameLayout,
private val isStation: Boolean,
private val density: Float,
val icon: ImageView,
val media: View,
val favicon: ImageView,
Expand All @@ -271,18 +264,6 @@ private class NativeBannerViews(
val callToAction: TextView,
val warning: TextView,
) {
fun setMediaSized(mediaSized: Boolean) {
fun dp(value: Int) = (value * density).toInt()
thumbnail.layoutParams = (thumbnail.layoutParams as LinearLayout.LayoutParams).apply {
width = if (mediaSized) dp(120) else dp(74)
height = if (mediaSized) dp(120) else dp(66)
marginEnd = if (mediaSized) dp(12) else dp(8)
}
description.maxLines = if (mediaSized) 2 else 1
// News rows only have room for the ad text once the thumbnail makes the row taller.
if (!isStation && !mediaSized) description.visibility = View.GONE
}

companion object {
fun create(context: Context, style: NativeBannerStyle, media: View): NativeBannerViews {
fun dp(value: Int) = TypedValue.applyDimension(
Expand Down Expand Up @@ -319,7 +300,7 @@ private class NativeBannerViews(
thumbnail.addView(icon, FrameLayout.LayoutParams(MATCH, MATCH))

val title = text(style.titleSizeSp, style.titleColor, bold = true, lines = if (isStation) 1 else 2)
val description = text(style.descriptionSizeSp, style.descriptionColor)
val description = text(style.descriptionSizeSp, style.descriptionColor, lines = 2)
val sponsored = text(style.metaSizeSp, style.onAccent).apply {
background = GradientDrawable().apply {
cornerRadius = dp(4).toFloat()
Expand Down Expand Up @@ -357,10 +338,10 @@ private class NativeBannerViews(
setBackgroundColor(style.background)
setPadding(dp(16), 0, dp(12), 0)
minimumHeight = dp(82)
addView(thumbnail, LinearLayout.LayoutParams(dp(74), dp(66)).apply {
addView(thumbnail, LinearLayout.LayoutParams(dp(120), dp(120)).apply {
topMargin = dp(8)
bottomMargin = dp(8)
marginEnd = dp(8)
marginEnd = dp(12)
})
addView(texts, LinearLayout.LayoutParams(0, WRAP, 1f))
// Yandex requires every icon (the feedback/close control included) to be at least
Expand All @@ -372,7 +353,7 @@ private class NativeBannerViews(
})
}
return NativeBannerViews(
root, thumbnail, isStation, context.resources.displayMetrics.density, icon, media, favicon, feedback, title, description, sponsored, domain, age,
root, icon, media, favicon, feedback, title, description, sponsored, domain, age,
callToAction, warning,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ private fun SortTypesRadioGroup(
@Composable
private fun getSortingTypeText(sortingType: StationSortingType): String {
return when (sortingType) {
// StationSortingType.DISTANCE -> stringResource(SharedRes.strings.core_ui_button_distance)
StationSortingType.DISTANCE -> stringResource(SharedRes.strings.core_ui_button_distance)
StationSortingType.STATION_NAME -> stringResource(SharedRes.strings.core_ui_button_name)
}
}
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ androidMinSdk = "24"
androidTargetSdk = "36"
versionMajor = "2"
versionMinor = "4"
versionPatch = "0"
versionPatch = "1"
applicationId = "com.ngapp.metanmobile"

accompanist = "0.37.3"
Expand Down
Loading
Loading