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/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 = { 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/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/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 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" 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, 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), ]