From 1055c3be091646d05690e3f9bef876a7d03bc937 Mon Sep 17 00:00:00 2001 From: ngapps Date: Fri, 2 Oct 2026 07:37:20 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Feature:=20"Price=20and=20neare?= =?UTF-8?q?st=20station"=20home-screen=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android widget (Jetpack Glance) showing the CNG price and the nearest station from the app's local data only - widgets never call the API. - Data layer: WidgetData (core:model), WidgetDataRepository (core:domain), CompositeWidgetDataRepository (core:data) built from the existing station, price and location repositories. - New top-level widget/ group: :widget:core holds what every widget shares (WidgetPublisher re-renders widgets when local data changes, appearance model and storage, palette, Glance surfaces, generic settings screen); :widget:nearest-station is the widget itself. - Per-widget settings: Price / Distance / Both tiles, theme (system, light, dark), background and tile color + opacity, imitated liquid glass with tint and strength, live preview over the real wallpaper. - Size-driven layout: a narrow widget keeps one tile, a wide one shows the chosen tiles; a single tile on a wide widget takes half its width. - Status dot gets a contrasting ring when it matches the tile color. - Tapping the station opens it via the existing ecogas-map deep link. Fix: cold start from a deep link (widget or App Link) crashed with "You must call setGraph() before calling getGraph()" - the link is now kept pending until the NavHost graph is set. Tests: repository, publisher (Android + iOS), palette, settings storage and the Glance layout via glance-appwidget-testing + Robolectric. --- app/build.gradle.kts | 2 + .../metanmobile/MetanMobileApplication.kt | 8 + .../navigation/MetanMobileNavHost.kt | 10 +- .../CompositeWidgetDataRepositoryTest.kt | 135 +++++ .../core/data/di/WidgetDataKoinModule.kt | 27 + .../widget/CompositeWidgetDataRepository.kt | 46 ++ .../repository/widget/WidgetDataRepository.kt | 20 + .../repository/widget/WidgetDataRepository.kt | 26 + .../core/model/widget/WidgetData.kt | 34 ++ gradle/libs.versions.toml | 3 + .../moko-resources/base/strings.xml | 27 + .../commonMain/moko-resources/be/strings.xml | 27 + .../commonMain/moko-resources/ru/strings.xml | 27 + settings.gradle.kts | 2 + widget/core/build.gradle.kts | 57 ++ .../appearance/WidgetAppearanceStateTest.kt | 61 ++ .../widget/core/theme/WidgetPaletteTest.kt | 143 +++++ .../core/src/androidMain/AndroidManifest.xml | 19 + .../core/appearance/WidgetAppearanceState.kt | 85 +++ .../widget/core/initializers/Widgets.kt | 35 ++ .../core/settings/WidgetSettingsActivity.kt | 89 +++ .../core/settings/WidgetSettingsComponents.kt | 556 ++++++++++++++++++ .../widget/core/theme/WidgetTheme.kt | 174 ++++++ .../widget/core/ui/WidgetSurfaces.kt | 146 +++++ .../res/drawable/widget_glass_container.xml | 41 ++ .../res/drawable/widget_glass_tile.xml | 41 ++ .../widget/core/WidgetPublisher.kt | 59 ++ .../metanmobile/widget/core/WidgetUpdater.kt | 29 + .../core/appearance/WidgetAppearance.kt | 56 ++ .../widget/core/di/WidgetCoreKoinModule.kt | 26 + .../widget/core/WidgetPublisherTest.kt | 113 ++++ widget/nearest-station/build.gradle.kts | 57 ++ .../NearestStationWidgetSettingsTest.kt | 59 ++ .../ui/NearestStationWidgetContentTest.kt | 133 +++++ .../src/androidMain/AndroidManifest.xml | 45 ++ .../nearest/station/NearestStationWidget.kt | 56 ++ .../station/NearestStationWidgetUpdater.kt | 32 + .../di/NearestStationWidgetKoinModule.kt | 29 + .../settings/NearestStationWidgetSettings.kt | 64 ++ .../NearestStationWidgetSettingsActivity.kt | 115 ++++ .../station/ui/NearestStationWidgetContent.kt | 189 ++++++ .../res/xml/nearest_station_widget_info.xml | 38 ++ .../station/state/NearestStationTiles.kt | 33 ++ .../state/NearestStationWidgetUiState.kt | 53 ++ .../station/NearestStationTilesTest.kt | 37 ++ .../NearestStationWidgetUiStateTest.kt | 88 +++ 46 files changed, 3150 insertions(+), 2 deletions(-) create mode 100644 core/data-test/src/test/kotlin/com/ngapp/metanmobile/core/data/test/repository/widget/CompositeWidgetDataRepositoryTest.kt create mode 100644 core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/di/WidgetDataKoinModule.kt create mode 100644 core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/widget/CompositeWidgetDataRepository.kt create mode 100644 core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/widget/WidgetDataRepository.kt create mode 100644 core/domain/src/commonMain/kotlin/com/ngapp/metanmobile/core/domain/repository/widget/WidgetDataRepository.kt create mode 100644 core/model/src/commonMain/kotlin/com/ngapp/metanmobile/core/model/widget/WidgetData.kt create mode 100644 widget/core/build.gradle.kts create mode 100644 widget/core/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearanceStateTest.kt create mode 100644 widget/core/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/core/theme/WidgetPaletteTest.kt create mode 100644 widget/core/src/androidMain/AndroidManifest.xml create mode 100644 widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearanceState.kt create mode 100644 widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/initializers/Widgets.kt create mode 100644 widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/settings/WidgetSettingsActivity.kt create mode 100644 widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/settings/WidgetSettingsComponents.kt create mode 100644 widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/theme/WidgetTheme.kt create mode 100644 widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/ui/WidgetSurfaces.kt create mode 100644 widget/core/src/androidMain/res/drawable/widget_glass_container.xml create mode 100644 widget/core/src/androidMain/res/drawable/widget_glass_tile.xml create mode 100644 widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/WidgetPublisher.kt create mode 100644 widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/WidgetUpdater.kt create mode 100644 widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearance.kt create mode 100644 widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/di/WidgetCoreKoinModule.kt create mode 100644 widget/core/src/commonTest/kotlin/com/ngapp/metanmobile/widget/core/WidgetPublisherTest.kt create mode 100644 widget/nearest-station/build.gradle.kts create mode 100644 widget/nearest-station/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettingsTest.kt create mode 100644 widget/nearest-station/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/ui/NearestStationWidgetContentTest.kt create mode 100644 widget/nearest-station/src/androidMain/AndroidManifest.xml create mode 100644 widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidget.kt create mode 100644 widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetUpdater.kt create mode 100644 widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/di/NearestStationWidgetKoinModule.kt create mode 100644 widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettings.kt create mode 100644 widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettingsActivity.kt create mode 100644 widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/ui/NearestStationWidgetContent.kt create mode 100644 widget/nearest-station/src/androidMain/res/xml/nearest_station_widget_info.xml create mode 100644 widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/state/NearestStationTiles.kt create mode 100644 widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/state/NearestStationWidgetUiState.kt create mode 100644 widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationTilesTest.kt create mode 100644 widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetUiStateTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0d88769..4efbac7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,6 +113,8 @@ dependencies { implementation(projects.core.network) implementation(projects.core.networkClient) implementation(projects.sync.work) + implementation(projects.widget.core) + implementation(projects.widget.nearestStation) implementation(projects.feature.cabinet) implementation(projects.feature.favorites) diff --git a/app/src/main/kotlin/com/ngapp/metanmobile/MetanMobileApplication.kt b/app/src/main/kotlin/com/ngapp/metanmobile/MetanMobileApplication.kt index a0505b2..9f268bc 100644 --- a/app/src/main/kotlin/com/ngapp/metanmobile/MetanMobileApplication.kt +++ b/app/src/main/kotlin/com/ngapp/metanmobile/MetanMobileApplication.kt @@ -6,6 +6,7 @@ import com.ngapp.metanmobile.core.data.di.locationDataModule import com.ngapp.metanmobile.core.data.di.locationModule import com.ngapp.metanmobile.core.data.di.syncDataModule import com.ngapp.metanmobile.core.data.di.userDataModule +import com.ngapp.metanmobile.core.data.di.widgetDataModule import com.ngapp.metanmobile.core.database.di.databaseModule import com.ngapp.metanmobile.core.datastore.di.userPreferencesDataStoreModule import com.ngapp.metanmobile.core.network.client.di.networkClientModule @@ -17,6 +18,9 @@ import com.ngapp.metanmobile.composeapp.configureImageLoader import com.ngapp.metanmobile.composeapp.di.mainModule import com.ngapp.metanmobile.sync.di.syncModule import com.ngapp.metanmobile.sync.initializers.Sync +import com.ngapp.metanmobile.widget.core.di.widgetCoreModule +import com.ngapp.metanmobile.widget.core.initializers.Widgets +import com.ngapp.metanmobile.widget.nearest.station.di.nearestStationWidgetModule import com.ngapp.metanmobile.feature.onboarding.di.featureOnboardingModule import com.ngapp.metanmobile.feature.news.di.featureNewsModule import com.ngapp.metanmobile.feature.favorites.di.featureFavoritesModule @@ -55,6 +59,7 @@ class MetanMobileApplication : Application() { locationModule(), locationDataModule, syncDataModule, + widgetDataModule, syncModule(), mainModule, uiModule, @@ -71,8 +76,11 @@ class MetanMobileApplication : Application() { featureContactsModule, featureFaqModule, featurePrivacyPolicyModule, + widgetCoreModule, + nearestStationWidgetModule, ) } Sync.initialize(context = this) + Widgets.initialize() } } diff --git a/composeApp/src/commonMain/kotlin/com/ngapp/metanmobile/composeapp/navigation/MetanMobileNavHost.kt b/composeApp/src/commonMain/kotlin/com/ngapp/metanmobile/composeapp/navigation/MetanMobileNavHost.kt index e2d960d..6ce5a93 100644 --- a/composeApp/src/commonMain/kotlin/com/ngapp/metanmobile/composeapp/navigation/MetanMobileNavHost.kt +++ b/composeApp/src/commonMain/kotlin/com/ngapp/metanmobile/composeapp/navigation/MetanMobileNavHost.kt @@ -84,14 +84,20 @@ fun MetanMobileNavHost( // ?.let { appState.navigateToDeepLink(it) } }, just fed by DeepLinkHolder instead of an // Android-only Intent so both platform entry points can push into the same place. val pendingDeepLink by DeepLinkHolder.pendingUrl.collectAsStateWithLifecycle() - LaunchedEffect(pendingDeepLink) { + val entry by navController.currentBackStackEntryAsState() + // On a cold start (the app opened straight from a link or a home-screen widget) the link + // arrives before the NavHost below is mounted — it waits for onboarding state to load — and + // navigating then crashes with "You must call setGraph() before calling getGraph()". Keep the + // link pending until the graph exists, i.e. the first back stack entry is there. + val isGraphReady = entry != null + LaunchedEffect(pendingDeepLink, isGraphReady) { + if (!isGraphReady) return@LaunchedEffect pendingDeepLink?.let { appState.navigateToDeepLink(it) DeepLinkHolder.consume() } } - val entry by navController.currentBackStackEntryAsState() val isOnboarding = entry?.destination?.hasRoute() == true val selectedDestination = TopLevelDestination.entries.firstOrNull { entry?.destination?.route == it.route } // Top-level screens hide the bar while their station-detail bottom sheet is expanded. The diff --git a/core/data-test/src/test/kotlin/com/ngapp/metanmobile/core/data/test/repository/widget/CompositeWidgetDataRepositoryTest.kt b/core/data-test/src/test/kotlin/com/ngapp/metanmobile/core/data/test/repository/widget/CompositeWidgetDataRepositoryTest.kt new file mode 100644 index 0000000..79e231a --- /dev/null +++ b/core/data-test/src/test/kotlin/com/ngapp/metanmobile/core/data/test/repository/widget/CompositeWidgetDataRepositoryTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.core.data.test.repository.widget + +import app.cash.turbine.test +import com.ngapp.metanmobile.core.data.Synchronizer +import com.ngapp.metanmobile.core.data.repository.location.LocationsRepository +import com.ngapp.metanmobile.core.data.repository.price.PricesRepository +import com.ngapp.metanmobile.core.data.repository.station.StationResourceQuery +import com.ngapp.metanmobile.core.data.repository.station.StationResourcesWithFavoritesRepository +import com.ngapp.metanmobile.core.data.repository.widget.CompositeWidgetDataRepository +import com.ngapp.metanmobile.core.model.location.LocationResource +import com.ngapp.metanmobile.core.model.price.PriceResource +import com.ngapp.metanmobile.core.model.station.UserStationResource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Unit tests for [CompositeWidgetDataRepository]: the widgets' snapshot of local data, built + * from the same repositories the app's screens use and never from the network. + */ +class CompositeWidgetDataRepositoryTest { + + private val stationsRepository = FakeUserStationsRepository() + private val pricesRepository = FakePricesRepository() + private val locationsRepository = FakeLocationsRepository() + + private val repository = CompositeWidgetDataRepository( + stationsRepository = stationsRepository, + pricesRepository = pricesRepository, + locationsRepository = locationsRepository, + ) + + @Test + fun `observeWidgetData combines the price, the location and the stations`() = runTest { + val price = PriceResource.init().copy(content = "1.16") + val location = LocationResource(id = 1, time = 0L, latitude = 53.9, longitude = 27.5) + val station = UserStationResource.init().copy(code = "a", distanceBetween = 2.4) + pricesRepository.price.value = price + locationsRepository.location.value = location + stationsRepository.stations.value = listOf(station) + + val data = repository.observeWidgetData().first() + + assertEquals(price, data.cngPrice) + assertEquals(location, data.location) + assertEquals(listOf(station), data.stations) + } + + @Test + fun `observeWidgetData reports an unknown location and no price as nulls`() = runTest { + val data = repository.observeWidgetData().first() + + assertNull(data.cngPrice) + assertNull(data.location) + assertEquals(emptyList(), data.stations) + } + + @Test + fun `observeWidgetData emits again when local data changes`() = runTest { + repository.observeWidgetData().test { + assertNull(awaitItem().cngPrice) + + pricesRepository.price.value = PriceResource.init().copy(content = "1.20") + + assertEquals("1.20", awaitItem().cngPrice?.content) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeWidgetData skips updates that change nothing`() = runTest { + val station = UserStationResource.init().copy(code = "a") + stationsRepository.stations.value = listOf(station) + + repository.observeWidgetData().test { + awaitItem() + + // A new list with equal content, as a sync re-writing the same rows produces. + stationsRepository.stations.value = listOf(station.copy()) + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } +} + +/** Tiny hand-written fakes, in keeping with the project's existing testing convention. */ +private class FakeUserStationsRepository : StationResourcesWithFavoritesRepository { + val stations = MutableStateFlow>(emptyList()) + + override fun observeAll(query: StationResourceQuery): Flow> = stations + + override fun observeAllFavorites(query: StationResourceQuery): Flow> = + stations.map { list -> list.filter { it.isFavorite } } +} + +private class FakePricesRepository : PricesRepository { + val price = MutableStateFlow(null) + + override fun getFuelPrice(): Flow = price + + override suspend fun syncWith(synchronizer: Synchronizer) = true +} + +private class FakeLocationsRepository : LocationsRepository { + val location = MutableStateFlow(null) + + override fun getLocationResources(): Flow> = location.map(::listOfNotNull) + + override fun getLocationResource(): Flow = location + + override suspend fun updateLocation(locationPermissionGranted: Boolean) = Unit +} diff --git a/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/di/WidgetDataKoinModule.kt b/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/di/WidgetDataKoinModule.kt new file mode 100644 index 0000000..19f9a22 --- /dev/null +++ b/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/di/WidgetDataKoinModule.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.core.data.di + +import com.ngapp.metanmobile.core.data.repository.widget.CompositeWidgetDataRepository +import com.ngapp.metanmobile.core.data.repository.widget.WidgetDataRepository +import org.koin.core.module.Module +import org.koin.dsl.module + +val widgetDataModule: Module = module { + single { CompositeWidgetDataRepository(get(), get(), get()) } +} diff --git a/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/widget/CompositeWidgetDataRepository.kt b/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/widget/CompositeWidgetDataRepository.kt new file mode 100644 index 0000000..8ffe9cd --- /dev/null +++ b/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/widget/CompositeWidgetDataRepository.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.core.data.repository.widget + +import com.ngapp.metanmobile.core.data.repository.location.LocationsRepository +import com.ngapp.metanmobile.core.data.repository.price.PricesRepository +import com.ngapp.metanmobile.core.data.repository.station.StationResourceQuery +import com.ngapp.metanmobile.core.data.repository.station.StationResourcesWithFavoritesRepository +import com.ngapp.metanmobile.core.model.widget.WidgetData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged + +class CompositeWidgetDataRepository( + private val stationsRepository: StationResourcesWithFavoritesRepository, + private val pricesRepository: PricesRepository, + private val locationsRepository: LocationsRepository, +) : WidgetDataRepository { + + override fun observeWidgetData(): Flow = combine( + stationsRepository.observeAll(query = StationResourceQuery()), + pricesRepository.getFuelPrice(), + locationsRepository.getLocationResource(), + ) { stations, cngPrice, location -> + WidgetData( + cngPrice = cngPrice, + location = location, + stations = stations, + ) + }.distinctUntilChanged() +} diff --git a/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/widget/WidgetDataRepository.kt b/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/widget/WidgetDataRepository.kt new file mode 100644 index 0000000..2d6e026 --- /dev/null +++ b/core/data/src/main/kotlin/com/ngapp/metanmobile/core/data/repository/widget/WidgetDataRepository.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.core.data.repository.widget + +typealias WidgetDataRepository = com.ngapp.metanmobile.core.domain.repository.widget.WidgetDataRepository diff --git a/core/domain/src/commonMain/kotlin/com/ngapp/metanmobile/core/domain/repository/widget/WidgetDataRepository.kt b/core/domain/src/commonMain/kotlin/com/ngapp/metanmobile/core/domain/repository/widget/WidgetDataRepository.kt new file mode 100644 index 0000000..3a67233 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/com/ngapp/metanmobile/core/domain/repository/widget/WidgetDataRepository.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.core.domain.repository.widget + +import com.ngapp.metanmobile.core.model.widget.WidgetData +import kotlinx.coroutines.flow.Flow + +interface WidgetDataRepository { + /** Local data only — widgets never trigger network requests. */ + fun observeWidgetData(): Flow +} diff --git a/core/model/src/commonMain/kotlin/com/ngapp/metanmobile/core/model/widget/WidgetData.kt b/core/model/src/commonMain/kotlin/com/ngapp/metanmobile/core/model/widget/WidgetData.kt new file mode 100644 index 0000000..3c0d66b --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/ngapp/metanmobile/core/model/widget/WidgetData.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.core.model.widget + +import com.ngapp.metanmobile.core.model.location.LocationResource +import com.ngapp.metanmobile.core.model.price.PriceResource +import com.ngapp.metanmobile.core.model.station.UserStationResource + +/** + * What the home-screen widgets show, all of it already stored locally by the app. + * + * [stations] carry their distance to [location] (null while the location is unknown) and the + * user's favorite flag, same as everywhere else in the app. + */ +data class WidgetData( + val cngPrice: PriceResource?, + val location: LocationResource?, + val stations: List, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0dd4731..1c44cf2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ androidxTestRunner = "1.7.0-rc01" androidxTracing = "1.3.0" androidxUiAutomator = "2.3.0" androidxWork = "2.10.1" +androidxGlance = "1.2.0" coil3 = "3.4.0" kyantBackdrop = "2.0.0" webview = "2.0.3" @@ -124,6 +125,8 @@ androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiaut androidx-tracing-ktx = { group = "androidx.tracing", name = "tracing-ktx", version.ref = "androidxTracing" } androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" } +androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "androidxGlance" } +androidx-glance-appwidget-testing = { group = "androidx.glance", name = "glance-appwidget-testing", version.ref = "androidxGlance" } kyant-backdrop = { module = "io.github.kyant0:backdrop", version.ref = "kyantBackdrop" } coil3-core = { module = "io.coil-kt.coil3:coil", version.ref = "coil3" } coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" } diff --git a/resources/src/commonMain/moko-resources/base/strings.xml b/resources/src/commonMain/moko-resources/base/strings.xml index d4d62f0..1ca3eed 100644 --- a/resources/src/commonMain/moko-resources/base/strings.xml +++ b/resources/src/commonMain/moko-resources/base/strings.xml @@ -276,4 +276,31 @@ Friday Saturday Sunday + + + Price and nearest station + CNG price and the CNG station nearest to you + Open the app to find the nearest station + Open the app to load the data + Save + Show + Price + Distance + Glass tint + Liquid glass + Tint strength + Black + White + Blue + Auto (follows theme) + Background + Tiles + Color + Opacity + Stretch the widget to show both tiles, shrink it to keep one. + Both + Theme + System + Light + Dark diff --git a/resources/src/commonMain/moko-resources/be/strings.xml b/resources/src/commonMain/moko-resources/be/strings.xml index 6ba9737..8cc82d0 100644 --- a/resources/src/commonMain/moko-resources/be/strings.xml +++ b/resources/src/commonMain/moko-resources/be/strings.xml @@ -248,4 +248,31 @@ Пятніца Субота Нядзеля + + + Кошт і бліжэйшая станцыя + Кошт метану і бліжэйшая да вас АГНКС + Адкрыйце праграму, каб знайсці бліжэйшую станцыю + Адкрыйце праграму, каб загрузіць даныя + Захаваць + Паказваць + Кошт + Адлегласць + Адценне шкла + Liquid glass + Сіла адцення + Чорны + Белы + Сіні + Аўта (па тэме) + Фон + Пліткі + Колер + Непразрыстасць + Расцягніце віджэт — з\'явяцца абедзве пліткі, сцісніце — застанецца адна. + Абедзве + Тэма + Як у сістэме + Светлая + Цёмная diff --git a/resources/src/commonMain/moko-resources/ru/strings.xml b/resources/src/commonMain/moko-resources/ru/strings.xml index 7a65959..aeeec11 100644 --- a/resources/src/commonMain/moko-resources/ru/strings.xml +++ b/resources/src/commonMain/moko-resources/ru/strings.xml @@ -276,4 +276,31 @@ Пятница Суббота Воскресенье + + + Цена и ближайшая станция + Цена метана и ближайшая к вам АГНКС + Откройте приложение, чтобы найти ближайшую станцию + Откройте приложение, чтобы загрузить данные + Сохранить + Показывать + Цена + Расстояние + Оттенок стекла + Liquid glass + Сила оттенка + Чёрный + Белый + Синий + Авто (по теме) + Фон + Плитки + Цвет + Непрозрачность + Растяните виджет — появятся обе плитки, сожмите — останется одна. + Обе + Тема + Как в системе + Светлая + Тёмная diff --git a/settings.gradle.kts b/settings.gradle.kts index 14540e9..3854d14 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -77,3 +77,5 @@ include(":feature:stationdetail") include(":lint") include(":sync:work") include(":sync:sync-test") +include(":widget:core") +include(":widget:nearest-station") diff --git a/widget/core/build.gradle.kts b/widget/core/build.gradle.kts new file mode 100644 index 0000000..ee8127a --- /dev/null +++ b/widget/core/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget + +plugins { + alias(libs.plugins.mm.kmp.library) + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.compose) +} + +kotlin { + // res/drawable holds the shared glass highlight drawables. + targets.withType().configureEach { + androidResources.enable = true + } + sourceSets { + // Shared plumbing for every home-screen widget: what changes trigger a refresh, and the + // common look. Each widget lives in its own :widget:* module and only plugs in a + // WidgetUpdater; widgets read the app's local data and never call the API. + commonMain.dependencies { + implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composePlugin.get()}") + api(projects.core.data) + implementation(libs.koin.core) + } + androidMain.dependencies { + api(libs.androidx.glance.appwidget) + implementation(projects.core.designsystem) + implementation(projects.resources) + implementation(compose.material3) + implementation(libs.moko.core) + implementation(libs.moko.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.koin.android) + } + commonTest.dependencies { + implementation(libs.kotlinx.coroutines.test) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit4) + } + } +} diff --git a/widget/core/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearanceStateTest.kt b/widget/core/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearanceStateTest.kt new file mode 100644 index 0000000..0504e5f --- /dev/null +++ b/widget/core/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearanceStateTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.appearance + +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import org.junit.Test +import kotlin.test.assertEquals + +class WidgetAppearanceStateTest { + + @Test + fun `a widget nobody configured gets the defaults`() { + assertEquals(WidgetAppearance(), mutablePreferencesOf().toWidgetAppearance()) + } + + @Test + fun `what is written is read back`() { + val appearance = WidgetAppearance( + themeMode = WidgetThemeMode.DARK, + background = WidgetSurfaceStyle( + color = WidgetColor.BLUE, + opacity = 40, + glass = WidgetGlass(enabled = true, tint = WidgetColor.BLACK, tintStrength = 65), + ), + tiles = WidgetSurfaceStyle(color = WidgetColor.WHITE, opacity = 80), + ) + + val preferences = mutablePreferencesOf().apply { writeWidgetAppearance(appearance) } + + assertEquals(appearance, preferences.toWidgetAppearance()) + } + + @Test + fun `a value saved by an older version that no longer exists falls back to the default`() { + val preferences = mutablePreferencesOf( + stringPreferencesKey("widget_background_color") to "PURPLE", + stringPreferencesKey("widget_theme_mode") to "SEPIA", + ) + + val appearance = preferences.toWidgetAppearance() + + assertEquals(WidgetColor.AUTO, appearance.background.color) + assertEquals(WidgetThemeMode.SYSTEM, appearance.themeMode) + } +} diff --git a/widget/core/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/core/theme/WidgetPaletteTest.kt b/widget/core/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/core/theme/WidgetPaletteTest.kt new file mode 100644 index 0000000..5202615 --- /dev/null +++ b/widget/core/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/core/theme/WidgetPaletteTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.theme + +import androidx.compose.ui.graphics.Color +import com.ngapp.metanmobile.core.designsystem.theme.BackgroundDark +import com.ngapp.metanmobile.core.designsystem.theme.BackgroundLight +import com.ngapp.metanmobile.core.designsystem.theme.Black +import com.ngapp.metanmobile.core.designsystem.theme.Blue +import com.ngapp.metanmobile.core.designsystem.theme.White +import com.ngapp.metanmobile.widget.core.appearance.WidgetAppearance +import com.ngapp.metanmobile.widget.core.appearance.WidgetColor +import com.ngapp.metanmobile.widget.core.appearance.WidgetGlass +import com.ngapp.metanmobile.widget.core.appearance.WidgetSurfaceStyle +import com.ngapp.metanmobile.widget.core.appearance.WidgetThemeMode +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WidgetPaletteTest { + + @Test + fun `the defaults look like the app - light in the light theme, dark in the dark one`() { + val palette = WidgetAppearance().palette() + + assertEquals(DayNightColor(day = White, night = Black), palette.container.fill) + assertEquals(DayNightColor(day = BackgroundLight, night = BackgroundDark), palette.tile.fill) + assertEquals(DayNightColor(day = Black, night = White), palette.onTile) + assertFalse(palette.container.isGlass) + assertFalse(palette.tile.isGlass) + } + + @Test + fun `a fixed theme pins every AUTO color to that side`() { + val light = WidgetAppearance(themeMode = WidgetThemeMode.LIGHT).palette() + val dark = WidgetAppearance(themeMode = WidgetThemeMode.DARK).palette() + + assertEquals(DayNightColor(White), light.container.fill) + assertEquals(DayNightColor(Black), light.onTile) + assertEquals(DayNightColor(Black), dark.container.fill) + assertEquals(DayNightColor(White), dark.onTile) + } + + @Test + fun `an explicit color ignores the theme`() { + val appearance = WidgetAppearance( + themeMode = WidgetThemeMode.DARK, + background = WidgetSurfaceStyle(color = WidgetColor.WHITE), + ) + + assertEquals(DayNightColor(White), appearance.palette().container.fill) + } + + @Test + fun `opacity becomes the fill's alpha`() { + val palette = WidgetAppearance( + background = WidgetSurfaceStyle(color = WidgetColor.BLUE, opacity = 0), + tiles = WidgetSurfaceStyle(color = WidgetColor.BLUE, opacity = 50), + ).palette() + + assertEquals(0f, palette.container.fill.day.alpha) + assertEquals(0.5f, palette.tile.fill.day.alpha, absoluteTolerance = 0.01f) + } + + @Test + fun `glass replaces the color with its tint at the chosen strength`() { + val palette = WidgetAppearance( + background = WidgetSurfaceStyle( + color = WidgetColor.BLACK, + glass = WidgetGlass(enabled = true, tint = WidgetColor.BLUE, tintStrength = 30), + ), + ).palette() + + assertTrue(palette.container.isGlass) + assertEquals(Blue.copy(alpha = 0.3f), palette.container.fill.day) + } + + @Test + fun `text is white on dark tiles and dark on white ones`() { + fun onTile(color: WidgetColor) = + WidgetAppearance(tiles = WidgetSurfaceStyle(color = color)).palette().onTile + + assertEquals(DayNightColor(White), onTile(WidgetColor.BLACK)) + assertEquals(DayNightColor(White), onTile(WidgetColor.BLUE)) + assertEquals(DayNightColor(Black), onTile(WidgetColor.WHITE)) + } + + @Test + fun `see-through tiles take their text color from what shows through them`() { + val palette = WidgetAppearance( + background = WidgetSurfaceStyle(color = WidgetColor.WHITE), + tiles = WidgetSurfaceStyle(color = WidgetColor.BLACK, opacity = 10), + ).palette() + + assertEquals(DayNightColor(Black), palette.onTile) + } + + @Test + fun `the operating dot gets a ring on blue tiles only`() { + val blueTiles = WidgetAppearance(tiles = WidgetSurfaceStyle(color = WidgetColor.BLUE)).palette() + val whiteTiles = WidgetAppearance(tiles = WidgetSurfaceStyle(color = WidgetColor.WHITE)).palette() + + assertNotNull(blueTiles.statusDotRing(WidgetStatus.OK)) + assertEquals(blueTiles.onTile, blueTiles.statusDotRing(WidgetStatus.OK)) + assertNull(blueTiles.statusDotRing(WidgetStatus.PROBLEM)) + assertNull(whiteTiles.statusDotRing(WidgetStatus.OK)) + } + + @Test + fun `isNight follows the phone only in the system theme`() { + assertTrue(WidgetThemeMode.SYSTEM.isNight(isSystemNight = true)) + assertFalse(WidgetThemeMode.SYSTEM.isNight(isSystemNight = false)) + assertFalse(WidgetThemeMode.LIGHT.isNight(isSystemNight = true)) + assertTrue(WidgetThemeMode.DARK.isNight(isSystemNight = false)) + } + + @Test + fun `transparent stays transparent`() { + val palette = WidgetAppearance(background = WidgetSurfaceStyle(opacity = 0)).palette() + + assertEquals(0f, palette.container.fill.day.alpha) + assertEquals(0f, palette.container.fill.night.alpha) + assertEquals(Color.Transparent.alpha, palette.container.fill.night.alpha) + } +} diff --git a/widget/core/src/androidMain/AndroidManifest.xml b/widget/core/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..5adc7b7 --- /dev/null +++ b/widget/core/src/androidMain/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + diff --git a/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearanceState.kt b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearanceState.kt new file mode 100644 index 0000000..a745cc5 --- /dev/null +++ b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearanceState.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.appearance + +import android.content.Context +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.appwidget.state.updateAppWidgetState + +// Stored in each widget's own Glance state (keyed by its id), not in the app's user data: two +// copies of the same widget can look different. +private const val BACKGROUND = "widget_background" +private const val TILES = "widget_tiles" +private val ThemeModeKey = stringPreferencesKey("widget_theme_mode") + +fun Preferences.toWidgetAppearance() = WidgetAppearance( + themeMode = enumValueOrNull(this[ThemeModeKey]) ?: WidgetThemeMode.SYSTEM, + background = readSurfaceStyle(BACKGROUND), + tiles = readSurfaceStyle(TILES), +) + +suspend fun saveWidgetAppearance(context: Context, glanceId: GlanceId, appearance: WidgetAppearance) { + updateAppWidgetState(context, glanceId) { it.writeWidgetAppearance(appearance) } +} + +/** The write half of [toWidgetAppearance], for widgets that store more settings alongside. */ +fun MutablePreferences.writeWidgetAppearance(appearance: WidgetAppearance) { + this[ThemeModeKey] = appearance.themeMode.name + writeSurfaceStyle(BACKGROUND, appearance.background) + writeSurfaceStyle(TILES, appearance.tiles) +} + +private class SurfaceKeys(prefix: String) { + val color = stringPreferencesKey("${prefix}_color") + val opacity = intPreferencesKey("${prefix}_opacity") + val glassEnabled = booleanPreferencesKey("${prefix}_glass_enabled") + val glassTint = stringPreferencesKey("${prefix}_glass_tint") + val glassTintStrength = intPreferencesKey("${prefix}_glass_tint_strength") +} + +private fun Preferences.readSurfaceStyle(prefix: String): WidgetSurfaceStyle { + val keys = SurfaceKeys(prefix) + val defaults = WidgetSurfaceStyle() + return WidgetSurfaceStyle( + color = enumValueOrNull(this[keys.color]) ?: defaults.color, + opacity = this[keys.opacity] ?: defaults.opacity, + glass = WidgetGlass( + enabled = this[keys.glassEnabled] ?: defaults.glass.enabled, + tint = enumValueOrNull(this[keys.glassTint]) ?: defaults.glass.tint, + tintStrength = this[keys.glassTintStrength] ?: defaults.glass.tintStrength, + ), + ) +} + +private fun MutablePreferences.writeSurfaceStyle(prefix: String, style: WidgetSurfaceStyle) { + val keys = SurfaceKeys(prefix) + this[keys.color] = style.color.name + this[keys.opacity] = style.opacity + this[keys.glassEnabled] = style.glass.enabled + this[keys.glassTint] = style.glass.tint.name + this[keys.glassTintStrength] = style.glass.tintStrength +} + +/** Reads an enum saved by name; a name from an older app version that no longer exists is null. */ +inline fun > enumValueOrNull(name: String?): T? = + enumValues().firstOrNull { it.name == name } diff --git a/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/initializers/Widgets.kt b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/initializers/Widgets.kt new file mode 100644 index 0000000..d8fb94b --- /dev/null +++ b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/initializers/Widgets.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.initializers + +import com.ngapp.metanmobile.widget.core.WidgetPublisher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +object Widgets : KoinComponent { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Starts keeping the home-screen widgets up to date with local data. It is called from the + // app module's Application.onCreate() after Koin is started and should be only done once. + fun initialize() { + get().start(scope) + } +} diff --git a/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/settings/WidgetSettingsActivity.kt b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/settings/WidgetSettingsActivity.kt new file mode 100644 index 0000000..1d0d1c8 --- /dev/null +++ b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/settings/WidgetSettingsActivity.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.settings + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.Composable +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.lifecycleScope +import com.ngapp.metanmobile.core.designsystem.theme.MMTheme +import com.ngapp.metanmobile.widget.core.WidgetPublisher +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject + +/** + * Base for a widget's configuration screen — opened from the launcher ("Settings" on a + * long-pressed widget, Android 12+) or when the widget is added on older Android. Handles the + * system contract and the save → re-render round trip; a widget only says what its settings + * [S] are, where they're stored and how they're edited (see WidgetSettingsComponents). + */ +abstract class WidgetSettingsActivity : ComponentActivity() { + + private val widgetPublisher: WidgetPublisher by inject() + + protected abstract suspend fun loadSettings(context: Context, glanceId: GlanceId): S + + protected abstract suspend fun saveSettings(context: Context, glanceId: GlanceId, settings: S) + + @Composable + protected abstract fun SettingsScreen(initialSettings: S, onSave: (S) -> Unit) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val appWidgetId = intent?.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID, + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + val result = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + // Leaving without saving while a widget is being added cancels adding it. + setResult(RESULT_CANCELED, result) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + enableEdgeToEdge() + + lifecycleScope.launch { + val context = this@WidgetSettingsActivity + val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) + val initialSettings = loadSettings(context, glanceId) + setContent { + MMTheme { + SettingsScreen( + initialSettings = initialSettings, + onSave = { settings -> + lifecycleScope.launch { + saveSettings(context, glanceId, settings) + widgetPublisher.refresh() + setResult(RESULT_OK, result) + finish() + } + }, + ) + } + } + } + } +} diff --git a/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/settings/WidgetSettingsComponents.kt b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/settings/WidgetSettingsComponents.kt new file mode 100644 index 0000000..6de8bf5 --- /dev/null +++ b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/settings/WidgetSettingsComponents.kt @@ -0,0 +1,556 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.settings + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ngapp.metanmobile.SharedRes +import com.ngapp.metanmobile.core.designsystem.component.MMButton +import com.ngapp.metanmobile.core.designsystem.theme.Black +import com.ngapp.metanmobile.core.designsystem.theme.Blue +import com.ngapp.metanmobile.core.designsystem.theme.Gray400 +import com.ngapp.metanmobile.core.designsystem.theme.White +import com.ngapp.metanmobile.widget.core.appearance.WIDGET_PERCENT_MAX +import com.ngapp.metanmobile.widget.core.appearance.WidgetAppearance +import com.ngapp.metanmobile.widget.core.appearance.WidgetColor +import com.ngapp.metanmobile.widget.core.appearance.WidgetSurfaceStyle +import com.ngapp.metanmobile.widget.core.appearance.WidgetThemeMode +import com.ngapp.metanmobile.widget.core.theme.WidgetDimens +import com.ngapp.metanmobile.widget.core.theme.WidgetPalette +import com.ngapp.metanmobile.widget.core.theme.WidgetStatus +import com.ngapp.metanmobile.widget.core.theme.isNight +import com.ngapp.metanmobile.widget.core.theme.palette +import com.ngapp.metanmobile.widget.core.theme.statusDotRing +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.stringResource +import kotlin.math.roundToInt + +// Building blocks every widget's settings screen is made of, so they all look the same: the +// live preview on top of the real wallpaper, and a panel of rows (switches, color swatches, +// sliders) under it. + +/** + * The whole settings screen. The window shows the user's wallpaper (see the activity theme), + * so [preview] is seen exactly where the widget will be: on top of it. The preview stays put + * while the settings panel under it scrolls. + */ +@Composable +fun WidgetSettingsLayout( + onSave: () -> Unit, + preview: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + // Pinned: only the panel below scrolls, so every change stays visible in the preview. + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .heightIn(min = 200.dp) + .padding(16.dp), + ) { + preview() + } + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f)) + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + content() + Spacer(Modifier.height(16.dp)) + MMButton( + buttonText = SharedRes.strings.widget_settings_button_save, + buttonBackgroundColor = Blue, + fontColor = White, + borderStrokeColor = Blue, + onClick = { onSave() }, + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +/** The theme, background and tile settings shared by every widget. */ +@Composable +fun WidgetAppearanceSettings( + appearance: WidgetAppearance, + onAppearanceChange: (WidgetAppearance) -> Unit, +) { + WidgetChoiceRow( + title = SharedRes.strings.widget_settings_text_theme, + options = WidgetThemeMode.entries, + selected = appearance.themeMode, + optionTitle = { it.title }, + onSelect = { onAppearanceChange(appearance.copy(themeMode = it)) }, + ) + WidgetSettingsSection(SharedRes.strings.widget_settings_text_background) + WidgetSurfaceSettings( + style = appearance.background, + onStyleChange = { onAppearanceChange(appearance.copy(background = it)) }, + ) + WidgetSettingsSection(SharedRes.strings.widget_settings_text_tiles) + WidgetSurfaceSettings( + style = appearance.tiles, + onStyleChange = { onAppearanceChange(appearance.copy(tiles = it)) }, + ) +} + +/** Color and opacity, or — with liquid glass on — the glass tint and its strength instead. */ +@Composable +private fun WidgetSurfaceSettings( + style: WidgetSurfaceStyle, + onStyleChange: (WidgetSurfaceStyle) -> Unit, +) { + val glass = style.glass + WidgetSwitchRow( + title = SharedRes.strings.widget_settings_text_glass, + checked = glass.enabled, + onCheckedChange = { onStyleChange(style.copy(glass = glass.copy(enabled = it))) }, + ) + if (glass.enabled) { + WidgetColorRow( + title = SharedRes.strings.widget_settings_text_glass_tint, + selected = glass.tint, + onSelect = { onStyleChange(style.copy(glass = glass.copy(tint = it))) }, + ) + WidgetSliderRow( + title = SharedRes.strings.widget_settings_text_glass_intensity, + value = glass.tintStrength, + onValueChange = { onStyleChange(style.copy(glass = glass.copy(tintStrength = it))) }, + ) + } else { + WidgetColorRow( + title = SharedRes.strings.widget_settings_text_color, + selected = style.color, + onSelect = { onStyleChange(style.copy(color = it)) }, + ) + WidgetSliderRow( + title = SharedRes.strings.widget_settings_text_opacity, + value = style.opacity, + onValueChange = { onStyleChange(style.copy(opacity = it)) }, + ) + } +} + +private val InactiveTrack = Gray400.copy(alpha = 0.4f) + +@Composable +fun WidgetSettingsSection(title: StringResource) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 20.dp, bottom = 4.dp), + ) +} + +@Composable +fun WidgetSwitchRow( + title: StringResource, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, +) { + SettingsRow( + title = title, + modifier = Modifier.toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ), + ) { + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled, + // Explicit: MMTheme's color roles are tuned for the app's own screens and leave + // these controls brown/blue in places. + colors = SwitchDefaults.colors( + checkedTrackColor = Blue, + checkedThumbColor = White, + uncheckedTrackColor = InactiveTrack, + uncheckedThumbColor = White, + uncheckedBorderColor = Color.Transparent, + // A switch locked on (e.g. the last tile left) must still read as "on". + disabledCheckedTrackColor = Blue.copy(alpha = 0.5f), + disabledCheckedThumbColor = White, + disabledUncheckedTrackColor = InactiveTrack, + disabledUncheckedThumbColor = White.copy(alpha = 0.6f), + disabledUncheckedBorderColor = Color.Transparent, + ), + ) + } +} + +/** A title over a row of pill-shaped choices, the selected one filled blue. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun WidgetChoiceRow( + title: StringResource, + options: List, + selected: T, + optionTitle: (T) -> StringResource, + onSelect: (T) -> Unit, +) { + SettingsRow(title = title) {} + // Wraps onto a second line when the labels don't fit (long translations, narrow phones). + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + val isSelected = option == selected + val shape = RoundedCornerShape(50) + Text( + text = stringResource(optionTitle(option)), + style = MaterialTheme.typography.headlineMedium, + color = if (isSelected) White else MaterialTheme.typography.headlineMedium.color, + modifier = Modifier + .clip(shape) + .background(if (isSelected) Blue else Color.Transparent) + .border(1.dp, if (isSelected) Blue else Gray400, shape) + .selectable(selected = isSelected, role = Role.RadioButton, onClick = { onSelect(option) }) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } +} + +/** A short explanation under a setting. */ +@Composable +fun WidgetSettingsHint(text: StringResource) { + Text( + text = stringResource(text), + style = MaterialTheme.typography.headlineMedium, + color = Gray400, + fontSize = 13.sp, + modifier = Modifier.padding(top = 8.dp), + ) +} + +@Composable +fun WidgetColorRow( + title: StringResource, + selected: WidgetColor, + onSelect: (WidgetColor) -> Unit, +) { + SettingsRow(title = title) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WidgetColor.entries.forEach { color -> + ColorSwatch(color = color, selected = color == selected, onClick = { onSelect(color) }) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WidgetSliderRow( + title: StringResource, + value: Int, + onValueChange: (Int) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val colors = SliderDefaults.colors( + thumbColor = Blue, + activeTrackColor = Blue, + inactiveTrackColor = InactiveTrack, + ) + Column { + SettingsRow(title = title) { + Text(text = value.toString(), style = MaterialTheme.typography.headlineMedium) + } + // A thin track and a small round thumb instead of Material's thick default. + Slider( + value = value.toFloat(), + onValueChange = { onValueChange(it.roundToInt()) }, + valueRange = 0f..WIDGET_PERCENT_MAX.toFloat(), + interactionSource = interactionSource, + colors = colors, + thumb = { + Box( + Modifier + .size(18.dp) + .clip(CircleShape) + .background(Blue), + ) + }, + track = { state -> + SliderDefaults.Track( + sliderState = state, + colors = colors, + modifier = Modifier.height(4.dp), + thumbTrackGapSize = 0.dp, + drawStopIndicator = null, + ) + }, + ) + } +} + +@Composable +private fun SettingsRow( + title: StringResource, + modifier: Modifier = Modifier, + control: @Composable () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.weight(1f), + ) + control() + } +} + +/** A square of the color, ringed in blue when selected; AUTO is split light/dark. */ +@Composable +private fun ColorSwatch(color: WidgetColor, selected: Boolean, onClick: () -> Unit) { + val description = stringResource(color.title) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .border(2.dp, if (selected) Blue else Color.Transparent, RoundedCornerShape(6.dp)) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .semantics { contentDescription = description }, + ) { + val shape = RoundedCornerShape(3.dp) + Canvas( + modifier = Modifier + .size(30.dp) + .clip(shape) + .border(1.dp, Gray400, shape), + ) { + when (color) { + WidgetColor.AUTO -> { + drawRect(White) + drawPath( + Path().apply { + moveTo(size.width, 0f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + }, + Black, + ) + } + WidgetColor.BLACK -> drawRect(Black) + WidgetColor.WHITE -> drawRect(White) + WidgetColor.BLUE -> drawRect(Blue) + } + } + } +} + +/** Colors of the widget preview, resolved for the current system theme. */ +class WidgetPreviewColors( + val tile: Color, + val isTileGlass: Boolean, + val onTile: Color, + internal val palette: WidgetPalette, + internal val isNight: Boolean, +) + +// Compose version of res/drawable/widget_glass_*.xml, so the preview shows the same glass. +private val GlassHighlight = Brush.verticalGradient( + 0f to Color.White.copy(alpha = 0.28f), + 0.5f to Color.White.copy(alpha = 0.06f), + 1f to Color.Transparent, +) + +private fun Modifier.glass(isGlass: Boolean, shape: Shape): Modifier = + if (!isGlass) this else background(GlassHighlight, shape).border(1.dp, Color.White.copy(alpha = 0.4f), shape) + +/** + * The widget as it will look, drawn with Compose from the same palette Glance uses; the + * widget's own tiles go in [content]. [halfWidth] mirrors WidgetContainer's. + */ +@Composable +fun WidgetPreview( + appearance: WidgetAppearance, + halfWidth: Boolean = false, + content: @Composable RowScope.(WidgetPreviewColors) -> Unit, +) { + val isNight = appearance.themeMode.isNight(isSystemNight = isSystemInDarkTheme()) + val palette = appearance.palette() + val colors = WidgetPreviewColors( + tile = palette.tile.fill.resolve(isNight), + isTileGlass = palette.tile.isGlass, + onTile = palette.onTile.resolve(isNight), + palette = palette, + isNight = isNight, + ) + val shape = RoundedCornerShape(WidgetDimens.containerCornerRadius) + Box(Modifier.fillMaxWidth()) { + Row( + horizontalArrangement = Arrangement.spacedBy(WidgetDimens.tileSpacing), + modifier = Modifier + .fillMaxWidth(if (halfWidth) 0.5f else 1f) + .clip(shape) + .background(palette.container.fill.resolve(isNight)) + .glass(palette.container.isGlass, shape) + .padding(WidgetDimens.containerPadding), + ) { + content(colors) + } + } +} + +@Composable +fun RowScope.WidgetPreviewTile(colors: WidgetPreviewColors, content: @Composable () -> Unit) { + val shape = RoundedCornerShape(WidgetDimens.tileCornerRadius) + Column( + modifier = Modifier + .weight(1f) + .clip(shape) + .background(colors.tile) + .glass(colors.isTileGlass, shape) + .padding( + horizontal = WidgetDimens.tilePaddingHorizontal, + vertical = WidgetDimens.tilePaddingVertical, + ), + ) { + content() + } +} + +/** Same sizes as WidgetTextStyles.value / caption on the real widget. */ +@Composable +fun WidgetPreviewValue(text: String, colors: WidgetPreviewColors, trailing: @Composable () -> Unit = {}) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = text, + color = colors.onTile, + fontSize = 21.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + trailing() + } +} + +@Composable +fun WidgetPreviewCaption(text: String, colors: WidgetPreviewColors) { + Text( + text = text, + color = colors.onTile, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** Mirrors WidgetStatusDot on the real widget, ring included. */ +@Composable +fun WidgetPreviewStatusDot(status: WidgetStatus, colors: WidgetPreviewColors) { + val ring = colors.palette.statusDotRing(status)?.resolve(colors.isNight) + val ringWidth = if (ring != null) WidgetDimens.statusDotRingWidth else 0.dp + Spacer(Modifier.width(6.dp - ringWidth)) + Box( + modifier = Modifier + .size(WidgetDimens.statusDotSize + ringWidth * 2) + .clip(CircleShape) + .background(ring ?: Color.Transparent) + .padding(ringWidth) + .clip(CircleShape) + .background(status.color), + ) +} + +private val WidgetThemeMode.title: StringResource + get() = when (this) { + WidgetThemeMode.SYSTEM -> SharedRes.strings.widget_settings_theme_system + WidgetThemeMode.LIGHT -> SharedRes.strings.widget_settings_theme_light + WidgetThemeMode.DARK -> SharedRes.strings.widget_settings_theme_dark + } + +private val WidgetColor.title: StringResource + get() = when (this) { + WidgetColor.BLACK -> SharedRes.strings.widget_settings_color_black + WidgetColor.WHITE -> SharedRes.strings.widget_settings_color_white + WidgetColor.BLUE -> SharedRes.strings.widget_settings_color_blue + WidgetColor.AUTO -> SharedRes.strings.widget_settings_color_auto + } diff --git a/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/theme/WidgetTheme.kt b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/theme/WidgetTheme.kt new file mode 100644 index 0000000..4cdb576 --- /dev/null +++ b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/theme/WidgetTheme.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.color.ColorProvider +import androidx.glance.text.FontWeight +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import com.ngapp.metanmobile.core.designsystem.theme.BackgroundDark +import com.ngapp.metanmobile.core.designsystem.theme.BackgroundLight +import com.ngapp.metanmobile.core.designsystem.theme.Black +import com.ngapp.metanmobile.core.designsystem.theme.Blue +import com.ngapp.metanmobile.core.designsystem.theme.Gray400 +import com.ngapp.metanmobile.core.designsystem.theme.Red +import com.ngapp.metanmobile.core.designsystem.theme.White +import com.ngapp.metanmobile.widget.core.appearance.WIDGET_PERCENT_MAX +import com.ngapp.metanmobile.widget.core.appearance.WidgetAppearance +import com.ngapp.metanmobile.widget.core.appearance.WidgetColor +import com.ngapp.metanmobile.widget.core.appearance.WidgetSurfaceStyle +import com.ngapp.metanmobile.widget.core.appearance.WidgetThemeMode + +/* + * The app's design tokens translated for Glance, which can't use the Compose design system + * components themselves. Colors come from the user's WidgetAppearance; AUTO follows the system + * light/dark theme, like the rest of the home screen does. Also used by the settings preview, + * so the preview and the widget can't drift apart. + */ + +/** A color for the system light theme and for the dark one. */ +data class DayNightColor(val day: Color, val night: Color) { + constructor(color: Color) : this(color, color) + + val provider: ColorProvider get() = ColorProvider(day = day, night = night) + + fun resolve(isNight: Boolean): Color = if (isNight) night else day + + /** Pinned to one side when the user fixed the widget's theme; as is for SYSTEM. */ + fun forTheme(mode: WidgetThemeMode): DayNightColor = when (mode) { + WidgetThemeMode.SYSTEM -> this + WidgetThemeMode.LIGHT -> DayNightColor(day) + WidgetThemeMode.DARK -> DayNightColor(night) + } +} + +data class WidgetSurfacePaint( + val fill: DayNightColor, + /** Draw the glass highlight and rim over the fill (see WidgetSurfaces). */ + val isGlass: Boolean, +) + +data class WidgetPalette( + val container: WidgetSurfacePaint, + val tile: WidgetSurfacePaint, + val onTile: DayNightColor, + /** The swatch the tiles mostly look like, to keep the status dot visible on them. */ + val tileLooksLike: WidgetColor, +) + +/** The swatch's color in the light and the dark system theme. */ +private fun WidgetColor.base(auto: DayNightColor): DayNightColor = when (this) { + WidgetColor.BLACK -> DayNightColor(Black) + WidgetColor.WHITE -> DayNightColor(White) + WidgetColor.BLUE -> DayNightColor(Blue) + WidgetColor.AUTO -> auto +} + +/** Readable text on a surface of this color. */ +private val WidgetColor.content: DayNightColor + get() = when (this) { + WidgetColor.WHITE -> DayNightColor(Black) + WidgetColor.BLACK, WidgetColor.BLUE -> DayNightColor(White) + WidgetColor.AUTO -> DayNightColor(day = Black, night = White) + } + +private fun DayNightColor.withPercent(percent: Int): DayNightColor { + val alpha = percent.coerceIn(0, WIDGET_PERCENT_MAX) / WIDGET_PERCENT_MAX.toFloat() + return DayNightColor(day = day.copy(alpha = day.alpha * alpha), night = night.copy(alpha = night.alpha * alpha)) +} + +private const val HALF = WIDGET_PERCENT_MAX / 2 + +// AUTO keeps the app's look: a white/dark container with tiles a shade off it, like cards. +private val AutoContainer = DayNightColor(day = White, night = Black) +private val AutoTile = DayNightColor(day = BackgroundLight, night = BackgroundDark) + +private fun WidgetSurfaceStyle.paint(auto: DayNightColor) = if (glass.enabled) { + WidgetSurfacePaint(fill = glass.tint.base(auto).withPercent(glass.tintStrength), isGlass = true) +} else { + WidgetSurfacePaint(fill = color.base(auto).withPercent(opacity), isGlass = false) +} + +/** The color that dominates the surface; null when it's mostly see-through. */ +private val WidgetSurfaceStyle.looksLike: WidgetColor? + get() = when { + glass.enabled -> glass.tint.takeIf { glass.tintStrength >= HALF } + opacity >= HALF -> color + else -> null + } + +fun WidgetAppearance.palette(): WidgetPalette { + // Text sits on the tile, or on whatever shows through it: the background, then the + // wallpaper, which is mostly dark behind see-through widgets — white reads best there. + val tileLooksLike = tiles.looksLike ?: background.looksLike ?: WidgetColor.BLACK + val container = background.paint(AutoContainer) + val tile = tiles.paint(AutoTile) + return WidgetPalette( + container = container.copy(fill = container.fill.forTheme(themeMode)), + tile = tile.copy(fill = tile.fill.forTheme(themeMode)), + onTile = tileLooksLike.content.forTheme(themeMode), + tileLooksLike = tileLooksLike, + ) +} + +/** Whether the widget draws its dark side, given the phone's own dark mode. */ +fun WidgetThemeMode.isNight(isSystemNight: Boolean): Boolean = when (this) { + WidgetThemeMode.SYSTEM -> isSystemNight + WidgetThemeMode.LIGHT -> false + WidgetThemeMode.DARK -> true +} + +/** Mirrors StationStatusView in core:ui. */ +enum class WidgetStatus(val color: Color, val swatch: WidgetColor?) { + OK(Blue, WidgetColor.BLUE), + PROBLEM(Red, null), + UNKNOWN(Gray400, null), +} + +/** + * A rim around the status dot, in the tile's text color, when the dot would otherwise + * disappear into a tile of its own color (a blue "operating" dot on a blue tile). + */ +fun WidgetPalette.statusDotRing(status: WidgetStatus): DayNightColor? = + onTile.takeIf { status.swatch != null && status.swatch == tileLooksLike } + +object WidgetTextStyles { + // MMTypography.displayMedium + fun value(color: ColorProvider) = + TextStyle(color = color, fontSize = 21.sp, fontWeight = FontWeight.Bold) + + // MMTypography.headlineMedium + fun caption(color: ColorProvider) = + TextStyle(color = color, fontSize = 15.sp, fontWeight = FontWeight.Medium) +} + +object WidgetDimens { + val containerPadding = 12.dp + // Matches res/drawable/widget_glass_container.xml (close to launchers' widget radius). + val containerCornerRadius = 24.dp + val tileSpacing = 8.dp + val tilePaddingHorizontal = 12.dp + val tilePaddingVertical = 8.dp + // MMShapes.large; matches res/drawable/widget_glass_tile.xml. + val tileCornerRadius = 12.dp + val statusDotSize = 6.dp + val statusDotRingWidth = 2.dp +} diff --git a/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/ui/WidgetSurfaces.kt b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/ui/WidgetSurfaces.kt new file mode 100644 index 0000000..60cbc09 --- /dev/null +++ b/widget/core/src/androidMain/kotlin/com/ngapp/metanmobile/widget/core/ui/WidgetSurfaces.kt @@ -0,0 +1,146 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.unit.ColorProvider +import com.ngapp.metanmobile.widget.core.R +import com.ngapp.metanmobile.widget.core.theme.WidgetDimens +import com.ngapp.metanmobile.widget.core.theme.WidgetPalette +import com.ngapp.metanmobile.widget.core.theme.WidgetStatus +import com.ngapp.metanmobile.widget.core.theme.WidgetSurfacePaint +import com.ngapp.metanmobile.widget.core.theme.statusDotRing + + +// The surfaces every widget is built from, so appearance settings (glass included) apply to +// all widgets the same way. + +/** + * The widget's background. An app can't resize its placed widget (only the launcher can), so a + * widget with less to show sets [halfWidth]: it draws itself in the start half and leaves the + * other half fully transparent. [modifier] goes on the visible part (e.g. a click action). + */ +@Composable +fun WidgetContainer( + palette: WidgetPalette, + modifier: GlanceModifier = GlanceModifier, + halfWidth: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + // Two equal weights split the width exactly in half, whatever the launcher's padding. + Row(modifier = GlanceModifier.fillMaxSize().appWidgetBackground()) { + Surface( + paint = palette.container, + glassDrawable = R.drawable.widget_glass_container, + modifier = modifier + .defaultWeight() + .fillMaxHeight() + .cornerRadius(WidgetDimens.containerCornerRadius), + ) { + Column( + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier + .fillMaxSize() + .padding(WidgetDimens.containerPadding), + content = content, + ) + } + if (halfWidth) Spacer(GlanceModifier.defaultWeight()) + } +} + +/** One card inside the container: a value and its captions. */ +@Composable +fun WidgetTile( + palette: WidgetPalette, + modifier: GlanceModifier = GlanceModifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + paint = palette.tile, + glassDrawable = R.drawable.widget_glass_tile, + modifier = modifier.cornerRadius(WidgetDimens.tileCornerRadius), + ) { + Column( + modifier = GlanceModifier.padding( + horizontal = WidgetDimens.tilePaddingHorizontal, + vertical = WidgetDimens.tilePaddingVertical, + ), + content = content, + ) + } +} + +/** A status dot (see StationStatusView in core:ui), ringed when it would blend into the tile. */ +@Composable +fun WidgetStatusDot(status: WidgetStatus, palette: WidgetPalette) { + val ring = palette.statusDotRing(status) + val ringWidth = WidgetDimens.statusDotRingWidth + Spacer(GlanceModifier.width(6.dp - if (ring != null) ringWidth else 0.dp)) + val dot = GlanceModifier + .size(WidgetDimens.statusDotSize) + .cornerRadius(WidgetDimens.statusDotSize / 2) + .background(ColorProvider(status.color)) + if (ring == null) { + Box(dot) {} + } else { + val ringSize = WidgetDimens.statusDotSize + ringWidth * 2 + Box( + contentAlignment = Alignment.Center, + modifier = GlanceModifier + .size(ringSize) + .cornerRadius(ringSize / 2) + .background(ring.provider), + ) { + Box(dot) {} + } + } +} + +// The glass highlight fills the surface, which takes its size from the content drawn on top. +@Composable +private fun Surface( + paint: WidgetSurfacePaint, + glassDrawable: Int, + modifier: GlanceModifier, + content: @Composable () -> Unit, +) { + Box(modifier = modifier.background(paint.fill.provider)) { + if (paint.isGlass) { + Box(GlanceModifier.fillMaxSize().background(ImageProvider(glassDrawable))) {} + } + content() + } +} diff --git a/widget/core/src/androidMain/res/drawable/widget_glass_container.xml b/widget/core/src/androidMain/res/drawable/widget_glass_container.xml new file mode 100644 index 0000000..8657024 --- /dev/null +++ b/widget/core/src/androidMain/res/drawable/widget_glass_container.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/widget/core/src/androidMain/res/drawable/widget_glass_tile.xml b/widget/core/src/androidMain/res/drawable/widget_glass_tile.xml new file mode 100644 index 0000000..a37b0b9 --- /dev/null +++ b/widget/core/src/androidMain/res/drawable/widget_glass_tile.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/WidgetPublisher.kt b/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/WidgetPublisher.kt new file mode 100644 index 0000000..664030a --- /dev/null +++ b/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/WidgetPublisher.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core + +import com.ngapp.metanmobile.core.data.repository.widget.WidgetDataRepository +import com.ngapp.metanmobile.core.model.widget.WidgetData +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * Keeps every widget in step with the app's local data while the process is alive: a finished + * sync, a new user location or a changed favorite re-renders the widgets. No network involved. + */ +class WidgetPublisher( + private val widgetDataRepository: WidgetDataRepository, + private val updaters: List, +) { + fun start(scope: CoroutineScope): Job = scope.launch { + if (updaters.isEmpty()) return@launch + widgetDataRepository.observeWidgetData().collectLatest(::publish) + } + + /** Re-renders the widgets right away, e.g. after the user changed how one looks. */ + suspend fun refresh() { + publish(widgetDataRepository.observeWidgetData().first()) + } + + private suspend fun publish(data: WidgetData) { + updaters.forEach { updater -> + try { + updater.update(data) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // One broken widget shouldn't keep the others stale. + println("WidgetPublisher: update failed: ${e::class.simpleName}: ${e.message}") + } + } + } +} diff --git a/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/WidgetUpdater.kt b/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/WidgetUpdater.kt new file mode 100644 index 0000000..b8fddcd --- /dev/null +++ b/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/WidgetUpdater.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core + +import com.ngapp.metanmobile.core.model.widget.WidgetData + +/** + * Implemented once per platform widget: re-renders that widget with fresh [WidgetData]. + * Register implementations in Koin with `bind WidgetUpdater::class` so [WidgetPublisher] + * picks them all up without knowing about any particular widget. + */ +fun interface WidgetUpdater { + suspend fun update(data: WidgetData) +} diff --git a/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearance.kt b/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearance.kt new file mode 100644 index 0000000..ce3eaa9 --- /dev/null +++ b/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/appearance/WidgetAppearance.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.appearance + +/** + * How a placed widget looks, chosen per widget instance by the user. Shared by every widget, + * so they all offer the same settings: one style for the widget's background, one for its tiles. + */ +data class WidgetAppearance( + /** Decides what every AUTO color resolves to; explicit colors don't change with it. */ + val themeMode: WidgetThemeMode = WidgetThemeMode.SYSTEM, + val background: WidgetSurfaceStyle = WidgetSurfaceStyle(), + val tiles: WidgetSurfaceStyle = WidgetSurfaceStyle(), +) + +/** SYSTEM follows the phone's dark mode; LIGHT and DARK keep the widget that way regardless. */ +enum class WidgetThemeMode { SYSTEM, LIGHT, DARK } + +/** A surface is either a plain color at some opacity, or liquid glass (which replaces both). */ +data class WidgetSurfaceStyle( + val color: WidgetColor = WidgetColor.AUTO, + /** 0 = fully transparent, 100 = solid. */ + val opacity: Int = WIDGET_PERCENT_MAX, + val glass: WidgetGlass = WidgetGlass(), +) + +/** + * Liquid glass, imitated: a tinted see-through fill with a highlight and a light rim. Android + * widgets can't blur the wallpaper behind them (the launcher draws them from RemoteViews). + */ +data class WidgetGlass( + val enabled: Boolean = false, + val tint: WidgetColor = WidgetColor.WHITE, + /** How much of [tint] the glass carries, 0..100. */ + val tintStrength: Int = 20, +) + +/** The color swatches offered in the settings. AUTO = light in the light theme, dark in the dark. */ +enum class WidgetColor { BLACK, WHITE, BLUE, AUTO } + +const val WIDGET_PERCENT_MAX = 100 diff --git a/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/di/WidgetCoreKoinModule.kt b/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/di/WidgetCoreKoinModule.kt new file mode 100644 index 0000000..6e06583 --- /dev/null +++ b/widget/core/src/commonMain/kotlin/com/ngapp/metanmobile/widget/core/di/WidgetCoreKoinModule.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core.di + +import com.ngapp.metanmobile.widget.core.WidgetPublisher +import org.koin.core.module.Module +import org.koin.dsl.module + +val widgetCoreModule: Module = module { + single { WidgetPublisher(get(), getAll()) } +} diff --git a/widget/core/src/commonTest/kotlin/com/ngapp/metanmobile/widget/core/WidgetPublisherTest.kt b/widget/core/src/commonTest/kotlin/com/ngapp/metanmobile/widget/core/WidgetPublisherTest.kt new file mode 100644 index 0000000..c4332a0 --- /dev/null +++ b/widget/core/src/commonTest/kotlin/com/ngapp/metanmobile/widget/core/WidgetPublisherTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core + +import com.ngapp.metanmobile.core.data.repository.widget.WidgetDataRepository +import com.ngapp.metanmobile.core.model.price.PriceResource +import com.ngapp.metanmobile.core.model.widget.WidgetData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WidgetPublisherTest { + + private val repository = FakeWidgetDataRepository() + + @Test + fun startingPushesTheCurrentDataToEveryWidget() = runTest { + val first = RecordingUpdater() + val second = RecordingUpdater() + + startPublisher(first, second) + + assertEquals(listOf(emptyData), first.received) + assertEquals(listOf(emptyData), second.received) + } + + @Test + fun widgetsAreUpdatedWhenLocalDataChanges() = runTest { + val updater = RecordingUpdater() + startPublisher(updater) + + val withPrice = emptyData.copy(cngPrice = PriceResource.init().copy(content = "1.16")) + repository.data.value = withPrice + runCurrent() + + assertEquals(listOf(emptyData, withPrice), updater.received) + } + + @Test + fun oneFailingWidgetDoesNotKeepTheOthersStale() = runTest { + val healthy = RecordingUpdater() + + startPublisher(WidgetUpdater { error("broken widget") }, healthy) + + assertEquals(listOf(emptyData), healthy.received) + } + + @Test + fun withoutWidgetsNothingIsObserved() = runTest { + startPublisher() + + assertEquals(0, repository.subscriptions) + } + + @Test + fun refreshPushesTheCurrentDataAgain() = runTest { + val updater = RecordingUpdater() + val publisher = WidgetPublisher(repository, listOf(updater)) + + publisher.refresh() + + assertEquals(listOf(emptyData), updater.received) + } + + // runCurrent, not advanceUntilIdle: the latter doesn't run backgroundScope work. + private fun TestScope.startPublisher(vararg updaters: WidgetUpdater) { + val job = WidgetPublisher(repository, updaters.toList()).start(backgroundScope) + runCurrent() + assertTrue(job.isActive || updaters.isEmpty()) + } + + private class RecordingUpdater : WidgetUpdater { + val received = mutableListOf() + + override suspend fun update(data: WidgetData) { + received += data + } + } + + private class FakeWidgetDataRepository : WidgetDataRepository { + val data = MutableStateFlow(emptyData) + var subscriptions = 0 + + override fun observeWidgetData(): Flow { + subscriptions++ + return data + } + } + + private companion object { + val emptyData = WidgetData(cngPrice = null, location = null, stations = emptyList()) + } +} diff --git a/widget/nearest-station/build.gradle.kts b/widget/nearest-station/build.gradle.kts new file mode 100644 index 0000000..4199b5b --- /dev/null +++ b/widget/nearest-station/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget + +plugins { + alias(libs.plugins.mm.kmp.library) + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.compose) +} + +kotlin { + // The widget's provider info (res/xml) is referenced from its manifest, so this module + // needs real Android resource processing, which KMP library targets have off by default. + targets.withType().configureEach { + androidResources.enable = true + } + sourceSets { + // "Nearest station" home-screen widget. The Android widget (Glance) lives here; the iOS + // one is a SwiftUI WidgetKit extension in iosApp that reads the data :widget:core shares. + commonMain.dependencies { + implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composePlugin.get()}") + implementation(projects.core.data) + implementation(projects.widget.core) + } + androidMain.dependencies { + implementation(projects.resources) + implementation(projects.core.designsystem) + implementation(compose.material3) + implementation(libs.moko.core) + implementation(libs.moko.compose) + implementation(libs.koin.android) + } + // The Glance layout is tested on the JVM: glance-appwidget-testing renders it without a + // launcher, Robolectric supplies the Context its strings come from. + getByName("androidHostTest").dependencies { + implementation(libs.junit4) + implementation(libs.robolectric) + implementation(libs.androidx.test.core) + implementation(libs.androidx.glance.appwidget.testing) + } + } +} diff --git a/widget/nearest-station/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettingsTest.kt b/widget/nearest-station/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettingsTest.kt new file mode 100644 index 0000000..a9f528c --- /dev/null +++ b/widget/nearest-station/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettingsTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.settings + +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import com.ngapp.metanmobile.widget.core.appearance.WidgetAppearance +import com.ngapp.metanmobile.widget.core.appearance.WidgetColor +import com.ngapp.metanmobile.widget.core.appearance.WidgetSurfaceStyle +import com.ngapp.metanmobile.widget.core.appearance.WidgetThemeMode +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationTiles +import org.junit.Test +import kotlin.test.assertEquals + +class NearestStationWidgetSettingsTest { + + @Test + fun `a widget nobody configured shows both tiles with the default look`() { + assertEquals(NearestStationWidgetSettings(), mutablePreferencesOf().toNearestStationWidgetSettings()) + assertEquals(NearestStationTiles.BOTH, NearestStationWidgetSettings().tiles) + } + + @Test + fun `what is written is read back, appearance included`() { + val settings = NearestStationWidgetSettings( + appearance = WidgetAppearance( + themeMode = WidgetThemeMode.LIGHT, + tiles = WidgetSurfaceStyle(color = WidgetColor.BLUE, opacity = 70), + ), + tiles = NearestStationTiles.DISTANCE, + ) + + val preferences = mutablePreferencesOf().apply { writeNearestStationWidgetSettings(settings) } + + assertEquals(settings, preferences.toNearestStationWidgetSettings()) + } + + @Test + fun `an unknown tiles value falls back to both`() { + val preferences = mutablePreferencesOf(stringPreferencesKey("nearest_station_tiles") to "MAP") + + assertEquals(NearestStationTiles.BOTH, preferences.toNearestStationWidgetSettings().tiles) + } +} diff --git a/widget/nearest-station/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/ui/NearestStationWidgetContentTest.kt b/widget/nearest-station/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/ui/NearestStationWidgetContentTest.kt new file mode 100644 index 0000000..09a14d9 --- /dev/null +++ b/widget/nearest-station/src/androidHostTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/ui/NearestStationWidgetContentTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.glance.appwidget.testing.unit.GlanceAppWidgetUnitTest +import androidx.glance.appwidget.testing.unit.hasStartActivityClickAction +import androidx.glance.appwidget.testing.unit.runGlanceAppWidgetUnitTest +import androidx.glance.testing.unit.hasText +import androidx.test.core.app.ApplicationProvider +import com.ngapp.metanmobile.core.model.price.PriceResource +import com.ngapp.metanmobile.core.model.station.UserStationResource +import com.ngapp.metanmobile.widget.nearest.station.settings.NearestStationWidgetSettings +import com.ngapp.metanmobile.widget.nearest.station.settings.writeNearestStationWidgetSettings +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationTiles +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationWidgetUiState +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The widget's Glance layout, rendered on the JVM: which tiles a size and the settings produce, + * the fallback messages, and where a tap on the station goes. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NearestStationWidgetContentTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val station = UserStationResource.init().copy( + code = "agnks_grodno2", + address = "Grodno, Indurskoe 15", + distanceBetween = 2.36, + isOperate = 1, + ) + private val success = NearestStationWidgetUiState.Success( + cngPrice = PriceResource.init().copy(content = "1.16"), + nearestStation = station, + ) + + @Test + fun aWideWidgetShowsBothTiles() = renderWidget(success, wide = true) { + onNode(hasText(PRICE)).assertExists() + onNode(hasText(DISTANCE)).assertExists() + onNode(hasText("Grodno, Indurskoe 15")).assertExists() + } + + @Test + fun aNarrowWidgetKeepsOnlyThePrice() = renderWidget(success, wide = false) { + onNode(hasText(PRICE)).assertExists() + onNode(hasText(DISTANCE)).assertDoesNotExist() + } + + @Test + fun theChosenSingleTileIsShownEvenWhenWide() = + renderWidget(success, wide = true, tiles = NearestStationTiles.DISTANCE) { + onNode(hasText(DISTANCE)).assertExists() + onNode(hasText(PRICE)).assertDoesNotExist() + } + + @Test + fun aNarrowWidgetKeepsTheChosenDistance() = + renderWidget(success, wide = false, tiles = NearestStationTiles.DISTANCE) { + onNode(hasText(DISTANCE)).assertExists() + onNode(hasText(PRICE)).assertDoesNotExist() + } + + @Test + fun anUnknownLocationAsksToOpenTheApp() = + renderWidget(success.copy(nearestStation = null), wide = true) { + onNode(hasText(PRICE)).assertExists() + onNode(hasText("Open the app to find the nearest station")).assertExists() + } + + @Test + fun nothingSyncedYetAsksToOpenTheApp() = renderWidget(NearestStationWidgetUiState.NoData, wide = true) { + onNode(hasText("Open the app to load the data")).assertExists() + onNode(hasText(PRICE)).assertDoesNotExist() + } + + @Test + fun tappingTheStationOpensItInTheApp() = renderWidget(success, wide = true) { + val stationLink = Intent(Intent.ACTION_VIEW, Uri.parse("https://metan.by/ecogas-map/agnks_grodno2/")) + .setPackage(context.packageName) + onNode(hasStartActivityClickAction(stationLink)).assertExists() + } + + private fun renderWidget( + uiState: NearestStationWidgetUiState, + wide: Boolean, + tiles: NearestStationTiles = NearestStationTiles.BOTH, + assertions: GlanceAppWidgetUnitTest.() -> Unit, + ) = runGlanceAppWidgetUnitTest { + setContext(context) + setAppWidgetSize( + if (wide) NearestStationWidgetLayout.WIDE_SIZE else NearestStationWidgetLayout.COMPACT_SIZE, + ) + setState( + mutablePreferencesOf().apply { + writeNearestStationWidgetSettings(NearestStationWidgetSettings(tiles = tiles)) + }, + ) + provideComposable { NearestStationWidgetContent(uiState) } + assertions() + } + + private companion object { + // English resources, the default locale under Robolectric. + const val PRICE = "1.16 BYN" + const val DISTANCE = "2,4 KM" + } +} diff --git a/widget/nearest-station/src/androidMain/AndroidManifest.xml b/widget/nearest-station/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..976a4bf --- /dev/null +++ b/widget/nearest-station/src/androidMain/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidget.kt b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidget.kt new file mode 100644 index 0000000..22ec628 --- /dev/null +++ b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidget.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import com.ngapp.metanmobile.core.data.repository.widget.WidgetDataRepository +import com.ngapp.metanmobile.widget.nearest.station.state.toNearestStationWidgetUiState +import com.ngapp.metanmobile.widget.nearest.station.ui.NearestStationWidgetContent +import com.ngapp.metanmobile.widget.nearest.station.ui.NearestStationWidgetLayout +import kotlinx.coroutines.flow.first +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class NearestStationWidget : GlanceAppWidget(), KoinComponent { + + private val widgetDataRepository: WidgetDataRepository by inject() + + // Both layouts are handed to the launcher up front, so resizing switches between one and + // two tiles instantly, without a round trip through the app. + override val sizeMode: SizeMode = SizeMode.Responsive( + setOf(NearestStationWidgetLayout.COMPACT_SIZE, NearestStationWidgetLayout.WIDE_SIZE), + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + // Read once per render: WidgetPublisher re-renders the widget whenever this data changes. + val uiState = widgetDataRepository.observeWidgetData().first() + .toNearestStationWidgetUiState() + provideContent { + NearestStationWidgetContent(uiState) + } + } +} + +class NearestStationWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = NearestStationWidget() +} diff --git a/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetUpdater.kt b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetUpdater.kt new file mode 100644 index 0000000..de28e30 --- /dev/null +++ b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetUpdater.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station + +import android.content.Context +import androidx.glance.appwidget.updateAll +import com.ngapp.metanmobile.core.model.widget.WidgetData +import com.ngapp.metanmobile.widget.core.WidgetUpdater + +internal class NearestStationWidgetUpdater( + private val context: Context, +) : WidgetUpdater { + // No-op when the user hasn't placed this widget anywhere. + override suspend fun update(data: WidgetData) { + NearestStationWidget().updateAll(context) + } +} diff --git a/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/di/NearestStationWidgetKoinModule.kt b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/di/NearestStationWidgetKoinModule.kt new file mode 100644 index 0000000..7089afc --- /dev/null +++ b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/di/NearestStationWidgetKoinModule.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.di + +import com.ngapp.metanmobile.widget.core.WidgetUpdater +import com.ngapp.metanmobile.widget.nearest.station.NearestStationWidgetUpdater +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module + +val nearestStationWidgetModule: Module = module { + single { NearestStationWidgetUpdater(androidContext()) } bind WidgetUpdater::class +} diff --git a/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettings.kt b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettings.kt new file mode 100644 index 0000000..7afc667 --- /dev/null +++ b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettings.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.settings + +import android.content.Context +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import com.ngapp.metanmobile.widget.core.appearance.WidgetAppearance +import com.ngapp.metanmobile.widget.core.appearance.enumValueOrNull +import com.ngapp.metanmobile.widget.core.appearance.toWidgetAppearance +import com.ngapp.metanmobile.widget.core.appearance.writeWidgetAppearance +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationTiles + +/** Everything the user can set on one placed "nearest station" widget. */ +data class NearestStationWidgetSettings( + val appearance: WidgetAppearance = WidgetAppearance(), + val tiles: NearestStationTiles = NearestStationTiles.BOTH, +) + +// Lives next to the shared appearance keys in the widget's own Glance state. +private val TilesKey = stringPreferencesKey("nearest_station_tiles") + +fun Preferences.toNearestStationWidgetSettings() = NearestStationWidgetSettings( + appearance = toWidgetAppearance(), + tiles = enumValueOrNull(this[TilesKey]) ?: NearestStationTiles.BOTH, +) + +suspend fun loadNearestStationWidgetSettings(context: Context, glanceId: GlanceId) = + getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) + .toNearestStationWidgetSettings() + +suspend fun saveNearestStationWidgetSettings( + context: Context, + glanceId: GlanceId, + settings: NearestStationWidgetSettings, +) { + updateAppWidgetState(context, glanceId) { it.writeNearestStationWidgetSettings(settings) } +} + +/** The write half of [toNearestStationWidgetSettings]. */ +fun MutablePreferences.writeNearestStationWidgetSettings(settings: NearestStationWidgetSettings) { + writeWidgetAppearance(settings.appearance) + this[TilesKey] = settings.tiles.name +} diff --git a/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettingsActivity.kt b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettingsActivity.kt new file mode 100644 index 0000000..7e7ede2 --- /dev/null +++ b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/settings/NearestStationWidgetSettingsActivity.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.settings + +import android.content.Context +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.glance.GlanceId +import com.ngapp.metanmobile.SharedRes +import com.ngapp.metanmobile.widget.core.settings.WidgetAppearanceSettings +import com.ngapp.metanmobile.widget.core.settings.WidgetChoiceRow +import com.ngapp.metanmobile.widget.core.settings.WidgetPreview +import com.ngapp.metanmobile.widget.core.settings.WidgetPreviewCaption +import com.ngapp.metanmobile.widget.core.settings.WidgetPreviewColors +import com.ngapp.metanmobile.widget.core.settings.WidgetPreviewStatusDot +import com.ngapp.metanmobile.widget.core.settings.WidgetPreviewTile +import com.ngapp.metanmobile.widget.core.settings.WidgetPreviewValue +import com.ngapp.metanmobile.widget.core.settings.WidgetSettingsActivity +import com.ngapp.metanmobile.widget.core.settings.WidgetSettingsHint +import com.ngapp.metanmobile.widget.core.settings.WidgetSettingsLayout +import com.ngapp.metanmobile.widget.core.theme.WidgetStatus +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationTiles +import dev.icerock.moko.resources.compose.stringResource + +class NearestStationWidgetSettingsActivity : WidgetSettingsActivity() { + + override suspend fun loadSettings(context: Context, glanceId: GlanceId) = + loadNearestStationWidgetSettings(context, glanceId) + + override suspend fun saveSettings( + context: Context, + glanceId: GlanceId, + settings: NearestStationWidgetSettings, + ) = saveNearestStationWidgetSettings(context, glanceId, settings) + + @Composable + override fun SettingsScreen( + initialSettings: NearestStationWidgetSettings, + onSave: (NearestStationWidgetSettings) -> Unit, + ) { + var settings by remember { mutableStateOf(initialSettings) } + val tiles = settings.tiles + WidgetSettingsLayout( + onSave = { onSave(settings) }, + preview = { + // The widget at its default (wide) size, exactly as the chosen options draw it. + WidgetPreview( + appearance = settings.appearance, + halfWidth = tiles != NearestStationTiles.BOTH, + ) { colors -> + if (tiles.showsPrice) PriceTilePreview(colors) + if (tiles.showsStation) StationTilePreview(colors) + } + }, + ) { + WidgetChoiceRow( + title = SharedRes.strings.widget_nearest_station_settings_text_show, + options = NearestStationTiles.entries, + selected = tiles, + optionTitle = { + when (it) { + NearestStationTiles.PRICE -> SharedRes.strings.widget_nearest_station_settings_show_price + NearestStationTiles.DISTANCE -> SharedRes.strings.widget_nearest_station_settings_show_station + NearestStationTiles.BOTH -> SharedRes.strings.widget_nearest_station_settings_show_both + } + }, + onSelect = { settings = settings.copy(tiles = it) }, + ) + WidgetSettingsHint(SharedRes.strings.widget_nearest_station_settings_hint_resize) + WidgetAppearanceSettings( + appearance = settings.appearance, + onAppearanceChange = { settings = settings.copy(appearance = it) }, + ) + } + } +} + +@Composable +private fun RowScope.PriceTilePreview(colors: WidgetPreviewColors) { + WidgetPreviewTile(colors) { + WidgetPreviewValue(stringResource(SharedRes.strings.core_ui_text_value_byn, "1,16"), colors) + WidgetPreviewCaption(stringResource(SharedRes.strings.core_ui_text_cng_price), colors) + } +} + +@Composable +private fun RowScope.StationTilePreview(colors: WidgetPreviewColors) { + WidgetPreviewTile(colors) { + WidgetPreviewValue( + text = stringResource(SharedRes.strings.core_ui_text_value_km, "2,4"), + colors = colors, + trailing = { WidgetPreviewStatusDot(WidgetStatus.OK, colors) }, + ) + WidgetPreviewCaption(stringResource(SharedRes.strings.core_ui_text_nearest_station), colors) + } +} diff --git a/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/ui/NearestStationWidgetContent.kt b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/ui/NearestStationWidgetContent.kt new file mode 100644 index 0000000..facc514 --- /dev/null +++ b/widget/nearest-station/src/androidMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/ui/NearestStationWidgetContent.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceModifier +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.text.Text +import com.ngapp.metanmobile.SharedRes +import com.ngapp.metanmobile.core.model.price.PriceResource +import com.ngapp.metanmobile.core.model.station.UserStationResource +import com.ngapp.metanmobile.widget.core.theme.WidgetDimens +import com.ngapp.metanmobile.widget.core.theme.WidgetPalette +import com.ngapp.metanmobile.widget.core.theme.WidgetStatus +import com.ngapp.metanmobile.widget.core.theme.WidgetTextStyles +import com.ngapp.metanmobile.widget.core.theme.palette +import com.ngapp.metanmobile.widget.core.ui.WidgetContainer +import com.ngapp.metanmobile.widget.core.ui.WidgetStatusDot +import com.ngapp.metanmobile.widget.core.ui.WidgetTile +import com.ngapp.metanmobile.widget.nearest.station.settings.toNearestStationWidgetSettings +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationTiles +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationWidgetUiState +import com.ngapp.metanmobile.widget.nearest.station.state.formatDistanceKm +import dev.icerock.moko.resources.StringResource + +/** The two layouts the widget switches between as it's resized (see SizeMode.Responsive). */ +internal object NearestStationWidgetLayout { + /** About 2 cells: one tile. */ + val COMPACT_SIZE = DpSize(110.dp, 40.dp) + + /** About 4 cells: both tiles fit with their texts. */ + val WIDE_SIZE = DpSize(250.dp, 40.dp) +} + +// Handled by MainActivity like any other App Link (see MetanMobileAppState.navigateToDeepLink). +private const val STATION_DEEP_LINK = "https://metan.by/ecogas-map" + +@Composable +internal fun NearestStationWidgetContent(uiState: NearestStationWidgetUiState) { + val context = LocalContext.current + val settings = currentState().toNearestStationWidgetSettings() + val palette = settings.appearance.palette() + // The user's choice, narrowed to one tile when the widget is too narrow for two. + val isWide = LocalSize.current.width >= NearestStationWidgetLayout.WIDE_SIZE.width + val tiles = settings.tiles.tilesFor(isWide) + val showsPrice = tiles.showsPrice + val showsStation = tiles.showsStation + WidgetContainer( + palette = palette, + modifier = GlanceModifier.clickable(openAppAction(context)), + // One tile in a wide widget takes half of it instead of stretching across. + halfWidth = isWide && tiles != NearestStationTiles.BOTH, + ) { + when (uiState) { + NearestStationWidgetUiState.NoData -> WidgetTile(palette, GlanceModifier.fillMaxWidth()) { + Caption(context.string(SharedRes.strings.widget_text_no_data), palette, maxLines = 3) + } + + is NearestStationWidgetUiState.Success -> Row(modifier = GlanceModifier.fillMaxWidth()) { + if (showsPrice) { + PriceTile( + cngPrice = uiState.cngPrice, + palette = palette, + modifier = GlanceModifier.defaultWeight(), + ) + } + if (showsPrice && showsStation) { + Spacer(GlanceModifier.width(WidgetDimens.tileSpacing)) + } + if (showsStation) { + StationTile( + nearestStation = uiState.nearestStation, + palette = palette, + modifier = GlanceModifier.defaultWeight(), + ) + } + } + } + } +} + +@Composable +private fun PriceTile(cngPrice: PriceResource?, palette: WidgetPalette, modifier: GlanceModifier) { + val context = LocalContext.current + WidgetTile(palette, modifier) { + Value(context.string(SharedRes.strings.core_ui_text_value_byn, cngPrice?.content ?: "—"), palette) + Caption(context.string(SharedRes.strings.core_ui_text_cng_price), palette) + Caption(context.string(SharedRes.strings.core_ui_text_for_one_meter), palette) + } +} + +@Composable +private fun StationTile(nearestStation: UserStationResource?, palette: WidgetPalette, modifier: GlanceModifier) { + val context = LocalContext.current + if (nearestStation == null) { + // Location unknown: the app asks for the permission / finds the location on open. + WidgetTile(palette, modifier) { + Caption( + context.string(SharedRes.strings.widget_nearest_station_text_no_location), + palette, + maxLines = 4, + ) + } + return + } + WidgetTile(palette, modifier.clickable(openStationAction(context, nearestStation.code))) { + Row(verticalAlignment = Alignment.CenterVertically) { + Value( + context.string( + SharedRes.strings.core_ui_text_value_km, + formatDistanceKm(nearestStation.distanceBetween ?: 0.0), + ), + palette, + ) + WidgetStatusDot(nearestStation.widgetStatus, palette) + } + Caption(context.string(SharedRes.strings.core_ui_text_nearest_station), palette) + Caption(nearestStation.address, palette) + } +} + +@Composable +private fun Value(text: String, palette: WidgetPalette) { + Text(text = text, style = WidgetTextStyles.value(palette.onTile.provider), maxLines = 1) +} + +@Composable +private fun Caption(text: String, palette: WidgetPalette, maxLines: Int = 1) { + Text( + text = text, + style = WidgetTextStyles.caption(palette.onTile.provider), + maxLines = maxLines, + modifier = GlanceModifier.padding(top = 2.dp), + ) +} + +internal val UserStationResource.widgetStatus: WidgetStatus + get() = when (isOperate) { + 1 -> WidgetStatus.OK + 0 -> WidgetStatus.PROBLEM + else -> WidgetStatus.UNKNOWN + } + +private fun openAppAction(context: Context): Action { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: Intent().setPackage(context.packageName) + return actionStartActivity(intent) +} + +private fun openStationAction(context: Context, stationCode: String): Action = + actionStartActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("$STATION_DEEP_LINK/$stationCode/")) + .setPackage(context.packageName), + ) + +private fun Context.string(resource: StringResource, vararg args: Any): String = + getString(resource.resourceId, *args) diff --git a/widget/nearest-station/src/androidMain/res/xml/nearest_station_widget_info.xml b/widget/nearest-station/src/androidMain/res/xml/nearest_station_widget_info.xml new file mode 100644 index 0000000..d7c2a09 --- /dev/null +++ b/widget/nearest-station/src/androidMain/res/xml/nearest_station_widget_info.xml @@ -0,0 +1,38 @@ + + + + + diff --git a/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/state/NearestStationTiles.kt b/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/state/NearestStationTiles.kt new file mode 100644 index 0000000..5fc57df --- /dev/null +++ b/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/state/NearestStationTiles.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.state + +/** + * Which tiles the user wants. The widget's size can still drop one: a narrow widget only has + * room for a single tile, so BOTH falls back to the price there (see [tilesFor]). + */ +enum class NearestStationTiles(val showsPrice: Boolean, val showsStation: Boolean) { + PRICE(showsPrice = true, showsStation = false), + DISTANCE(showsPrice = false, showsStation = true), + BOTH(showsPrice = true, showsStation = true), + ; + + /** What actually fits: a narrow widget shows one tile, the first of the chosen ones. */ + fun tilesFor(isWide: Boolean): NearestStationTiles = + if (this == BOTH && !isWide) PRICE else this +} diff --git a/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/state/NearestStationWidgetUiState.kt b/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/state/NearestStationWidgetUiState.kt new file mode 100644 index 0000000..72bccaa --- /dev/null +++ b/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/state/NearestStationWidgetUiState.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.state + +import com.ngapp.metanmobile.core.model.price.PriceResource +import com.ngapp.metanmobile.core.model.station.StationType +import com.ngapp.metanmobile.core.model.station.UserStationResource +import com.ngapp.metanmobile.core.model.widget.WidgetData +import kotlin.math.roundToInt + +sealed interface NearestStationWidgetUiState { + /** Nothing synced yet: the app was installed but hasn't loaded its data. */ + data object NoData : NearestStationWidgetUiState + + /** [nearestStation] is null while the user's location is unknown. */ + data class Success( + val cngPrice: PriceResource?, + val nearestStation: UserStationResource?, + ) : NearestStationWidgetUiState +} + +fun WidgetData.toNearestStationWidgetUiState(): NearestStationWidgetUiState { + if (cngPrice == null && stations.isEmpty()) return NearestStationWidgetUiState.NoData + return NearestStationWidgetUiState.Success( + cngPrice = cngPrice, + // Service infrastructure isn't somewhere to refuel, and refueling is what this widget is for. + nearestStation = stations + .filter { it.type != StationType.SERVICE.typeName } + .filter { it.distanceBetween != null } + .minByOrNull { it.distanceBetween!! }, + ) +} + +/** Same "2,4" format as the nearest-station card on the Home screen. */ +fun formatDistanceKm(km: Double): String { + val tenths = (km * 10).roundToInt() + return "${tenths / 10},${tenths % 10}" +} diff --git a/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationTilesTest.kt b/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationTilesTest.kt new file mode 100644 index 0000000..ec33738 --- /dev/null +++ b/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationTilesTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station + +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationTiles +import kotlin.test.Test +import kotlin.test.assertEquals + +class NearestStationTilesTest { + + @Test + fun aWideWidgetShowsWhatWasChosen() { + NearestStationTiles.entries.forEach { assertEquals(it, it.tilesFor(isWide = true)) } + } + + @Test + fun aNarrowWidgetKeepsOneTile() { + assertEquals(NearestStationTiles.PRICE, NearestStationTiles.BOTH.tilesFor(isWide = false)) + assertEquals(NearestStationTiles.PRICE, NearestStationTiles.PRICE.tilesFor(isWide = false)) + assertEquals(NearestStationTiles.DISTANCE, NearestStationTiles.DISTANCE.tilesFor(isWide = false)) + } +} diff --git a/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetUiStateTest.kt b/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetUiStateTest.kt new file mode 100644 index 0000000..345a491 --- /dev/null +++ b/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetUiStateTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station + +import com.ngapp.metanmobile.core.model.price.PriceResource +import com.ngapp.metanmobile.core.model.station.StationType +import com.ngapp.metanmobile.core.model.station.UserStationResource +import com.ngapp.metanmobile.core.model.widget.WidgetData +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationWidgetUiState +import com.ngapp.metanmobile.widget.nearest.station.state.formatDistanceKm +import com.ngapp.metanmobile.widget.nearest.station.state.toNearestStationWidgetUiState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NearestStationWidgetUiStateTest { + + @Test + fun nothingSyncedYetIsNoData() { + val uiState = WidgetData(cngPrice = null, location = null, stations = emptyList()) + .toNearestStationWidgetUiState() + + assertEquals(NearestStationWidgetUiState.NoData, uiState) + } + + @Test + fun unknownLocationKeepsThePriceButNoStation() { + val uiState = widgetData(station("a", distance = null)).toNearestStationWidgetUiState() + + uiState as NearestStationWidgetUiState.Success + assertEquals("1.06", uiState.cngPrice?.content) + assertNull(uiState.nearestStation) + } + + @Test + fun picksTheClosestStation() { + val uiState = widgetData( + station("far", distance = 12.0), + station("near", distance = 2.4), + ).toNearestStationWidgetUiState() + + assertEquals("near", (uiState as NearestStationWidgetUiState.Success).nearestStation?.code) + } + + @Test + fun skipsServiceInfrastructure() { + val uiState = widgetData( + station("service", distance = 0.5, type = StationType.SERVICE.typeName), + station("cng", distance = 3.0), + ).toNearestStationWidgetUiState() + + assertEquals("cng", (uiState as NearestStationWidgetUiState.Success).nearestStation?.code) + } + + @Test + fun distanceIsFormattedLikeOnHome() { + assertEquals("2,4", formatDistanceKm(2.36)) + assertEquals("0,0", formatDistanceKm(0.0)) + assertEquals("12,0", formatDistanceKm(11.98)) + } + + private fun widgetData(vararg stations: UserStationResource) = WidgetData( + cngPrice = PriceResource.init().copy(content = "1.06"), + location = null, + stations = stations.toList(), + ) + + private fun station( + code: String, + distance: Double?, + type: String = StationType.CNG.typeName, + ) = UserStationResource.init().copy(code = code, type = type, distanceBetween = distance) +} From 2ea502131c58fdb81e3cc9fdcb0c142634dcc753 Mon Sep 17 00:00:00 2001 From: ngapps Date: Fri, 2 Oct 2026 15:00:44 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20Feature:=20iOS=20"Price=20and?= =?UTF-8?q?=20nearest=20station"=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WidgetKit counterpart of the Android widget, fed by the same shared Kotlin pipeline: still no API calls from the widget. - Kotlin: the app writes a compact JSON snapshot (price, the already picked nearest station, texts translated into the app's language) into the App Group group.com.ngapp.metanmobile, and asks WidgetKit to reload through a Swift bridge (WidgetReloader) - only when the snapshot actually changed, since iOS rations reloads. The same WidgetPublisher as on Android drives it, started in initSharedKoin. - Swift (MetanMobileWidgetExtension, iOS 17+): small (one tile) and medium (chosen tiles) sizes, settings in the system "Edit Widget" sheet (tiles, theme, background/tile color and opacity steps), the Android palette rules ported, status dot ring, station deep links (metanmobile://ecogas-map/{code}/), iOS 26 Clear/Tinted rendering modes, en/ru/be String Catalog, Xcode previews. - Xcode: widget extension target, App Group on both targets, ru/be project localizations. Tests: snapshot mapping and its JSON contract with the Swift side, run on the JVM and the iOS simulator. The nearest-station iOS test binary pins compose foundation to the app's version (moko pulls 1.7.0). --- composeApp/build.gradle.kts | 5 + .../metanmobile/composeapp/SharedKoin.kt | 16 +- iosApp/MetanMobile.entitlements | 10 + iosApp/MetanMobile.xcodeproj/project.pbxproj | 308 +++++++++++- iosApp/MetanMobileApp/MetanMobileApp.swift | 14 +- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + iosApp/MetanMobileWidget/Info.plist | 17 + .../MetanMobileWidget/Localizable.xcstrings | 458 ++++++++++++++++++ .../MetanMobileWidgetBundle.swift | 25 + .../NearestStationSnapshot.swift | 91 ++++ .../NearestStationWidget.swift | 66 +++ .../NearestStationWidgetIntent.swift | 115 +++++ .../NearestStationWidgetView.swift | 228 +++++++++ iosApp/MetanMobileWidget/WidgetTheme.swift | 104 ++++ .../MetanMobileWidgetExtension.entitlements | 10 + .../widget/core/AppGroupWidgetFiles.kt | 60 +++ widget/nearest-station/build.gradle.kts | 12 + .../snapshot/NearestStationWidgetSnapshot.kt | 104 ++++ .../NearestStationWidgetSnapshotTest.kt | 121 +++++ .../NearestStationWidgetSnapshotWriter.kt | 75 +++ .../di/NearestStationWidgetKoinModule.kt | 30 ++ 24 files changed, 1926 insertions(+), 6 deletions(-) create mode 100644 iosApp/MetanMobile.entitlements create mode 100644 iosApp/MetanMobileWidget/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 iosApp/MetanMobileWidget/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iosApp/MetanMobileWidget/Assets.xcassets/Contents.json create mode 100644 iosApp/MetanMobileWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 iosApp/MetanMobileWidget/Info.plist create mode 100644 iosApp/MetanMobileWidget/Localizable.xcstrings create mode 100644 iosApp/MetanMobileWidget/MetanMobileWidgetBundle.swift create mode 100644 iosApp/MetanMobileWidget/NearestStationSnapshot.swift create mode 100644 iosApp/MetanMobileWidget/NearestStationWidget.swift create mode 100644 iosApp/MetanMobileWidget/NearestStationWidgetIntent.swift create mode 100644 iosApp/MetanMobileWidget/NearestStationWidgetView.swift create mode 100644 iosApp/MetanMobileWidget/WidgetTheme.swift create mode 100644 iosApp/MetanMobileWidgetExtension.entitlements create mode 100644 widget/core/src/iosMain/kotlin/com/ngapp/metanmobile/widget/core/AppGroupWidgetFiles.kt create mode 100644 widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/snapshot/NearestStationWidgetSnapshot.kt create mode 100644 widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetSnapshotTest.kt create mode 100644 widget/nearest-station/src/iosMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetSnapshotWriter.kt create mode 100644 widget/nearest-station/src/iosMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/di/NearestStationWidgetKoinModule.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index fc63603..ae90613 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -22,6 +22,9 @@ kotlin { // the framework's generated Objective-C header so MobileAdsBridge.swift can actually // implement it - same "export needs an api dependency" requirement as above. export(projects.core.ui) + // widget:core's WidgetReloader is implemented in Swift (WidgetCenter is Swift-only), + // so it has to be visible in the framework header too. + export(projects.widget.core) } } @@ -77,6 +80,8 @@ kotlin { // for Android's own WorkManager wiring via the :app module's own dependency on it. iosMain.dependencies { implementation(projects.sync.work) + api(projects.widget.core) + implementation(projects.widget.nearestStation) } } } diff --git a/composeApp/src/iosMain/kotlin/com/ngapp/metanmobile/composeapp/SharedKoin.kt b/composeApp/src/iosMain/kotlin/com/ngapp/metanmobile/composeapp/SharedKoin.kt index ce87b8f..225e71c 100644 --- a/composeApp/src/iosMain/kotlin/com/ngapp/metanmobile/composeapp/SharedKoin.kt +++ b/composeApp/src/iosMain/kotlin/com/ngapp/metanmobile/composeapp/SharedKoin.kt @@ -35,6 +35,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.core.context.startKoin import org.koin.dsl.module +import com.ngapp.metanmobile.widget.nearest.station.di.nearestStationWidgetModule +import com.ngapp.metanmobile.widget.core.di.widgetCoreModule +import com.ngapp.metanmobile.widget.core.WidgetReloader +import com.ngapp.metanmobile.widget.core.WidgetPublisher +import com.ngapp.metanmobile.core.data.di.widgetDataModule /** * Initializes the shared graph before the SwiftUI host creates Compose content. @@ -42,8 +47,10 @@ import org.koin.dsl.module * @param nativeAdsBridge Swift's real GoogleMobileAds/UserMessagingPlatform implementation * (MobileAdsBridge.swift) - see core:ui's NativeAdsBridge.kt for why this can't just be * resolved through Koin like everything else here. + * @param widgetReloader Swift's WidgetCenter call (WidgetReloaderBridge.swift), used to redraw + * the home-screen widgets after their data in the App Group changed. */ -fun initSharedKoin(nativeAdsBridge: NativeAdsBridge) { +fun initSharedKoin(nativeAdsBridge: NativeAdsBridge, widgetReloader: WidgetReloader) { registerNativeAdsBridge(nativeAdsBridge) configureImageLoader() val koinApp = startKoin { @@ -86,12 +93,19 @@ fun initSharedKoin(nativeAdsBridge: NativeAdsBridge) { featureContactsModule, featureFaqModule, featurePrivacyPolicyModule, + module { single { widgetReloader } }, + widgetDataModule, + widgetCoreModule, + nearestStationWidgetModule, ) } // Mirrors Android's Sync.initialize(context) call right after startKoin{} in // MetanMobileApplication.onCreate() — same "sync once at app launch" behavior, just reached // through the SyncManager Koin already built instead of going straight to WorkManager. koinApp.koin.get().requestSync() + // Mirrors Android's Widgets.initialize(): keeps the widgets' App Group data in step with the + // local database for as long as the app runs (foreground or a background sync). + koinApp.koin.get().start(CoroutineScope(SupervisorJob() + Dispatchers.Default)) // BGTaskScheduler.registerForTaskWithIdentifier must run before the end of App.init() (this // SwiftUI app has no AppDelegate, so init() is the equivalent of applicationDidFinishLaunching) // — initSharedKoin() is called from exactly there (MetanMobileApp.swift), so this is the diff --git a/iosApp/MetanMobile.entitlements b/iosApp/MetanMobile.entitlements new file mode 100644 index 0000000..b6d7d23 --- /dev/null +++ b/iosApp/MetanMobile.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.ngapp.metanmobile + + + diff --git a/iosApp/MetanMobile.xcodeproj/project.pbxproj b/iosApp/MetanMobile.xcodeproj/project.pbxproj index 1c37ace..d3cd5d0 100644 --- a/iosApp/MetanMobile.xcodeproj/project.pbxproj +++ b/iosApp/MetanMobile.xcodeproj/project.pbxproj @@ -3,20 +3,52 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ 49CD5C84EB9425A9FEEE64AB /* GoogleUserMessagingPlatform in Frameworks */ = {isa = PBXBuildFile; productRef = EBA130AC801A4EF978A55FC3 /* GoogleUserMessagingPlatform */; }; 60A1B8F8958CE4CF8F2CCBBE /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 6F9E297E26986DF1807E8564 /* GoogleMobileAds */; }; - F4B7081A9D3C617E2B5A4E90 /* YandexMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = F4B7081B9D3C617E2B5A4E90 /* YandexMobileAds */; }; 72B72A1852479A8B9A20444B /* MobileAdsBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF2552AAA736136ED28D7BF3 /* MobileAdsBridge.swift */; }; + 9E8E73BA306FD3710026D467 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9E8E73B9306FD3710026D467 /* WidgetKit.framework */; }; + 9E8E73BC306FD3710026D467 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9E8E73BB306FD3710026D467 /* SwiftUI.framework */; }; + 9E8E73CD306FD3730026D467 /* MetanMobileWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9E8E73B7306FD3710026D467 /* MetanMobileWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A10000010000000000000001 /* MetanMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000110000000000000011 /* MetanMobileApp.swift */; }; A10000020000000000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000120000000000000012 /* ContentView.swift */; }; A10000030000000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000130000000000000013 /* Assets.xcassets */; }; + F4B7081A9D3C617E2B5A4E90 /* YandexMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = F4B7081B9D3C617E2B5A4E90 /* YandexMobileAds */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 9E8E73CB306FD3730026D467 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10000410000000000000041 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9E8E73B6306FD3710026D467; + remoteInfo = MetanMobileWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9E8E73CE306FD3730026D467 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 9E8E73CD306FD3730026D467 /* MetanMobileWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ + 9E8E73B7306FD3710026D467 /* MetanMobileWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MetanMobileWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 9E8E73B9306FD3710026D467 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 9E8E73BB306FD3710026D467 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 9E8E73DF306FD9030026D467 /* MetanMobileWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MetanMobileWidgetExtension.entitlements; sourceTree = ""; }; + 9E8E73E0306FD9320026D467 /* MetanMobile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MetanMobile.entitlements; sourceTree = ""; }; A10000100000000000000010 /* MetanMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MetanMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; A10000110000000000000011 /* MetanMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetanMobileApp.swift; sourceTree = ""; }; A10000120000000000000012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -25,7 +57,30 @@ EF2552AAA736136ED28D7BF3 /* MobileAdsBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileAdsBridge.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 9E8E73D1306FD3740026D467 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 9E8E73B6306FD3710026D467 /* MetanMobileWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 9E8E73BD306FD3710026D467 /* MetanMobileWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9E8E73D1306FD3740026D467 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MetanMobileWidget; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ + 9E8E73B4306FD3710026D467 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9E8E73BC306FD3710026D467 /* SwiftUI.framework in Frameworks */, + 9E8E73BA306FD3710026D467 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000200000000000000020 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -39,10 +94,23 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9E8E73B8306FD3710026D467 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9E8E73B9306FD3710026D467 /* WidgetKit.framework */, + 9E8E73BB306FD3710026D467 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; A10000300000000000000030 = { isa = PBXGroup; children = ( + 9E8E73E0306FD9320026D467 /* MetanMobile.entitlements */, + 9E8E73DF306FD9030026D467 /* MetanMobileWidgetExtension.entitlements */, A10000310000000000000031 /* MetanMobileApp */, + 9E8E73BD306FD3710026D467 /* MetanMobileWidget */, + 9E8E73B8306FD3710026D467 /* Frameworks */, A10000320000000000000032 /* Products */, ); sourceTree = ""; @@ -63,6 +131,7 @@ isa = PBXGroup; children = ( A10000100000000000000010 /* MetanMobile.app */, + 9E8E73B7306FD3710026D467 /* MetanMobileWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -70,6 +139,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 9E8E73B6306FD3710026D467 /* MetanMobileWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9E8E73D2306FD3740026D467 /* Build configuration list for PBXNativeTarget "MetanMobileWidgetExtension" */; + buildPhases = ( + 9E8E73B3306FD3710026D467 /* Sources */, + 9E8E73B4306FD3710026D467 /* Frameworks */, + 9E8E73B5306FD3710026D467 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 9E8E73BD306FD3710026D467 /* MetanMobileWidget */, + ); + name = MetanMobileWidgetExtension; + packageProductDependencies = ( + ); + productName = MetanMobileWidgetExtension; + productReference = 9E8E73B7306FD3710026D467 /* MetanMobileWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; A10000400000000000000040 /* MetanMobile */ = { isa = PBXNativeTarget; buildConfigurationList = A10000800000000000000080 /* Build configuration list for PBXNativeTarget "MetanMobile" */; @@ -79,10 +170,12 @@ A10000200000000000000020 /* Frameworks */, A10000600000000000000060 /* Resources */, 1013E7AC8C0E9E6A068E770F /* Inject Ads Secrets */, + 9E8E73CE306FD3730026D467 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 9E8E73CC306FD3730026D467 /* PBXTargetDependency */, ); name = MetanMobile; packageProductDependencies = ( @@ -100,8 +193,12 @@ A10000410000000000000041 /* Project object */ = { isa = PBXProject; attributes = { + LastSwiftUpdateCheck = 2700; LastUpgradeCheck = 1600; TargetAttributes = { + 9E8E73B6306FD3710026D467 = { + CreatedOnToolsVersion = 27.0; + }; A10000400000000000000040 = { CreatedOnToolsVersion = 16.0; }; @@ -114,6 +211,8 @@ knownRegions = ( en, Base, + ru, + be, ); mainGroup = A10000300000000000000030; packageReferences = ( @@ -126,11 +225,19 @@ projectRoot = ""; targets = ( A10000400000000000000040 /* MetanMobile */, + 9E8E73B6306FD3710026D467 /* MetanMobileWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 9E8E73B5306FD3710026D467 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000600000000000000060 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -177,6 +284,13 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 9E8E73B3306FD3710026D467 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000500000000000000050 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -189,13 +303,186 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 9E8E73CC306FD3730026D467 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9E8E73B6306FD3710026D467 /* MetanMobileWidgetExtension */; + targetProxy = 9E8E73CB306FD3730026D467 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 9E8E73CF306FD3730026D467 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = MetanMobileWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = K4VJG4673X; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MetanMobileWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MetanMobileWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.ngapp.metanmobile.ios.MetanMobileWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9E8E73D0306FD3730026D467 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = MetanMobileWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = K4VJG4673X; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MetanMobileWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = MetanMobileWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.ngapp.metanmobile.ios.MetanMobileWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; A10001000000000000000010 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = MetanMobile.entitlements; CURRENT_PROJECT_VERSION = 2; - MARKETING_VERSION = 1.0.1; DEVELOPMENT_TEAM = K4VJG4673X; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; @@ -208,6 +495,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 1.0.1; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ( "-framework", @@ -216,6 +504,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.ngapp.metanmobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_VERSION = 5.0; @@ -227,8 +516,8 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = MetanMobile.entitlements; CURRENT_PROJECT_VERSION = 2; - MARKETING_VERSION = 1.0.1; DEVELOPMENT_TEAM = K4VJG4673X; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; @@ -241,6 +530,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 1.0.1; OTHER_LDFLAGS = ( "-framework", MetanMobileComposeApp, @@ -248,6 +538,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.ngapp.metanmobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_VERSION = 5.0; @@ -280,6 +571,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 9E8E73D2306FD3740026D467 /* Build configuration list for PBXNativeTarget "MetanMobileWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9E8E73CF306FD3730026D467 /* Debug */, + 9E8E73D0306FD3730026D467 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; A10000800000000000000080 /* Build configuration list for PBXNativeTarget "MetanMobile" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/iosApp/MetanMobileApp/MetanMobileApp.swift b/iosApp/MetanMobileApp/MetanMobileApp.swift index d55c827..986031c 100644 --- a/iosApp/MetanMobileApp/MetanMobileApp.swift +++ b/iosApp/MetanMobileApp/MetanMobileApp.swift @@ -1,10 +1,14 @@ import SwiftUI +import WidgetKit import MetanMobileComposeApp @main struct MetanMobileApp: App { init() { - SharedKoinKt.doInitSharedKoin(nativeAdsBridge: MobileAdsBridge()) + SharedKoinKt.doInitSharedKoin( + nativeAdsBridge: MobileAdsBridge(), + widgetReloader: WidgetReloaderBridge() + ) } var body: some Scene { @@ -16,3 +20,11 @@ struct MetanMobileApp: App { } } } + +/// Lets the shared Kotlin code redraw the home-screen widgets after it wrote their data into the +/// App Group: WidgetCenter is a Swift-only API (see widget:core's WidgetReloader). +final class WidgetReloaderBridge: WidgetReloader { + func reloadAllWidgets() { + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/iosApp/MetanMobileWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/MetanMobileWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iosApp/MetanMobileWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/MetanMobileWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/MetanMobileWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/iosApp/MetanMobileWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/MetanMobileWidget/Assets.xcassets/Contents.json b/iosApp/MetanMobileWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/iosApp/MetanMobileWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/MetanMobileWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/iosApp/MetanMobileWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/iosApp/MetanMobileWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosApp/MetanMobileWidget/Info.plist b/iosApp/MetanMobileWidget/Info.plist new file mode 100644 index 0000000..4b39d18 --- /dev/null +++ b/iosApp/MetanMobileWidget/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleLocalizations + + en + ru + be + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/iosApp/MetanMobileWidget/Localizable.xcstrings b/iosApp/MetanMobileWidget/Localizable.xcstrings new file mode 100644 index 0000000..5064de3 --- /dev/null +++ b/iosApp/MetanMobileWidget/Localizable.xcstrings @@ -0,0 +1,458 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "0%" : { + "comment" : "Display representation of opacity 0%.", + "isCommentAutoGenerated" : true + }, + "25%" : { + "comment" : "Display representation of the opacity value 25%.", + "isCommentAutoGenerated" : true + }, + "50%" : { + "comment" : "Display representation of the opacity level \"half\".", + "isCommentAutoGenerated" : true + }, + "75%" : { + "comment" : "Display representation of the opacity value of 75%.", + "isCommentAutoGenerated" : true + }, + "100%" : { + "comment" : "Display representation of opacity 100%.", + "isCommentAutoGenerated" : true + }, + "Auto (follows theme)" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Аўта (па тэме)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Авто (по теме)" + } + } + } + }, + "Background color" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Колер фону" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет фона" + } + } + } + }, + "Background opacity" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непразрыстасць фону" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непрозрачность фона" + } + } + } + }, + "Black" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чорны" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чёрный" + } + } + } + }, + "Blue" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сіні" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Синий" + } + } + } + }, + "Both" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Абедзве" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обе" + } + } + } + }, + "CNG price" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кошт метану" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цена метана" + } + } + } + }, + "CNG price and the CNG station nearest to you" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кошт метану і бліжэйшая да вас АГНКС" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цена метана и ближайшая к вам АГНКС" + } + } + } + }, + "Color" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Колер" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет" + } + } + } + }, + "Dark" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цёмная" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тёмная" + } + } + } + }, + "Distance" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адлегласць" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расстояние" + } + } + } + }, + "For 1 sq. m" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "За 1 куб. метр" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "За 1 куб. метр" + } + } + } + }, + "Light" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Светлая" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Светлая" + } + } + } + }, + "Nearest station" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Бліжэйшая станцыя" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ближайшая станция" + } + } + } + }, + "Opacity" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непразрыстасць" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непрозрачность" + } + } + } + }, + "Open the app to find the nearest station" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адкрыйце праграму, каб знайсці бліжэйшую станцыю" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Откройте приложение, чтобы найти ближайшую станцию" + } + } + } + }, + "Open the app to load the data" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адкрыйце праграму, каб загрузіць даныя" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Откройте приложение, чтобы загрузить данные" + } + } + } + }, + "Price" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кошт" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цена" + } + } + } + }, + "Price and nearest station" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кошт і бліжэйшая станцыя" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цена и ближайшая станция" + } + } + } + }, + "Show" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Паказваць" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать" + } + } + } + }, + "System" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Як у сістэме" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как в системе" + } + } + } + }, + "Theme" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тэма" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема" + } + } + } + }, + "Tile color" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Колер плітак" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет плиток" + } + } + } + }, + "Tile opacity" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непразрыстасць плітак" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непрозрачность плиток" + } + } + } + }, + "Tiles" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пліткі" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плитки" + } + } + } + }, + "White" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Белы" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Белый" + } + } + } + }, + "Widget appearance" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выгляд віджэта" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешний вид виджета" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/iosApp/MetanMobileWidget/MetanMobileWidgetBundle.swift b/iosApp/MetanMobileWidget/MetanMobileWidgetBundle.swift new file mode 100644 index 0000000..8e1860e --- /dev/null +++ b/iosApp/MetanMobileWidget/MetanMobileWidgetBundle.swift @@ -0,0 +1,25 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import WidgetKit + +@main +struct MetanMobileWidgetBundle: WidgetBundle { + var body: some Widget { + NearestStationWidget() + } +} diff --git a/iosApp/MetanMobileWidget/NearestStationSnapshot.swift b/iosApp/MetanMobileWidget/NearestStationSnapshot.swift new file mode 100644 index 0000000..8da699d --- /dev/null +++ b/iosApp/MetanMobileWidget/NearestStationSnapshot.swift @@ -0,0 +1,91 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Shared with the app (both targets have this App Group in Signing & Capabilities). Must match +/// WIDGET_APP_GROUP in widget:core. +let widgetAppGroup = "group.com.ngapp.metanmobile" + +/// What the app writes for this widget (NearestStationWidgetSnapshot in :widget:nearest-station). +/// The field names are a contract with that Kotlin class: change both together. Texts arrive +/// already translated into the app's language and the nearest station is already picked. +struct NearestStationSnapshot: Codable, Equatable { + /// Nil until the app has synced its data at least once. + let price: Price? + /// Nil while the user's location is unknown. + let station: Station? + let texts: Texts + + struct Price: Codable, Equatable { + let value: String + } + + struct Station: Codable, Equatable { + let code: String + let distance: String + let address: String + let status: Status + let url: String + } + + enum Status: String, Codable { + case operating = "OPERATING" + case notOperating = "NOT_OPERATING" + case unknown = "UNKNOWN" + } + + struct Texts: Codable, Equatable { + let cngPrice: String + let perCubicMeter: String + let nearestStation: String + let noLocation: String + let noData: String + } + + /// Written by the app's NearestStationWidgetSnapshotWriter. + static let fileName = "nearest-station.json" + + /// The app's latest data, or nil if it hasn't run (or synced) since the widget was added. + static func load() -> NearestStationSnapshot? { + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: widgetAppGroup + ), + let data = try? Data(contentsOf: container.appendingPathComponent(fileName)) + else { return nil } + return try? JSONDecoder().decode(NearestStationSnapshot.self, from: data) + } + + /// For the widget gallery, placeholders and Xcode previews. + static let sample = NearestStationSnapshot( + price: Price(value: "1,16 BYN"), + station: Station( + code: "agnks_grodno2", + distance: "2,4 km", + address: "г. Гродно, Индурское шоссе, 15", + status: .operating, + url: "metanmobile://ecogas-map/agnks_grodno2/" + ), + texts: Texts( + cngPrice: String(localized: "CNG price"), + perCubicMeter: String(localized: "For 1 sq. m"), + nearestStation: String(localized: "Nearest station"), + noLocation: String(localized: "Open the app to find the nearest station"), + noData: String(localized: "Open the app to load the data") + ) + ) +} diff --git a/iosApp/MetanMobileWidget/NearestStationWidget.swift b/iosApp/MetanMobileWidget/NearestStationWidget.swift new file mode 100644 index 0000000..7150360 --- /dev/null +++ b/iosApp/MetanMobileWidget/NearestStationWidget.swift @@ -0,0 +1,66 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import WidgetKit + +/// "Price and nearest station": the iOS counterpart of the Android NearestStationWidget. Small +/// shows one tile, medium shows the tiles chosen in its settings. +struct NearestStationWidget: Widget { + /// Never change it: placed widgets are matched to the code by this kind. + static let kind = "NearestStationWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: Self.kind, + intent: NearestStationWidgetIntent.self, + provider: NearestStationProvider() + ) { entry in + NearestStationWidgetView(entry: entry) + } + .configurationDisplayName("Price and nearest station") + .description("CNG price and the CNG station nearest to you") + .supportedFamilies([.systemSmall, .systemMedium]) + // Same 12pt padding as the Android widget instead of the system's wider margins. + .contentMarginsDisabled() + } +} + +struct NearestStationEntry: TimelineEntry { + let date: Date + /// Nil until the app has written its data (it hasn't run since the widget was added). + let snapshot: NearestStationSnapshot? + let configuration: NearestStationWidgetIntent +} + +/// The data only changes when the app writes a new snapshot, and the app then asks WidgetKit to +/// reload (WidgetReloaderBridge) - so there's nothing to schedule here: `.never`. +struct NearestStationProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> NearestStationEntry { + NearestStationEntry(date: .now, snapshot: .sample, configuration: NearestStationWidgetIntent()) + } + + func snapshot(for configuration: NearestStationWidgetIntent, in context: Context) async -> NearestStationEntry { + // The widget gallery shows sample data rather than an empty widget. + let snapshot = NearestStationSnapshot.load() ?? (context.isPreview ? .sample : nil) + return NearestStationEntry(date: .now, snapshot: snapshot, configuration: configuration) + } + + func timeline(for configuration: NearestStationWidgetIntent, in context: Context) async -> Timeline { + let entry = NearestStationEntry(date: .now, snapshot: NearestStationSnapshot.load(), configuration: configuration) + return Timeline(entries: [entry], policy: .never) + } +} diff --git a/iosApp/MetanMobileWidget/NearestStationWidgetIntent.swift b/iosApp/MetanMobileWidget/NearestStationWidgetIntent.swift new file mode 100644 index 0000000..fbf06df --- /dev/null +++ b/iosApp/MetanMobileWidget/NearestStationWidgetIntent.swift @@ -0,0 +1,115 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppIntents +import WidgetKit + +/// The widget's settings, edited through the system's "Edit Widget" sheet. Mirrors the Android +/// widget's settings screen (NearestStationWidgetSettings + WidgetAppearance), minus liquid +/// glass: on iOS 26 the system draws real glass itself (the Home Screen's Clear/Tinted styles). +struct NearestStationWidgetIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Widget appearance" + + @Parameter(title: "Show", default: .both) + var tiles: TilesOption + + @Parameter(title: "Theme", default: .system) + var theme: ThemeOption + + @Parameter(title: "Background color", default: .auto) + var backgroundColor: ColorOption + + @Parameter(title: "Background opacity", default: .full) + var backgroundOpacity: OpacityOption + + @Parameter(title: "Tile color", default: .auto) + var tileColor: ColorOption + + @Parameter(title: "Tile opacity", default: .full) + var tileOpacity: OpacityOption + + static var parameterSummary: some ParameterSummary { + Summary { + \.$tiles + \.$theme + \.$backgroundColor + \.$backgroundOpacity + \.$tileColor + \.$tileOpacity + } + } +} + +/// Which tiles to show. A small widget only has room for one: "both" keeps the price there. +enum TilesOption: String, AppEnum { + case price, distance, both + + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Tiles" + static let caseDisplayRepresentations: [TilesOption: DisplayRepresentation] = [ + .price: "Price", + .distance: "Distance", + .both: "Both", + ] +} + +/// System follows the phone's dark mode; light and dark keep the widget that way regardless. +enum ThemeOption: String, AppEnum { + case system, light, dark + + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Theme" + static let caseDisplayRepresentations: [ThemeOption: DisplayRepresentation] = [ + .system: "System", + .light: "Light", + .dark: "Dark", + ] +} + +/// The same swatches as on Android. Auto = light in the light theme, dark in the dark one. +enum ColorOption: String, AppEnum { + case auto, white, black, blue + + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Color" + static let caseDisplayRepresentations: [ColorOption: DisplayRepresentation] = [ + .auto: "Auto (follows theme)", + .white: "White", + .black: "Black", + .blue: "Blue", + ] +} + +/// The system sheet has no sliders, so opacity comes in steps. +enum OpacityOption: String, AppEnum { + case none, quarter, half, threeQuarters, full + + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Opacity" + static let caseDisplayRepresentations: [OpacityOption: DisplayRepresentation] = [ + .none: "0%", + .quarter: "25%", + .half: "50%", + .threeQuarters: "75%", + .full: "100%", + ] + + var value: Double { + switch self { + case .none: 0 + case .quarter: 0.25 + case .half: 0.5 + case .threeQuarters: 0.75 + case .full: 1 + } + } +} diff --git a/iosApp/MetanMobileWidget/NearestStationWidgetView.swift b/iosApp/MetanMobileWidget/NearestStationWidgetView.swift new file mode 100644 index 0000000..f6a4582 --- /dev/null +++ b/iosApp/MetanMobileWidget/NearestStationWidgetView.swift @@ -0,0 +1,228 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import WidgetKit + +/// The layout, matching the Android widget (NearestStationWidgetContent.kt): a container with +/// one or two tiles - the CNG price and the nearest station. +struct NearestStationWidgetView: View { + let entry: NearestStationEntry + + @Environment(\.widgetFamily) private var family + @Environment(\.colorScheme) private var systemColorScheme + /// Accented/vibrant on iOS 26's Tinted and Clear Home Screens: the system draws the glass + /// and the colors, so the widget's own fills step back. + @Environment(\.widgetRenderingMode) private var renderingMode + + private var configuration: NearestStationWidgetIntent { entry.configuration } + + private var isDark: Bool { + switch configuration.theme { + case .system: systemColorScheme == .dark + case .light: false + case .dark: true + } + } + + private var isFullColor: Bool { renderingMode == .fullColor } + + /// A small widget only has room for one tile; "both" keeps the price there. + private var tiles: TilesOption { + family == .systemSmall && configuration.tiles == .both ? .price : configuration.tiles + } + + var body: some View { + let palette = WidgetPalette(intent: configuration, isDark: isDark) + content(palette) + .padding(WidgetTokens.containerPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .containerBackground(for: .widget) { + isFullColor ? palette.container : Color.clear + } + .widgetURL(widgetURL) + } + + @ViewBuilder + private func content(_ palette: WidgetPalette) -> some View { + if let snapshot = entry.snapshot, snapshot.price != nil || snapshot.station != nil { + HStack(spacing: WidgetTokens.tileSpacing) { + if tiles != .distance { + PriceTile(snapshot: snapshot, palette: palette, isFullColor: isFullColor) + } + if tiles != .price { + stationTile(snapshot: snapshot, palette: palette) + } + // One tile in a medium widget takes half of it, as on Android. + if tiles != .both && family != .systemSmall { + Color.clear.frame(maxWidth: .infinity) + } + } + } else { + Tile(palette: palette, isFullColor: isFullColor) { + Caption( + text: entry.snapshot?.texts.noData ?? String(localized: "Open the app to load the data"), + color: palette.onTile, + lineLimit: 3 + ) + } + } + } + + @ViewBuilder + private func stationTile(snapshot: NearestStationSnapshot, palette: WidgetPalette) -> some View { + let tile = StationTile(snapshot: snapshot, palette: palette, isFullColor: isFullColor) + // Small widgets only support one tap target (widgetURL); medium ones can link the tile. + if family != .systemSmall, let url = snapshot.station.flatMap({ URL(string: $0.url) }) { + Link(destination: url) { tile } + } else { + tile + } + } + + /// The station when it's the only thing shown, otherwise just the app. + private var widgetURL: URL? { + if tiles == .distance, let station = entry.snapshot?.station { + return URL(string: station.url) + } + return URL(string: "metanmobile://") + } +} + +private struct PriceTile: View { + let snapshot: NearestStationSnapshot + let palette: WidgetPalette + let isFullColor: Bool + + var body: some View { + Tile(palette: palette, isFullColor: isFullColor) { + Value(text: snapshot.price?.value ?? "—", color: palette.onTile) + Caption(text: snapshot.texts.cngPrice, color: palette.onTile) + Caption(text: snapshot.texts.perCubicMeter, color: palette.onTile) + } + } +} + +private struct StationTile: View { + let snapshot: NearestStationSnapshot + let palette: WidgetPalette + let isFullColor: Bool + + var body: some View { + Tile(palette: palette, isFullColor: isFullColor) { + if let station = snapshot.station { + HStack(spacing: 6) { + Value(text: station.distance, color: palette.onTile) + StatusDot(status: station.status, ring: palette.statusDotRing(for: station.status)) + } + Caption(text: snapshot.texts.nearestStation, color: palette.onTile) + Caption(text: station.address, color: palette.onTile) + } else { + // Location unknown: the app asks for the permission / finds the location on open. + Caption(text: snapshot.texts.noLocation, color: palette.onTile, lineLimit: 4) + } + } + } +} + +private struct Tile: View { + let palette: WidgetPalette + let isFullColor: Bool + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 2) { content } + .padding(.horizontal, WidgetTokens.tilePaddingHorizontal) + .padding(.vertical, WidgetTokens.tilePaddingVertical) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: WidgetTokens.tileCornerRadius, style: .continuous) + .fill(isFullColor ? AnyShapeStyle(palette.tile) : AnyShapeStyle(.fill.tertiary)) + ) + } +} + +/// MMTypography.displayMedium +private struct Value: View { + let text: String + let color: Color + + var body: some View { + Text(text) + .font(.system(size: 21, weight: .bold)) + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.7) + .widgetAccentable() + } +} + +/// MMTypography.headlineMedium +private struct Caption: View { + let text: String + let color: Color + var lineLimit = 1 + + var body: some View { + Text(text) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(color) + .lineLimit(lineLimit) + } +} + +/// Mirrors StationStatusView in core:ui; ringed when it would blend into the tile. +private struct StatusDot: View { + let status: NearestStationSnapshot.Status + let ring: Color? + + var body: some View { + Circle() + .fill(status.color) + .frame(width: WidgetTokens.statusDotSize, height: WidgetTokens.statusDotSize) + .padding(ring == nil ? 0 : WidgetTokens.statusDotRingWidth) + .background(Circle().fill(ring ?? .clear)) + } +} + +#Preview("Medium", as: .systemMedium) { + NearestStationWidget() +} timeline: { + NearestStationEntry(date: .now, snapshot: .sample, configuration: NearestStationWidgetIntent()) + NearestStationEntry(date: .now, snapshot: .sample, configuration: .blueTiles) + NearestStationEntry(date: .now, snapshot: nil, configuration: NearestStationWidgetIntent()) +} + +#Preview("Small", as: .systemSmall) { + NearestStationWidget() +} timeline: { + NearestStationEntry(date: .now, snapshot: .sample, configuration: NearestStationWidgetIntent()) + NearestStationEntry(date: .now, snapshot: .sample, configuration: .distanceOnly) +} + +private extension NearestStationWidgetIntent { + static var blueTiles: NearestStationWidgetIntent { + let intent = NearestStationWidgetIntent() + intent.tileColor = .blue + return intent + } + + static var distanceOnly: NearestStationWidgetIntent { + let intent = NearestStationWidgetIntent() + intent.tiles = .distance + return intent + } +} diff --git a/iosApp/MetanMobileWidget/WidgetTheme.swift b/iosApp/MetanMobileWidget/WidgetTheme.swift new file mode 100644 index 0000000..6cbb0d9 --- /dev/null +++ b/iosApp/MetanMobileWidget/WidgetTheme.swift @@ -0,0 +1,104 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// The app's design tokens (core:designsystem Color.kt) and the widget palette rules, ported +/// from widget:core's WidgetTheme.kt so both platforms' widgets look and behave the same. +enum WidgetTokens { + static let black = Color(hex: 0x1F1F1F) + static let white = Color(hex: 0xFFFFFF) + static let blue = Color(hex: 0x009CDE) + static let red = Color(hex: 0xEB5757) + static let gray400 = Color(hex: 0x919191) + static let backgroundLight = Color(hex: 0xF5F2F5) + static let backgroundDark = Color(hex: 0x24292E) + + static let containerPadding: CGFloat = 12 + static let tileSpacing: CGFloat = 8 + static let tilePaddingHorizontal: CGFloat = 12 + static let tilePaddingVertical: CGFloat = 8 + static let tileCornerRadius: CGFloat = 12 + static let statusDotSize: CGFloat = 6 + static let statusDotRingWidth: CGFloat = 2 +} + +/// Resolved colors for one widget, for the theme it's drawn in. +struct WidgetPalette { + let container: Color + let tile: Color + let onTile: Color + /// The swatch the tiles mostly look like, to keep the status dot visible on them. + let tileLooksLike: ColorOption + + init(intent: NearestStationWidgetIntent, isDark: Bool) { + func base(_ option: ColorOption, auto: Color) -> Color { + switch option { + case .auto: auto + case .white: WidgetTokens.white + case .black: WidgetTokens.black + case .blue: WidgetTokens.blue + } + } + // Matches the app: a white/dark container with tiles a shade off it, like cards. + let autoContainer = isDark ? WidgetTokens.black : WidgetTokens.white + let autoTile = isDark ? WidgetTokens.backgroundDark : WidgetTokens.backgroundLight + + container = base(intent.backgroundColor, auto: autoContainer) + .opacity(intent.backgroundOpacity.value) + tile = base(intent.tileColor, auto: autoTile).opacity(intent.tileOpacity.value) + + // Text sits on the tile, or on whatever shows through it: the background, then the + // wallpaper - mostly dark behind see-through widgets, so white reads best there. + func looksLike(_ color: ColorOption, _ opacity: OpacityOption) -> ColorOption? { + opacity.value >= 0.5 ? color : nil + } + tileLooksLike = looksLike(intent.tileColor, intent.tileOpacity) + ?? looksLike(intent.backgroundColor, intent.backgroundOpacity) + ?? .black + switch tileLooksLike { + case .white: onTile = WidgetTokens.black + case .black, .blue: onTile = WidgetTokens.white + case .auto: onTile = isDark ? WidgetTokens.white : WidgetTokens.black + } + } + + /// A rim in the text color when the dot would disappear into a tile of its own color. + func statusDotRing(for status: NearestStationSnapshot.Status) -> Color? { + status == .operating && tileLooksLike == .blue ? onTile : nil + } +} + +extension NearestStationSnapshot.Status { + /// Mirrors StationStatusView in core:ui. + var color: Color { + switch self { + case .operating: WidgetTokens.blue + case .notOperating: WidgetTokens.red + case .unknown: WidgetTokens.gray400 + } + } +} + +extension Color { + init(hex: UInt32) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255 + ) + } +} diff --git a/iosApp/MetanMobileWidgetExtension.entitlements b/iosApp/MetanMobileWidgetExtension.entitlements new file mode 100644 index 0000000..b6d7d23 --- /dev/null +++ b/iosApp/MetanMobileWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.ngapp.metanmobile + + + diff --git a/widget/core/src/iosMain/kotlin/com/ngapp/metanmobile/widget/core/AppGroupWidgetFiles.kt b/widget/core/src/iosMain/kotlin/com/ngapp/metanmobile/widget/core/AppGroupWidgetFiles.kt new file mode 100644 index 0000000..4e92a42 --- /dev/null +++ b/widget/core/src/iosMain/kotlin/com/ngapp/metanmobile/widget/core/AppGroupWidgetFiles.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.core + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSFileManager +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.writeToURL + +/** + * The App Group shared by the app and its WidgetKit extension (both targets must have it in + * Signing & Capabilities). The extension can't run the app's Kotlin code or open its database, + * so widgets get their data as files in this group's container. + */ +const val WIDGET_APP_GROUP = "group.com.ngapp.metanmobile" + +/** + * Asks WidgetKit to redraw the widgets. WidgetCenter is a Swift-only API, so the app's Swift + * side implements this and hands it to Kotlin at startup (see initSharedKoin). + */ +fun interface WidgetReloader { + fun reloadAllWidgets() +} + +/** Writes widget data files into the [WIDGET_APP_GROUP] container. */ +class AppGroupWidgetFiles(private val groupId: String = WIDGET_APP_GROUP) { + + /** False when the container is unavailable, i.e. the App Group capability is missing. */ + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + fun write(fileName: String, content: String): Boolean { + val container = NSFileManager.defaultManager + .containerURLForSecurityApplicationGroupIdentifier(groupId) + if (container == null) { + println("AppGroupWidgetFiles: no container for $groupId - is the App Group capability set?") + return false + } + val file = container.URLByAppendingPathComponent(fileName) ?: return false + @Suppress("CAST_NEVER_SUCCEEDS") + val text = NSString.create(string = content) + return text.writeToURL(file, atomically = true, encoding = NSUTF8StringEncoding, error = null) + } +} diff --git a/widget/nearest-station/build.gradle.kts b/widget/nearest-station/build.gradle.kts index 4199b5b..ced2db3 100644 --- a/widget/nearest-station/build.gradle.kts +++ b/widget/nearest-station/build.gradle.kts @@ -21,6 +21,7 @@ plugins { alias(libs.plugins.mm.kmp.library) alias(libs.plugins.jetbrains.compose) alias(libs.plugins.compose) + alias(libs.plugins.kotlin.serialization) } kotlin { @@ -36,6 +37,17 @@ kotlin { implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composePlugin.get()}") implementation(projects.core.data) implementation(projects.widget.core) + implementation(libs.kotlinx.serialization.json) + } + iosMain.dependencies { + // :resources brings moko's compose 1.7.0 (foundation, ui, animation) transitively; on + // its own (without :composeApp forcing the app's version) that old UI stack next to + // compose-runtime 1.11 breaks linking the iOS test binary. Pin it to the version the + // app ships - foundation pulls ui and animation along. + implementation("org.jetbrains.compose.foundation:foundation:${libs.versions.composePlugin.get()}") + implementation(projects.resources) + implementation(libs.moko.core) + implementation(libs.koin.core) } androidMain.dependencies { implementation(projects.resources) diff --git a/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/snapshot/NearestStationWidgetSnapshot.kt b/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/snapshot/NearestStationWidgetSnapshot.kt new file mode 100644 index 0000000..3fb9027 --- /dev/null +++ b/widget/nearest-station/src/commonMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/snapshot/NearestStationWidgetSnapshot.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.snapshot + +import com.ngapp.metanmobile.core.model.station.UserStationResource +import com.ngapp.metanmobile.core.model.widget.WidgetData +import com.ngapp.metanmobile.widget.nearest.station.state.NearestStationWidgetUiState +import com.ngapp.metanmobile.widget.nearest.station.state.formatDistanceKm +import com.ngapp.metanmobile.widget.nearest.station.state.toNearestStationWidgetUiState +import kotlinx.serialization.Serializable + +/** + * What the iOS widget extension (SwiftUI, no Kotlin) draws, written by the app as JSON into the + * App Group. The field names are a contract with NearestStationSnapshot.swift: change both + * together. Texts come already translated into the app's language, and the nearest station is + * already picked, so the Swift side only lays things out. + */ +@Serializable +data class NearestStationWidgetSnapshot( + /** Null until the app has synced its data at least once. */ + val price: Price?, + /** Null while the user's location is unknown. */ + val station: Station?, + val texts: Texts, +) { + @Serializable + data class Price( + /** "1.16 BYN" */ + val value: String, + ) + + @Serializable + data class Station( + val code: String, + /** "2,4 km" */ + val distance: String, + val address: String, + val status: Status, + /** Opens the station in the app (see MetanMobileAppState.navigateToDeepLink). */ + val url: String, + ) + + @Serializable + enum class Status { OPERATING, NOT_OPERATING, UNKNOWN } + + @Serializable + data class Texts( + val cngPrice: String, + val perCubicMeter: String, + val nearestStation: String, + val noLocation: String, + val noData: String, + ) +} + +/** The translated strings the snapshot needs; formats take the value as their only %s. */ +data class NearestStationWidgetStrings( + val priceFormat: (String) -> String, + val distanceFormat: (String) -> String, + val texts: NearestStationWidgetSnapshot.Texts, +) + +/** iOS has no App Links without a hosted association file, so the app's custom scheme is used. */ +private const val STATION_URL = "metanmobile://ecogas-map" + +fun WidgetData.toNearestStationWidgetSnapshot( + strings: NearestStationWidgetStrings, +): NearestStationWidgetSnapshot { + val uiState = toNearestStationWidgetUiState() + val success = uiState as? NearestStationWidgetUiState.Success + return NearestStationWidgetSnapshot( + price = success?.cngPrice?.let { NearestStationWidgetSnapshot.Price(strings.priceFormat(it.content)) }, + station = success?.nearestStation?.toSnapshotStation(strings), + texts = strings.texts, + ) +} + +private fun UserStationResource.toSnapshotStation(strings: NearestStationWidgetStrings) = + NearestStationWidgetSnapshot.Station( + code = code, + distance = strings.distanceFormat(formatDistanceKm(distanceBetween ?: 0.0)), + address = address, + status = when (isOperate) { + 1 -> NearestStationWidgetSnapshot.Status.OPERATING + 0 -> NearestStationWidgetSnapshot.Status.NOT_OPERATING + else -> NearestStationWidgetSnapshot.Status.UNKNOWN + }, + url = "$STATION_URL/$code/", + ) diff --git a/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetSnapshotTest.kt b/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetSnapshotTest.kt new file mode 100644 index 0000000..0cf0b8e --- /dev/null +++ b/widget/nearest-station/src/commonTest/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetSnapshotTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station + +import com.ngapp.metanmobile.core.model.price.PriceResource +import com.ngapp.metanmobile.core.model.station.StationType +import com.ngapp.metanmobile.core.model.station.UserStationResource +import com.ngapp.metanmobile.core.model.widget.WidgetData +import com.ngapp.metanmobile.widget.nearest.station.snapshot.NearestStationWidgetSnapshot +import com.ngapp.metanmobile.widget.nearest.station.snapshot.NearestStationWidgetSnapshot.Status +import com.ngapp.metanmobile.widget.nearest.station.snapshot.NearestStationWidgetStrings +import com.ngapp.metanmobile.widget.nearest.station.snapshot.toNearestStationWidgetSnapshot +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NearestStationWidgetSnapshotTest { + + private val strings = NearestStationWidgetStrings( + priceFormat = { "$it BYN" }, + distanceFormat = { "$it km" }, + texts = NearestStationWidgetSnapshot.Texts( + cngPrice = "CNG price", + perCubicMeter = "For 1 m3", + nearestStation = "Nearest station", + noLocation = "No location", + noData = "No data", + ), + ) + + @Test + fun theNearestStationAndPriceAreReadyToShow() { + val snapshot = widgetData( + station("far", distance = 9.0), + station("near", distance = 2.36, isOperate = 0), + ).toNearestStationWidgetSnapshot(strings) + + assertEquals("1.16 BYN", snapshot.price?.value) + val station = snapshot.station!! + assertEquals("near", station.code) + assertEquals("2,4 km", station.distance) + assertEquals(Status.NOT_OPERATING, station.status) + assertEquals("metanmobile://ecogas-map/near/", station.url) + assertEquals(strings.texts, snapshot.texts) + } + + @Test + fun serviceInfrastructureIsNeverTheNearestStation() { + val snapshot = widgetData( + station("service", distance = 0.5, type = StationType.SERVICE.typeName), + station("cng", distance = 4.0), + ).toNearestStationWidgetSnapshot(strings) + + assertEquals("cng", snapshot.station?.code) + } + + @Test + fun anUnknownLocationLeavesNoStation() { + val snapshot = widgetData(station("a", distance = null)).toNearestStationWidgetSnapshot(strings) + + assertEquals("1.16 BYN", snapshot.price?.value) + assertNull(snapshot.station) + } + + @Test + fun nothingSyncedYetLeavesNeitherPriceNorStation() { + val snapshot = WidgetData(cngPrice = null, location = null, stations = emptyList()) + .toNearestStationWidgetSnapshot(strings) + + assertNull(snapshot.price) + assertNull(snapshot.station) + } + + /** The Swift widget decodes these exact names (NearestStationSnapshot.swift). */ + @Test + fun theJsonKeepsTheNamesTheSwiftWidgetReads() { + val json = Json.encodeToString( + widgetData(station("a", distance = 1.0)).toNearestStationWidgetSnapshot(strings), + ) + val root = Json.parseToJsonElement(json).jsonObject + + assertEquals(setOf("price", "station", "texts"), root.keys) + assertEquals(setOf("value"), root["price"]!!.jsonObject.keys) + assertEquals(setOf("code", "distance", "address", "status", "url"), root["station"]!!.jsonObject.keys) + assertEquals( + setOf("cngPrice", "perCubicMeter", "nearestStation", "noLocation", "noData"), + root["texts"]!!.jsonObject.keys, + ) + assertEquals("\"OPERATING\"", root["station"]!!.jsonObject["status"].toString()) + } + + private fun widgetData(vararg stations: UserStationResource) = WidgetData( + cngPrice = PriceResource.init().copy(content = "1.16"), + location = null, + stations = stations.toList(), + ) + + private fun station( + code: String, + distance: Double?, + isOperate: Int = 1, + type: String = StationType.CNG.typeName, + ) = UserStationResource.init().copy(code = code, distanceBetween = distance, isOperate = isOperate, type = type) +} diff --git a/widget/nearest-station/src/iosMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetSnapshotWriter.kt b/widget/nearest-station/src/iosMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetSnapshotWriter.kt new file mode 100644 index 0000000..97ad2a8 --- /dev/null +++ b/widget/nearest-station/src/iosMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/NearestStationWidgetSnapshotWriter.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station + +import com.ngapp.metanmobile.SharedRes +import com.ngapp.metanmobile.core.model.widget.WidgetData +import com.ngapp.metanmobile.widget.core.AppGroupWidgetFiles +import com.ngapp.metanmobile.widget.core.WidgetReloader +import com.ngapp.metanmobile.widget.core.WidgetUpdater +import com.ngapp.metanmobile.widget.nearest.station.snapshot.NearestStationWidgetSnapshot +import com.ngapp.metanmobile.widget.nearest.station.snapshot.NearestStationWidgetStrings +import com.ngapp.metanmobile.widget.nearest.station.snapshot.toNearestStationWidgetSnapshot +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.desc.Resource +import dev.icerock.moko.resources.desc.ResourceFormatted +import dev.icerock.moko.resources.desc.StringDesc +import kotlinx.serialization.json.Json + +/** Read by NearestStationProvider.swift in the widget extension. */ +internal const val NEAREST_STATION_SNAPSHOT_FILE = "nearest-station.json" + +/** + * The iOS counterpart of the Android NearestStationWidgetUpdater: writes the snapshot into the + * App Group and asks WidgetKit to redraw. WidgetKit rations redraws, so both only happen when + * the snapshot actually changed. + */ +internal class NearestStationWidgetSnapshotWriter( + private val files: AppGroupWidgetFiles, + private val reloader: WidgetReloader, +) : WidgetUpdater { + + private var lastWritten: String? = null + + override suspend fun update(data: WidgetData) { + val json = Json.encodeToString(data.toNearestStationWidgetSnapshot(localizedStrings())) + if (json == lastWritten) return + if (files.write(NEAREST_STATION_SNAPSHOT_FILE, json)) { + lastWritten = json + reloader.reloadAllWidgets() + } + } +} + +// In the app's current language, so the widget speaks the same one. +private fun localizedStrings() = NearestStationWidgetStrings( + priceFormat = { SharedRes.strings.core_ui_text_value_byn.formatted(it) }, + distanceFormat = { SharedRes.strings.core_ui_text_value_km.formatted(it) }, + texts = NearestStationWidgetSnapshot.Texts( + cngPrice = SharedRes.strings.core_ui_text_cng_price.localized(), + perCubicMeter = SharedRes.strings.core_ui_text_for_one_meter.localized(), + nearestStation = SharedRes.strings.core_ui_text_nearest_station.localized(), + noLocation = SharedRes.strings.widget_nearest_station_text_no_location.localized(), + noData = SharedRes.strings.widget_text_no_data.localized(), + ), +) + +private fun StringResource.localized(): String = StringDesc.Resource(this).localized() + +private fun StringResource.formatted(value: String): String = + StringDesc.ResourceFormatted(this, value).localized() diff --git a/widget/nearest-station/src/iosMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/di/NearestStationWidgetKoinModule.kt b/widget/nearest-station/src/iosMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/di/NearestStationWidgetKoinModule.kt new file mode 100644 index 0000000..ed8438a --- /dev/null +++ b/widget/nearest-station/src/iosMain/kotlin/com/ngapp/metanmobile/widget/nearest/station/di/NearestStationWidgetKoinModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.ngapp.metanmobile.widget.nearest.station.di + +import com.ngapp.metanmobile.widget.core.AppGroupWidgetFiles +import com.ngapp.metanmobile.widget.core.WidgetUpdater +import com.ngapp.metanmobile.widget.nearest.station.NearestStationWidgetSnapshotWriter +import org.koin.core.module.Module +import org.koin.dsl.bind +import org.koin.dsl.module + +/** Needs a WidgetReloader in the graph, provided by the app's Swift side (see initSharedKoin). */ +val nearestStationWidgetModule: Module = module { + single { NearestStationWidgetSnapshotWriter(AppGroupWidgetFiles(), get()) } bind WidgetUpdater::class +} From 4f498ffcde878039367830aa93186cd928af300c Mon Sep 17 00:00:00 2001 From: ngapps Date: Fri, 2 Oct 2026 16:02:56 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=85=20Test:=20iOS=20widget=20Swift=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MetanMobileWidgetTests target (Swift Testing, no host app) that compiles the widget's logic files directly, so it runs in seconds without building the app or its Kotlin framework. - JSON contract with the Kotlin snapshot, including a fixture captured from a simulator's App Group, every status, broken and newer files. - Palette rules, matching the Android WidgetPaletteTest: auto colors per theme, explicit colors, opacity steps, text color, status dot ring. - Widget rules: one tile in a small widget, themes, tap targets and the station link shape the app's deep link routing expects. - Every widget string translated into ru and be. The widget's decisions moved out of the SwiftUI view into NearestStationWidgetRules.swift so they can be tested; behaviour is unchanged. Shared schemes for the widget extension and its tests, so they are available outside this machine (CI). --- iosApp/MetanMobile.xcodeproj/project.pbxproj | 212 +++++++++++++++++- .../MetanMobileWidgetExtension.xcscheme | 121 ++++++++++ .../xcschemes/MetanMobileWidgetTests.xcscheme | 52 +++++ .../NearestStationSnapshot.swift | 2 +- .../NearestStationWidgetRules.swift | 57 +++++ .../NearestStationWidgetView.swift | 25 +-- .../NearestStationSnapshotTests.swift | 100 +++++++++ .../NearestStationWidgetRulesTests.swift | 64 ++++++ .../WidgetLocalizationTests.swift | 57 +++++ .../WidgetPaletteTests.swift | 134 +++++++++++ 10 files changed, 801 insertions(+), 23 deletions(-) create mode 100644 iosApp/MetanMobile.xcodeproj/xcshareddata/xcschemes/MetanMobileWidgetExtension.xcscheme create mode 100644 iosApp/MetanMobile.xcodeproj/xcshareddata/xcschemes/MetanMobileWidgetTests.xcscheme create mode 100644 iosApp/MetanMobileWidget/NearestStationWidgetRules.swift create mode 100644 iosApp/MetanMobileWidgetTests/NearestStationSnapshotTests.swift create mode 100644 iosApp/MetanMobileWidgetTests/NearestStationWidgetRulesTests.swift create mode 100644 iosApp/MetanMobileWidgetTests/WidgetLocalizationTests.swift create mode 100644 iosApp/MetanMobileWidgetTests/WidgetPaletteTests.swift diff --git a/iosApp/MetanMobile.xcodeproj/project.pbxproj b/iosApp/MetanMobile.xcodeproj/project.pbxproj index d3cd5d0..3adc267 100644 --- a/iosApp/MetanMobile.xcodeproj/project.pbxproj +++ b/iosApp/MetanMobile.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 9E8E73BB306FD3710026D467 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 9E8E73DF306FD9030026D467 /* MetanMobileWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MetanMobileWidgetExtension.entitlements; sourceTree = ""; }; 9E8E73E0306FD9320026D467 /* MetanMobile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MetanMobile.entitlements; sourceTree = ""; }; + 9E8E73E7306FE6060026D467 /* MetanMobileWidgetTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MetanMobileWidgetTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A10000100000000000000010 /* MetanMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MetanMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; A10000110000000000000011 /* MetanMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetanMobileApp.swift; sourceTree = ""; }; A10000120000000000000012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -65,10 +66,22 @@ ); target = 9E8E73B6306FD3710026D467 /* MetanMobileWidgetExtension */; }; + 9E8E73F3306FE6730026D467 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Localizable.xcstrings, + NearestStationSnapshot.swift, + NearestStationWidgetIntent.swift, + NearestStationWidgetRules.swift, + WidgetTheme.swift, + ); + target = 9E8E73E6306FE6060026D467 /* MetanMobileWidgetTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 9E8E73BD306FD3710026D467 /* MetanMobileWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9E8E73D1306FD3740026D467 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MetanMobileWidget; sourceTree = ""; }; + 9E8E73BD306FD3710026D467 /* MetanMobileWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9E8E73D1306FD3740026D467 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 9E8E73F3306FE6730026D467 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MetanMobileWidget; sourceTree = ""; }; + 9E8E73E8306FE6060026D467 /* MetanMobileWidgetTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MetanMobileWidgetTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -81,6 +94,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9E8E73E4306FE6060026D467 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000200000000000000020 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -110,6 +130,7 @@ 9E8E73DF306FD9030026D467 /* MetanMobileWidgetExtension.entitlements */, A10000310000000000000031 /* MetanMobileApp */, 9E8E73BD306FD3710026D467 /* MetanMobileWidget */, + 9E8E73E8306FE6060026D467 /* MetanMobileWidgetTests */, 9E8E73B8306FD3710026D467 /* Frameworks */, A10000320000000000000032 /* Products */, ); @@ -132,6 +153,7 @@ children = ( A10000100000000000000010 /* MetanMobile.app */, 9E8E73B7306FD3710026D467 /* MetanMobileWidgetExtension.appex */, + 9E8E73E7306FE6060026D467 /* MetanMobileWidgetTests.xctest */, ); name = Products; sourceTree = ""; @@ -161,6 +183,28 @@ productReference = 9E8E73B7306FD3710026D467 /* MetanMobileWidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 9E8E73E6306FE6060026D467 /* MetanMobileWidgetTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9E8E73EB306FE6060026D467 /* Build configuration list for PBXNativeTarget "MetanMobileWidgetTests" */; + buildPhases = ( + 9E8E73E3306FE6060026D467 /* Sources */, + 9E8E73E4306FE6060026D467 /* Frameworks */, + 9E8E73E5306FE6060026D467 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 9E8E73E8306FE6060026D467 /* MetanMobileWidgetTests */, + ); + name = MetanMobileWidgetTests; + packageProductDependencies = ( + ); + productName = MetanMobileWidgetTests; + productReference = 9E8E73E7306FE6060026D467 /* MetanMobileWidgetTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; A10000400000000000000040 /* MetanMobile */ = { isa = PBXNativeTarget; buildConfigurationList = A10000800000000000000080 /* Build configuration list for PBXNativeTarget "MetanMobile" */; @@ -199,6 +243,9 @@ 9E8E73B6306FD3710026D467 = { CreatedOnToolsVersion = 27.0; }; + 9E8E73E6306FE6060026D467 = { + CreatedOnToolsVersion = 27.0; + }; A10000400000000000000040 = { CreatedOnToolsVersion = 16.0; }; @@ -226,6 +273,7 @@ targets = ( A10000400000000000000040 /* MetanMobile */, 9E8E73B6306FD3710026D467 /* MetanMobileWidgetExtension */, + 9E8E73E6306FE6060026D467 /* MetanMobileWidgetTests */, ); }; /* End PBXProject section */ @@ -238,6 +286,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9E8E73E5306FE6060026D467 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000600000000000000060 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -291,6 +346,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9E8E73E3306FE6060026D467 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000500000000000000050 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -477,6 +539,145 @@ }; name = Release; }; + 9E8E73EC306FE6060026D467 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = K4VJG4673X; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.ngapp.metanmobile.ios.MetanMobileWidgetTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 9E8E73ED306FE6060026D467 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = K4VJG4673X; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.ngapp.metanmobile.ios.MetanMobileWidgetTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; A10001000000000000000010 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -580,6 +781,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 9E8E73EB306FE6060026D467 /* Build configuration list for PBXNativeTarget "MetanMobileWidgetTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9E8E73EC306FE6060026D467 /* Debug */, + 9E8E73ED306FE6060026D467 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; A10000800000000000000080 /* Build configuration list for PBXNativeTarget "MetanMobile" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/iosApp/MetanMobile.xcodeproj/xcshareddata/xcschemes/MetanMobileWidgetExtension.xcscheme b/iosApp/MetanMobile.xcodeproj/xcshareddata/xcschemes/MetanMobileWidgetExtension.xcscheme new file mode 100644 index 0000000..48d2711 --- /dev/null +++ b/iosApp/MetanMobile.xcodeproj/xcshareddata/xcschemes/MetanMobileWidgetExtension.xcscheme @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/MetanMobile.xcodeproj/xcshareddata/xcschemes/MetanMobileWidgetTests.xcscheme b/iosApp/MetanMobile.xcodeproj/xcshareddata/xcschemes/MetanMobileWidgetTests.xcscheme new file mode 100644 index 0000000..9cf4c8f --- /dev/null +++ b/iosApp/MetanMobile.xcodeproj/xcshareddata/xcschemes/MetanMobileWidgetTests.xcscheme @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/MetanMobileWidget/NearestStationSnapshot.swift b/iosApp/MetanMobileWidget/NearestStationSnapshot.swift index 8da699d..54b84e1 100644 --- a/iosApp/MetanMobileWidget/NearestStationSnapshot.swift +++ b/iosApp/MetanMobileWidget/NearestStationSnapshot.swift @@ -67,7 +67,7 @@ struct NearestStationSnapshot: Codable, Equatable { ), let data = try? Data(contentsOf: container.appendingPathComponent(fileName)) else { return nil } - return try? JSONDecoder().decode(NearestStationSnapshot.self, from: data) + return decode(data) } /// For the widget gallery, placeholders and Xcode previews. diff --git a/iosApp/MetanMobileWidget/NearestStationWidgetRules.swift b/iosApp/MetanMobileWidget/NearestStationWidgetRules.swift new file mode 100644 index 0000000..12fad04 --- /dev/null +++ b/iosApp/MetanMobileWidget/NearestStationWidgetRules.swift @@ -0,0 +1,57 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// The widget's decisions, kept apart from the SwiftUI layout so tests can check them directly +// (MetanMobileWidgetTests). Same rules as the Android widget. + +extension ThemeOption { + /// Whether the widget draws its dark side, given the phone's own dark mode. + func isDark(systemIsDark: Bool) -> Bool { + switch self { + case .system: systemIsDark + case .light: false + case .dark: true + } + } +} + +extension TilesOption { + /// What actually fits: a small widget shows one tile, "both" keeps the price there. + func shown(isSmall: Bool) -> TilesOption { + isSmall && self == .both ? .price : self + } +} + +/// The app for the widget as a whole. The station when it's the only thing shown: a small +/// widget has a single tap target. (Medium widgets also link the station tile on its own.) +func nearestStationWidgetURL(tiles: TilesOption, snapshot: NearestStationSnapshot?) -> URL? { + if tiles == .distance, let station = snapshot?.station { + return URL(string: station.url) + } + return URL(string: "metanmobile://") +} + +extension NearestStationSnapshot { + /// Decodes what the app wrote; nil for a missing or unreadable file. + static func decode(_ data: Data) -> NearestStationSnapshot? { + try? JSONDecoder().decode(NearestStationSnapshot.self, from: data) + } + + /// True when there's something to show: the price, the station, or both. + var hasContent: Bool { price != nil || station != nil } +} diff --git a/iosApp/MetanMobileWidget/NearestStationWidgetView.swift b/iosApp/MetanMobileWidget/NearestStationWidgetView.swift index f6a4582..2602ff1 100644 --- a/iosApp/MetanMobileWidget/NearestStationWidgetView.swift +++ b/iosApp/MetanMobileWidget/NearestStationWidgetView.swift @@ -30,20 +30,11 @@ struct NearestStationWidgetView: View { private var configuration: NearestStationWidgetIntent { entry.configuration } - private var isDark: Bool { - switch configuration.theme { - case .system: systemColorScheme == .dark - case .light: false - case .dark: true - } - } + private var isDark: Bool { configuration.theme.isDark(systemIsDark: systemColorScheme == .dark) } private var isFullColor: Bool { renderingMode == .fullColor } - /// A small widget only has room for one tile; "both" keeps the price there. - private var tiles: TilesOption { - family == .systemSmall && configuration.tiles == .both ? .price : configuration.tiles - } + private var tiles: TilesOption { configuration.tiles.shown(isSmall: family == .systemSmall) } var body: some View { let palette = WidgetPalette(intent: configuration, isDark: isDark) @@ -53,12 +44,12 @@ struct NearestStationWidgetView: View { .containerBackground(for: .widget) { isFullColor ? palette.container : Color.clear } - .widgetURL(widgetURL) + .widgetURL(nearestStationWidgetURL(tiles: tiles, snapshot: entry.snapshot)) } @ViewBuilder private func content(_ palette: WidgetPalette) -> some View { - if let snapshot = entry.snapshot, snapshot.price != nil || snapshot.station != nil { + if let snapshot = entry.snapshot, snapshot.hasContent { HStack(spacing: WidgetTokens.tileSpacing) { if tiles != .distance { PriceTile(snapshot: snapshot, palette: palette, isFullColor: isFullColor) @@ -92,14 +83,6 @@ struct NearestStationWidgetView: View { tile } } - - /// The station when it's the only thing shown, otherwise just the app. - private var widgetURL: URL? { - if tiles == .distance, let station = entry.snapshot?.station { - return URL(string: station.url) - } - return URL(string: "metanmobile://") - } } private struct PriceTile: View { diff --git a/iosApp/MetanMobileWidgetTests/NearestStationSnapshotTests.swift b/iosApp/MetanMobileWidgetTests/NearestStationSnapshotTests.swift new file mode 100644 index 0000000..cf2aa83 --- /dev/null +++ b/iosApp/MetanMobileWidgetTests/NearestStationSnapshotTests.swift @@ -0,0 +1,100 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Testing + +/// The JSON contract with the Kotlin side (NearestStationWidgetSnapshot in +/// :widget:nearest-station, whose own test pins the same field names). The fixtures are exactly +/// what kotlinx.serialization writes - the first one was copied from a simulator's App Group. +struct NearestStationSnapshotTests { + + @Test func decodesWhatTheAppWritesWithAStation() throws { + let json = """ + {"price":{"value":"1,16 BYN"},"station":{"code":"agnks_grodno2","distance":"2,4 km",\ + "address":"г. Гродно, Индурское шоссе, 15","status":"NOT_OPERATING",\ + "url":"metanmobile://ecogas-map/agnks_grodno2/"},"texts":{"cngPrice":"Цена метана",\ + "perCubicMeter":"За 1 куб. метр","nearestStation":"Ближайшая станция",\ + "noLocation":"Откройте приложение","noData":"Нет данных"}} + """ + + let snapshot = try #require(NearestStationSnapshot.decode(Data(json.utf8))) + + #expect(snapshot.price?.value == "1,16 BYN") + let station = try #require(snapshot.station) + #expect(station.code == "agnks_grodno2") + #expect(station.distance == "2,4 km") + #expect(station.status == .notOperating) + #expect(station.url == "metanmobile://ecogas-map/agnks_grodno2/") + #expect(snapshot.texts.nearestStation == "Ближайшая станция") + #expect(snapshot.hasContent) + } + + @Test func decodesTheNoLocationStateAsCapturedFromTheSimulator() throws { + let json = """ + {"price":{"value":"1,16 BYN"},"station":null,"texts":{"cngPrice":"CNG price",\ + "perCubicMeter":"For 1 sq. m","nearestStation":"Nearest station",\ + "noLocation":"Open the app to find the nearest station","noData":"Open the app to load the data"}} + """ + + let snapshot = try #require(NearestStationSnapshot.decode(Data(json.utf8))) + + #expect(snapshot.station == nil) + #expect(snapshot.price != nil) + #expect(snapshot.hasContent) + } + + @Test func nothingSyncedYetHasNoContent() throws { + let json = """ + {"price":null,"station":null,"texts":{"cngPrice":"","perCubicMeter":"","nearestStation":"",\ + "noLocation":"","noData":"Open the app to load the data"}} + """ + + let snapshot = try #require(NearestStationSnapshot.decode(Data(json.utf8))) + + #expect(!snapshot.hasContent) + #expect(snapshot.texts.noData == "Open the app to load the data") + } + + @Test(arguments: [ + ("OPERATING", NearestStationSnapshot.Status.operating), + ("NOT_OPERATING", .notOperating), + ("UNKNOWN", .unknown), + ]) + func decodesEveryStatusTheKotlinEnumHas(raw: String, expected: NearestStationSnapshot.Status) throws { + let status = try JSONDecoder().decode(NearestStationSnapshot.Status.self, from: Data("\"\(raw)\"".utf8)) + #expect(status == expected) + } + + @Test func aBrokenFileIsTreatedAsNoData() { + #expect(NearestStationSnapshot.decode(Data("{".utf8)) == nil) + #expect(NearestStationSnapshot.decode(Data()) == nil) + } + + @Test func extraFieldsFromANewerAppVersionAreIgnored() throws { + let json = """ + {"price":{"value":"1 BYN","currency":"BYN"},"station":null,"newField":true,\ + "texts":{"cngPrice":"","perCubicMeter":"","nearestStation":"","noLocation":"","noData":""}} + """ + + #expect(NearestStationSnapshot.decode(Data(json.utf8))?.price?.value == "1 BYN") + } + + @Test func theSampleRoundTrips() throws { + let data = try JSONEncoder().encode(NearestStationSnapshot.sample) + #expect(NearestStationSnapshot.decode(data) == .sample) + } +} diff --git a/iosApp/MetanMobileWidgetTests/NearestStationWidgetRulesTests.swift b/iosApp/MetanMobileWidgetTests/NearestStationWidgetRulesTests.swift new file mode 100644 index 0000000..e6c6804 --- /dev/null +++ b/iosApp/MetanMobileWidgetTests/NearestStationWidgetRulesTests.swift @@ -0,0 +1,64 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Testing + +/// The widget's decisions (NearestStationWidgetRules.swift), the same as the Android widget's. +struct NearestStationWidgetRulesTests { + + @Test func aSmallWidgetKeepsOneTile() { + #expect(TilesOption.both.shown(isSmall: true) == .price) + #expect(TilesOption.price.shown(isSmall: true) == .price) + #expect(TilesOption.distance.shown(isSmall: true) == .distance) + } + + @Test func aMediumWidgetShowsWhatWasChosen() { + for tiles in [TilesOption.price, .distance, .both] { + #expect(tiles.shown(isSmall: false) == tiles) + } + } + + @Test func onlyTheSystemThemeFollowsThePhone() { + #expect(ThemeOption.system.isDark(systemIsDark: true)) + #expect(!ThemeOption.system.isDark(systemIsDark: false)) + #expect(!ThemeOption.light.isDark(systemIsDark: true)) + #expect(ThemeOption.dark.isDark(systemIsDark: false)) + } + + @Test func aDistanceOnlyWidgetOpensTheStation() { + let url = nearestStationWidgetURL(tiles: .distance, snapshot: .sample) + + #expect(url?.absoluteString == "metanmobile://ecogas-map/agnks_grodno2/") + } + + @Test func otherwiseTheWidgetOpensTheApp() { + #expect(nearestStationWidgetURL(tiles: .both, snapshot: .sample)?.absoluteString == "metanmobile://") + #expect(nearestStationWidgetURL(tiles: .price, snapshot: .sample)?.absoluteString == "metanmobile://") + // No location yet: nothing to open but the app. + #expect(nearestStationWidgetURL(tiles: .distance, snapshot: nil)?.absoluteString == "metanmobile://") + } + + @Test func theAppHandlesTheStationLinkFormat() throws { + // MetanMobileAppState.navigateToDeepLink strips "metanmobile://" and routes + // "ecogas-map/{code}" to the station - the URL must keep exactly that shape. + let url = try #require(URL(string: NearestStationSnapshot.sample.station!.url)) + + #expect(url.scheme == "metanmobile") + #expect(url.host() == "ecogas-map") + #expect(url.pathComponents.filter { $0 != "/" } == ["agnks_grodno2"]) + } +} diff --git a/iosApp/MetanMobileWidgetTests/WidgetLocalizationTests.swift b/iosApp/MetanMobileWidgetTests/WidgetLocalizationTests.swift new file mode 100644 index 0000000..0c2781d --- /dev/null +++ b/iosApp/MetanMobileWidgetTests/WidgetLocalizationTests.swift @@ -0,0 +1,57 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Testing + +/// Every text the widget itself shows (its name, settings and the fallback messages) exists in +/// the app's three languages. The rest of its texts come translated from the app (the snapshot). +struct WidgetLocalizationTests { + + /// Keys of Localizable.xcstrings - the strings used in the widget's Swift code. + static let keys = [ + "Widget appearance", "Show", "Theme", "Background color", "Background opacity", + "Tile color", "Tile opacity", "Tiles", "Price", "Distance", "Both", "System", "Light", + "Dark", "Color", "Auto (follows theme)", "White", "Black", "Blue", "Opacity", + "Price and nearest station", "CNG price and the CNG station nearest to you", + "CNG price", "For 1 sq. m", "Nearest station", + "Open the app to find the nearest station", "Open the app to load the data", + ] + + @Test(arguments: ["ru", "be"]) + func everyStringIsTranslated(language: String) throws { + let table = try #require(Self.strings(for: language), "No \(language) strings in the bundle") + let missing = Self.keys.filter { (table[$0] ?? "").isEmpty } + + #expect(missing.isEmpty, "Not translated into \(language): \(missing)") + } + + @Test func translationsDifferFromTheEnglishText() throws { + let ru = try #require(Self.strings(for: "ru")) + + #expect(ru["Price"] == "Цена") + #expect(ru["Open the app to load the data"] == "Откройте приложение, чтобы загрузить данные") + } + + private static func strings(for language: String) -> [String: String]? { + let bundle = Bundle(for: BundleToken.self) + guard let path = bundle.path(forResource: "Localizable", ofType: "strings", inDirectory: nil, forLocalization: language) + else { return nil } + return NSDictionary(contentsOfFile: path) as? [String: String] + } +} + +private final class BundleToken {} diff --git a/iosApp/MetanMobileWidgetTests/WidgetPaletteTests.swift b/iosApp/MetanMobileWidgetTests/WidgetPaletteTests.swift new file mode 100644 index 0000000..03df1ed --- /dev/null +++ b/iosApp/MetanMobileWidgetTests/WidgetPaletteTests.swift @@ -0,0 +1,134 @@ +// +// Copyright 2026 NGApps Dev (https://github.com/ngapp-dev). All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Testing + +/// The palette rules ported from widget:core's WidgetTheme.kt (checked there by +/// WidgetPaletteTest) - both platforms' widgets must resolve colors the same way. +struct WidgetPaletteTests { + + @Test func theDefaultsLookLikeTheApp() { + let light = WidgetPalette(intent: intent(), isDark: false) + let dark = WidgetPalette(intent: intent(), isDark: true) + + #expect(light.container.rgba == WidgetTokens.white.rgba) + #expect(light.tile.rgba == WidgetTokens.backgroundLight.rgba) + #expect(light.onTile.rgba == WidgetTokens.black.rgba) + #expect(dark.container.rgba == WidgetTokens.black.rgba) + #expect(dark.tile.rgba == WidgetTokens.backgroundDark.rgba) + #expect(dark.onTile.rgba == WidgetTokens.white.rgba) + } + + @Test func anExplicitColorIgnoresTheTheme() { + let palette = WidgetPalette(intent: intent { $0.backgroundColor = .white }, isDark: true) + + #expect(palette.container.rgba == WidgetTokens.white.rgba) + } + + @Test(arguments: [ + (OpacityOption.none, 0.0), + (.quarter, 0.25), + (.half, 0.5), + (.threeQuarters, 0.75), + (.full, 1.0), + ]) + func opacityBecomesTheFillsAlpha(option: OpacityOption, alpha: Double) { + let palette = WidgetPalette( + intent: intent { $0.tileColor = .blue; $0.tileOpacity = option }, + isDark: false + ) + + #expect(abs(palette.tile.rgba.alpha - alpha) < 0.01) + } + + @Test(arguments: [ + (ColorOption.black, true), + (.blue, true), + (.white, false), + ]) + func textIsWhiteOnDarkTilesAndDarkOnWhiteOnes(tileColor: ColorOption, whiteText: Bool) { + let palette = WidgetPalette(intent: intent { $0.tileColor = tileColor }, isDark: false) + + let expected = whiteText ? WidgetTokens.white : WidgetTokens.black + #expect(palette.onTile.rgba == expected.rgba) + } + + @Test func seeThroughTilesTakeTheirTextColorFromWhatShowsThrough() { + let palette = WidgetPalette( + intent: intent { + $0.backgroundColor = .white + $0.tileColor = .black + $0.tileOpacity = .quarter + }, + isDark: false + ) + + #expect(palette.onTile.rgba == WidgetTokens.black.rgba) + } + + @Test func aFullySeeThroughWidgetUsesWhiteTextForTheWallpaper() { + let palette = WidgetPalette( + intent: intent { $0.backgroundOpacity = .none; $0.tileOpacity = .none }, + isDark: false + ) + + #expect(palette.onTile.rgba == WidgetTokens.white.rgba) + } + + @Test func theOperatingDotIsRingedOnBlueTilesOnly() { + let blue = WidgetPalette(intent: intent { $0.tileColor = .blue }, isDark: false) + let white = WidgetPalette(intent: intent { $0.tileColor = .white }, isDark: false) + + #expect(blue.statusDotRing(for: .operating)?.rgba == blue.onTile.rgba) + #expect(blue.statusDotRing(for: .notOperating) == nil) + #expect(white.statusDotRing(for: .operating) == nil) + } + + @Test func statusColorsMatchTheApp() { + #expect(NearestStationSnapshot.Status.operating.color.rgba == WidgetTokens.blue.rgba) + #expect(NearestStationSnapshot.Status.notOperating.color.rgba == WidgetTokens.red.rgba) + #expect(NearestStationSnapshot.Status.unknown.color.rgba == WidgetTokens.gray400.rgba) + } + + private func intent(_ configure: (NearestStationWidgetIntent) -> Void = { _ in }) -> NearestStationWidgetIntent { + let intent = NearestStationWidgetIntent() + configure(intent) + return intent + } +} + +/// Resolved components, so colors built in different ways compare by what they actually are. +struct RGBA: Equatable { + let red: Double, green: Double, blue: Double, alpha: Double + + static func == (lhs: RGBA, rhs: RGBA) -> Bool { + abs(lhs.red - rhs.red) < 0.005 && abs(lhs.green - rhs.green) < 0.005 + && abs(lhs.blue - rhs.blue) < 0.005 && abs(lhs.alpha - rhs.alpha) < 0.005 + } +} + +extension Color { + var rgba: RGBA { + let resolved = resolve(in: EnvironmentValues()) + return RGBA( + red: Double(resolved.red), + green: Double(resolved.green), + blue: Double(resolved.blue), + alpha: Double(resolved.opacity) + ) + } +} From bc7716d4e1443e234dd7e21ff644ef2b7c9d8aba Mon Sep 17 00:00:00 2001 From: ngapps Date: Fri, 2 Oct 2026 16:02:56 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=94=96=20Bump=20version:=20Android=20?= =?UTF-8?q?2.4.0,=20iOS=201.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android 2.3.8 -> 2.4.0 (versionCode 2400). - iOS app 1.0.1 (2) -> 1.1.0 (3). The widget extension gets the same version and build: App Store rejects an extension whose version differs from its app's. --- gradle/libs.versions.toml | 4 ++-- iosApp/MetanMobile.xcodeproj/project.pbxproj | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c44cf2..870fed3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,8 +3,8 @@ androidCompileSdk = "37" androidMinSdk = "24" androidTargetSdk = "36" versionMajor = "2" -versionMinor = "3" -versionPatch = "8" +versionMinor = "4" +versionPatch = "0" applicationId = "com.ngapp.metanmobile" accompanist = "0.37.3" diff --git a/iosApp/MetanMobile.xcodeproj/project.pbxproj b/iosApp/MetanMobile.xcodeproj/project.pbxproj index 3adc267..4eaaf50 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 = 1; + CURRENT_PROJECT_VERSION = 3; 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.0; + MARKETING_VERSION = 1.1.0; 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 = 1; + CURRENT_PROJECT_VERSION = 3; 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.0; + MARKETING_VERSION = 1.1.0; 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 = 2; + CURRENT_PROJECT_VERSION = 3; 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.0.1; + MARKETING_VERSION = 1.1.0; 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 = 2; + CURRENT_PROJECT_VERSION = 3; 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.0.1; + MARKETING_VERSION = 1.1.0; OTHER_LDFLAGS = ( "-framework", MetanMobileComposeApp, From a70561199b406920e291b33aef5659bdbf536d6c Mon Sep 17 00:00:00 2001 From: ngapps Date: Fri, 2 Oct 2026 16:02:56 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=91=B7=20CI:=20Xcode=2027,=20widget?= =?UTF-8?q?=20tests=20on=20iOS,=20dependency=20caches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iOS job selects Xcode 27 and now also runs the widget's Kotlin tests on the iOS simulator and its Swift tests (MetanMobileWidgetTests) on an iOS 27 iPhone simulator picked from the runner. The Swift tests have no host app, so they don't build the app or its Kotlin framework. - Caches what was downloaded on every run and isn't in Gradle's cache: the Kotlin/Native toolchain (~/.konan, ~1 GB), Swift packages (~900 MB) and Robolectric's Android SDK jars (~300 MB). - Test results are uploaded when the iOS job fails. --- .github/workflows/ci.yml | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47a531e..abb4242 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,15 @@ jobs: } EOF + # Robolectric downloads an android-all jar per SDK level (~300 MB) the first time a test + # runs; setup-gradle doesn't cache it, so it was fetched again on every run. + - name: Cache Robolectric SDK jars + uses: actions/cache@v4 + with: + path: ~/.m2/repository/org/robolectric + key: robolectric-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: robolectric- + - name: Build run: ./gradlew :app:assembleDebug @@ -94,6 +103,12 @@ jobs: steps: - uses: actions/checkout@v4 + # The project and its tests are set up with Xcode 27 / iOS 27 (the widget tests target + # iOS 27). Fails fast here if the runner image doesn't ship it. + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '27' + - uses: actions/setup-java@v4 with: distribution: zulu @@ -101,6 +116,24 @@ jobs: - uses: gradle/actions/setup-gradle@v4 + # The Kotlin/Native toolchain and its dependencies (~1 GB) live in ~/.konan, outside + # Gradle's cache, and were downloaded again on every run. Keyed on the versions file, + # which pins the Kotlin version. + - name: Cache Kotlin/Native + uses: actions/cache@v4 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: konan-${{ runner.os }}- + + # Swift packages (Google Mobile Ads, Yandex, AppMetrica, ...) resolved by xcodebuild. + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: ~/spm-packages + key: spm-${{ hashFiles('iosApp/MetanMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm- + # secrets.properties is gitignored, but the Secrets Gradle plugin fails without it. # Real ad unit ids fall back to test ids in core:ui, so placeholders are enough. - name: Create stub secrets.properties @@ -114,3 +147,33 @@ jobs: - name: Compile shared code for iOS run: ./gradlew :composeApp:compileKotlinIosSimulatorArm64 + + # Kotlin tests of the widget modules on the iOS simulator (the JVM run is in the Android job). + - name: Widget Kotlin tests (iOS) + run: ./gradlew :widget:core:iosSimulatorArm64Test :widget:nearest-station:iosSimulatorArm64Test + + # The Swift widget's tests. The test target has no host app, so this doesn't build the + # app (nor its Kotlin framework) - only the widget's sources and the tests. + - name: Widget Swift tests + run: | + UDID=$(xcrun simctl list devices available -j | jq -r ' + [.devices | to_entries[] | select(.key | test("iOS-27")) + | .value[] | select(.name | startswith("iPhone"))][0].udid') + if [ -z "$UDID" ] || [ "$UDID" = "null" ]; then + echo "No iOS 27 iPhone simulator on this runner"; xcrun simctl list runtimes; exit 1 + fi + xcodebuild test \ + -project iosApp/MetanMobile.xcodeproj \ + -scheme MetanMobileWidgetTests \ + -destination "id=$UDID" \ + -clonedSourcePackagesDirPath ~/spm-packages \ + -resultBundlePath build/MetanMobileWidgetTests.xcresult + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-reports + path: | + build/MetanMobileWidgetTests.xcresult + **/build/reports/