From 86f7761c971f738caedc239f577846f3ebd5ecbd Mon Sep 17 00:00:00 2001 From: ngapps Date: Sat, 3 Oct 2026 00:46:26 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20bring=20back=20sortin?= =?UTF-8?q?g=20stations=20by=20distance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Distance" is selectable again in the stations sort dialog and is stored in preferences (it was mapped to "Name"). - The repository sorts by distance in memory, since only it knows the user's position; ascending/descending both work, the list re-sorts as the location changes, and stations with an unknown distance go last. - The DAO orders by title for DISTANCE too, so the list stays stable while the location is unknown (it was ordered by NULL). Fix: distanceInKm returned NaN when the user stands exactly at a station (rounding pushed the cosine past 1). Tests: distance sorting (asc, desc, unknown location, re-sort on a location change), DAO fallback order, the NaN case. --- .../core/common/util/LocationUtils.kt | 3 +- .../core/common/util/LocationUtilsTest.kt | 8 +++ .../test/repository/FakeStationsRepository.kt | 15 ++--- ...ionResourcesWithFavoritesRepositoryTest.kt | 64 +++++++++++++++++++ ...StationResourcesWithFavoritesRepository.kt | 26 ++++++-- .../dao/station/StationResourceDaoTest.kt | 9 +++ .../dao/station/StationResourceDao.kt | 8 ++- .../core/datastore/UserPreferencesMappings.kt | 6 +- .../core/model/userdata/StationSortingType.kt | 2 +- .../StationsSortAndFilterConfigDialog.kt | 2 +- 10 files changed, 120 insertions(+), 23 deletions(-) diff --git a/core/common/src/commonMain/kotlin/com/ngapp/metanmobile/core/common/util/LocationUtils.kt b/core/common/src/commonMain/kotlin/com/ngapp/metanmobile/core/common/util/LocationUtils.kt index dc68be5..b5a8f91 100644 --- a/core/common/src/commonMain/kotlin/com/ngapp/metanmobile/core/common/util/LocationUtils.kt +++ b/core/common/src/commonMain/kotlin/com/ngapp/metanmobile/core/common/util/LocationUtils.kt @@ -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 diff --git a/core/common/src/commonTest/kotlin/com/ngapp/metanmobile/core/common/util/LocationUtilsTest.kt b/core/common/src/commonTest/kotlin/com/ngapp/metanmobile/core/common/util/LocationUtilsTest.kt index e63a98c..e8c794a 100644 --- a/core/common/src/commonTest/kotlin/com/ngapp/metanmobile/core/common/util/LocationUtilsTest.kt +++ b/core/common/src/commonTest/kotlin/com/ngapp/metanmobile/core/common/util/LocationUtilsTest.kt @@ -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. diff --git a/core/data-test/src/main/kotlin/com/ngapp/metanmobile/core/data/test/repository/FakeStationsRepository.kt b/core/data-test/src/main/kotlin/com/ngapp/metanmobile/core/data/test/repository/FakeStationsRepository.kt index 6b44f26..bf1fa56 100644 --- a/core/data-test/src/main/kotlin/com/ngapp/metanmobile/core/data/test/repository/FakeStationsRepository.kt +++ b/core/data-test/src/main/kotlin/com/ngapp/metanmobile/core/data/test/repository/FakeStationsRepository.kt @@ -90,16 +90,15 @@ internal class FakeStationsRepository constructor( } private fun List.applySorting(query: StationResourceQuery): List { + // 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 } } } diff --git a/core/data-test/src/test/kotlin/com/ngapp/metanmobile/core/data/test/repository/station/CompositeStationResourcesWithFavoritesRepositoryTest.kt b/core/data-test/src/test/kotlin/com/ngapp/metanmobile/core/data/test/repository/station/CompositeStationResourcesWithFavoritesRepositoryTest.kt index a8d661d..77f26dc 100644 --- a/core/data-test/src/test/kotlin/com/ngapp/metanmobile/core/data/test/repository/station/CompositeStationResourcesWithFavoritesRepositoryTest.kt +++ b/core/data-test/src/test/kotlin/com/ngapp/metanmobile/core/data/test/repository/station/CompositeStationResourcesWithFavoritesRepositoryTest.kt @@ -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 diff --git a/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/station/CompositeStationResourcesWithFavoritesRepository.kt b/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/station/CompositeStationResourcesWithFavoritesRepository.kt index f34ed16..2f6b2f3 100644 --- a/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/station/CompositeStationResourcesWithFavoritesRepository.kt +++ b/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/station/CompositeStationResourcesWithFavoritesRepository.kt @@ -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 @@ -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 @@ -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.sortedByConfig( + sortingType: StationSortingType, + sortingOrder: SortingOrder, + ): List = 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 } } diff --git a/core/database/src/androidHostTest/kotlin/com/ngapp/metanmobile/core/database/dao/station/StationResourceDaoTest.kt b/core/database/src/androidHostTest/kotlin/com/ngapp/metanmobile/core/database/dao/station/StationResourceDaoTest.kt index fa6f519..8db6842 100644 --- a/core/database/src/androidHostTest/kotlin/com/ngapp/metanmobile/core/database/dao/station/StationResourceDaoTest.kt +++ b/core/database/src/androidHostTest/kotlin/com/ngapp/metanmobile/core/database/dao/station/StationResourceDaoTest.kt @@ -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, diff --git a/core/database/src/main/kotlin/com/ngapp/metanmobile/core/database/dao/station/StationResourceDao.kt b/core/database/src/main/kotlin/com/ngapp/metanmobile/core/database/dao/station/StationResourceDao.kt index 67dabd8..df0323b 100644 --- a/core/database/src/main/kotlin/com/ngapp/metanmobile/core/database/dao/station/StationResourceDao.kt +++ b/core/database/src/main/kotlin/com/ngapp/metanmobile/core/database/dao/station/StationResourceDao.kt @@ -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 """, @@ -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 """, diff --git a/core/datastore/src/commonMain/kotlin/com/ngapp/metanmobile/core/datastore/UserPreferencesMappings.kt b/core/datastore/src/commonMain/kotlin/com/ngapp/metanmobile/core/datastore/UserPreferencesMappings.kt index f0844ba..d889079 100644 --- a/core/datastore/src/commonMain/kotlin/com/ngapp/metanmobile/core/datastore/UserPreferencesMappings.kt +++ b/core/datastore/src/commonMain/kotlin/com/ngapp/metanmobile/core/datastore/UserPreferencesMappings.kt @@ -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 } diff --git a/core/model/src/commonMain/kotlin/com/ngapp/metanmobile/core/model/userdata/StationSortingType.kt b/core/model/src/commonMain/kotlin/com/ngapp/metanmobile/core/model/userdata/StationSortingType.kt index 8fc30e9..75df7ea 100644 --- a/core/model/src/commonMain/kotlin/com/ngapp/metanmobile/core/model/userdata/StationSortingType.kt +++ b/core/model/src/commonMain/kotlin/com/ngapp/metanmobile/core/model/userdata/StationSortingType.kt @@ -19,5 +19,5 @@ package com.ngapp.metanmobile.core.model.userdata enum class StationSortingType { STATION_NAME, -// DISTANCE, + DISTANCE, } \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/ngapp/metanmobile/core/ui/alertdialogs/StationsSortAndFilterConfigDialog.kt b/core/ui/src/commonMain/kotlin/com/ngapp/metanmobile/core/ui/alertdialogs/StationsSortAndFilterConfigDialog.kt index e64e1dc..2e3689a 100644 --- a/core/ui/src/commonMain/kotlin/com/ngapp/metanmobile/core/ui/alertdialogs/StationsSortAndFilterConfigDialog.kt +++ b/core/ui/src/commonMain/kotlin/com/ngapp/metanmobile/core/ui/alertdialogs/StationsSortAndFilterConfigDialog.kt @@ -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) } } \ No newline at end of file From 1de8ee889be5693ad636875deae4a9a4bfb53532 Mon Sep 17 00:00:00 2001 From: ngapps Date: Sat, 3 Oct 2026 00:46:26 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20invisible=20text=20in?= =?UTF-8?q?=20the=20search=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search field drew its text with colorScheme.onBackground, which in this palette is the background/card color (white in light theme, dark in dark), so typed text vanished in both themes. It now uses the typography's text color. --- .../metanmobile/core/designsystem/component/TopAppBar.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/designsystem/src/commonMain/kotlin/com/ngapp/metanmobile/core/designsystem/component/TopAppBar.kt b/core/designsystem/src/commonMain/kotlin/com/ngapp/metanmobile/core/designsystem/component/TopAppBar.kt index 520c45f..cf9bf59 100644 --- a/core/designsystem/src/commonMain/kotlin/com/ngapp/metanmobile/core/designsystem/component/TopAppBar.kt +++ b/core/designsystem/src/commonMain/kotlin/com/ngapp/metanmobile/core/designsystem/component/TopAppBar.kt @@ -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 = { From 3692adf9d3af28b8bcbbab976032ea78bccc019f Mon Sep 17 00:00:00 2001 From: ngapps Date: Sat, 3 Oct 2026 00:46:26 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20AdMob=20native=20ad?= =?UTF-8?q?=20media=20view=20under=20120x120?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an AdMob ad had an icon, the row showed the icon in a 74x66 thumbnail and hid the registered MediaView, which the AdMob validator reports as 0x0 ("media views smaller than 120x120 will be demonetized"). The thumbnail is now always the ad's media at 120x120 (as with Yandex), and the icon moves to the small slot next to the "Ad" label. Android and iOS. --- .../core/ui/ads/NativeBanner.android.kt | 45 ++++++------------- iosApp/MetanMobileApp/MobileAdsBridge.swift | 38 ++++++++-------- 2 files changed, 31 insertions(+), 52 deletions(-) diff --git a/core/ui/src/androidMain/kotlin/com/ngapp/metanmobile/core/ui/ads/NativeBanner.android.kt b/core/ui/src/androidMain/kotlin/com/ngapp/metanmobile/core/ui/ads/NativeBanner.android.kt index 012a7fe..2d6b8c4 100644 --- a/core/ui/src/androidMain/kotlin/com/ngapp/metanmobile/core/ui/ads/NativeBanner.android.kt +++ b/core/ui/src/androidMain/kotlin/com/ngapp/metanmobile/core/ui/ads/NativeBanner.android.kt @@ -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 @@ -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) }, ) @@ -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 @@ -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, @@ -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( @@ -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() @@ -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 @@ -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, ) } diff --git a/iosApp/MetanMobileApp/MobileAdsBridge.swift b/iosApp/MetanMobileApp/MobileAdsBridge.swift index f47194e..2e06211 100644 --- a/iosApp/MetanMobileApp/MobileAdsBridge.swift +++ b/iosApp/MetanMobileApp/MobileAdsBridge.swift @@ -339,8 +339,8 @@ private extension UIView { /// The NativeBanner row, matching the app's NewsRow / StationRow: thumbnail on the left (the /// icon gets the rows' asymmetric-rounded image shape; media stays square-cornered so the creative /// and its video controls are never clipped), title (+ description) and a meta line with the "Ad" -/// label, advertiser and call-to-action. With an icon it's a list row's size (82pt); showing media -/// it grows to fit a 120x120pt thumbnail, the minimum media size. +/// label, advertiser and call-to-action. The thumbnail is the ad's media at 120x120pt, the minimum +/// media size the ad networks allow. @MainActor private enum NativeBannerRow { struct Built { @@ -367,28 +367,29 @@ private enum NativeBannerRow { let media = GoogleMobileAds.MediaView() media.contentMode = .scaleAspectFill let parts = Parts() - let hasIcon = ad.icon?.image != nil - let height = layout(in: adView, parts: parts, media: media, style: style, mediaSized: !hasIcon, withFeedback: false) + let height = layout(in: adView, parts: parts, media: media, style: style, withFeedback: false) parts.title.text = ad.headline parts.description.text = ad.body - parts.description.isHidden = ad.body?.isEmpty ?? true || (!style.isStationLayout && hasIcon) + parts.description.isHidden = ad.body?.isEmpty ?? true parts.domain.text = ad.advertiser parts.sponsored.text = style.adLabel parts.callToAction.setTitle(ad.callToAction, for: .normal) parts.callToAction.isHidden = ad.callToAction == nil // The native ad view handles clicks itself. parts.callToAction.isUserInteractionEnabled = false - parts.icon.image = ad.icon?.image - parts.icon.isHidden = !hasIcon - media.isHidden = hasIcon - [parts.age, parts.warning, parts.favicon, parts.feedback].forEach { $0.isHidden = true } + // AdMob validates the registered MediaView: it must stay visible and at least 120x120pt, + // so the thumbnail is always the media and the icon goes to the meta line. + parts.icon.isHidden = true + parts.favicon.image = ad.icon?.image + parts.favicon.isHidden = ad.icon?.image == nil + [parts.age, parts.warning, parts.feedback].forEach { $0.isHidden = true } adView.headlineView = parts.title adView.bodyView = parts.description adView.advertiserView = parts.domain adView.callToActionView = parts.callToAction - adView.iconView = parts.icon + adView.iconView = parts.favicon adView.mediaView = media adView.nativeAd = ad return Built(view: adView, height: height) @@ -398,8 +399,7 @@ private enum NativeBannerRow { let adView = YandexMobileAds.NativeAdView() let media = YandexMobileAds.NativeMediaView() let parts = Parts() - // Any Yandex ad may carry media, so the thumbnail always has media size. - let height = layout(in: adView, parts: parts, media: media, style: style, mediaSized: true, withFeedback: true) + let height = layout(in: adView, parts: parts, media: media, style: style, withFeedback: true) adView.titleLabel = parts.title adView.bodyLabel = parts.description @@ -422,14 +422,12 @@ private enum NativeBannerRow { parts: Parts, media: UIView, style: NativeAdViewStyle, - mediaSized: Bool, withFeedback: Bool ) -> CGFloat { root.backgroundColor = UIColor(argb: style.background) - let thumbnailWidth: CGFloat = mediaSized ? 120 : 74 - let thumbnailHeight: CGFloat = mediaSized ? 120 : 66 - let rowHeight = thumbnailHeight + 16 + let thumbnailSize: CGFloat = 120 + let rowHeight = thumbnailSize + 16 parts.thumbnail.translatesAutoresizingMaskIntoConstraints = false [media, parts.icon].forEach { child in @@ -447,7 +445,7 @@ private enum NativeBannerRow { parts.title.numberOfLines = style.isStationLayout ? 1 : 2 parts.description.font = .systemFont(ofSize: style.descriptionSize, weight: .medium) parts.description.textColor = UIColor(argb: style.descriptionColor) - parts.description.numberOfLines = mediaSized ? 2 : 1 + parts.description.numberOfLines = 2 parts.sponsored.font = .systemFont(ofSize: style.metaSize) parts.sponsored.textColor = UIColor(argb: style.onAccent) parts.sponsored.backgroundColor = UIColor(argb: style.accent) @@ -478,12 +476,12 @@ private enum NativeBannerRow { let row = UIStackView(arrangedSubviews: rowViews) row.axis = .horizontal row.alignment = .center - row.spacing = mediaSized ? 12 : 8 + row.spacing = 12 root.embed(row, insets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 12)) var constraints = [ - parts.thumbnail.widthAnchor.constraint(equalToConstant: thumbnailWidth), - parts.thumbnail.heightAnchor.constraint(equalToConstant: thumbnailHeight), + parts.thumbnail.widthAnchor.constraint(equalToConstant: thumbnailSize), + parts.thumbnail.heightAnchor.constraint(equalToConstant: thumbnailSize), parts.favicon.widthAnchor.constraint(equalToConstant: 12), parts.favicon.heightAnchor.constraint(equalToConstant: 12), ] From 94348d4389db7e3086c05d6faf3d70ffba6a6a3b Mon Sep 17 00:00:00 2001 From: ngapps Date: Sat, 3 Oct 2026 01:00:52 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=94=96=20Bump=20version:=20Android=20?= =?UTF-8?q?2.4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android 2.4.0 -> 2.4.1 (versionCode 2401). --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 870fed3..155d23f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ androidMinSdk = "24" androidTargetSdk = "36" versionMajor = "2" versionMinor = "4" -versionPatch = "0" +versionPatch = "1" applicationId = "com.ngapp.metanmobile" accompanist = "0.37.3" From 090478bca2b044ac24a3db3dfb8c6c9f3e30b757 Mon Sep 17 00:00:00 2001 From: ngapps Date: Sat, 3 Oct 2026 01:01:25 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=94=96=20Bump=20version:=20iOS=201.1.?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iOS app 1.1.0 (3) -> 1.1.1 (4); the widget extension gets the same version and build. --- iosApp/MetanMobile.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/iosApp/MetanMobile.xcodeproj/project.pbxproj b/iosApp/MetanMobile.xcodeproj/project.pbxproj index 4eaaf50..184007d 100644 --- a/iosApp/MetanMobile.xcodeproj/project.pbxproj +++ b/iosApp/MetanMobile.xcodeproj/project.pbxproj @@ -410,7 +410,7 @@ CODE_SIGN_ENTITLEMENTS = MetanMobileWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = K4VJG4673X; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -441,7 +441,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -496,7 +496,7 @@ CODE_SIGN_ENTITLEMENTS = MetanMobileWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = K4VJG4673X; ENABLE_NS_ASSERTIONS = NO; @@ -521,7 +521,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.ngapp.metanmobile.ios.MetanMobileWidget; @@ -683,7 +683,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = MetanMobile.entitlements; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = K4VJG4673X; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; @@ -696,7 +696,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ( "-framework", @@ -718,7 +718,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = MetanMobile.entitlements; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = K4VJG4673X; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; @@ -731,7 +731,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; OTHER_LDFLAGS = ( "-framework", MetanMobileComposeApp,