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/ 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/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/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/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/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..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" @@ -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/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..4eaaf50 100644 --- a/iosApp/MetanMobile.xcodeproj/project.pbxproj +++ b/iosApp/MetanMobile.xcodeproj/project.pbxproj @@ -3,20 +3,53 @@ 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 = ""; }; + 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 = ""; }; @@ -25,7 +58,49 @@ 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 */; + }; + 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 */, 9E8E73F3306FE6730026D467 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MetanMobileWidget; sourceTree = ""; }; + 9E8E73E8306FE6060026D467 /* MetanMobileWidgetTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MetanMobileWidgetTests; 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; + }; + 9E8E73E4306FE6060026D467 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000200000000000000020 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -39,10 +114,24 @@ /* 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 */, + 9E8E73E8306FE6060026D467 /* MetanMobileWidgetTests */, + 9E8E73B8306FD3710026D467 /* Frameworks */, A10000320000000000000032 /* Products */, ); sourceTree = ""; @@ -63,6 +152,8 @@ isa = PBXGroup; children = ( A10000100000000000000010 /* MetanMobile.app */, + 9E8E73B7306FD3710026D467 /* MetanMobileWidgetExtension.appex */, + 9E8E73E7306FE6060026D467 /* MetanMobileWidgetTests.xctest */, ); name = Products; sourceTree = ""; @@ -70,6 +161,50 @@ /* 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"; + }; + 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" */; @@ -79,10 +214,12 @@ A10000200000000000000020 /* Frameworks */, A10000600000000000000060 /* Resources */, 1013E7AC8C0E9E6A068E770F /* Inject Ads Secrets */, + 9E8E73CE306FD3730026D467 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 9E8E73CC306FD3730026D467 /* PBXTargetDependency */, ); name = MetanMobile; packageProductDependencies = ( @@ -100,8 +237,15 @@ A10000410000000000000041 /* Project object */ = { isa = PBXProject; attributes = { + LastSwiftUpdateCheck = 2700; LastUpgradeCheck = 1600; TargetAttributes = { + 9E8E73B6306FD3710026D467 = { + CreatedOnToolsVersion = 27.0; + }; + 9E8E73E6306FE6060026D467 = { + CreatedOnToolsVersion = 27.0; + }; A10000400000000000000040 = { CreatedOnToolsVersion = 16.0; }; @@ -114,6 +258,8 @@ knownRegions = ( en, Base, + ru, + be, ); mainGroup = A10000300000000000000030; packageReferences = ( @@ -126,11 +272,27 @@ projectRoot = ""; targets = ( A10000400000000000000040 /* MetanMobile */, + 9E8E73B6306FD3710026D467 /* MetanMobileWidgetExtension */, + 9E8E73E6306FE6060026D467 /* MetanMobileWidgetTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 9E8E73B5306FD3710026D467 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9E8E73E5306FE6060026D467 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000600000000000000060 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -177,6 +339,20 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 9E8E73B3306FD3710026D467 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9E8E73E3306FE6060026D467 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000500000000000000050 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -189,13 +365,325 @@ }; /* 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 = 3; + 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.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 = 3; + 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.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; + }; + 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 = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CURRENT_PROJECT_VERSION = 2; - MARKETING_VERSION = 1.0.1; + CODE_SIGN_ENTITLEMENTS = MetanMobile.entitlements; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = K4VJG4673X; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; @@ -208,6 +696,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 1.1.0; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ( "-framework", @@ -216,6 +705,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 +717,8 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CURRENT_PROJECT_VERSION = 2; - MARKETING_VERSION = 1.0.1; + CODE_SIGN_ENTITLEMENTS = MetanMobile.entitlements; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = K4VJG4673X; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; @@ -241,6 +731,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 1.1.0; OTHER_LDFLAGS = ( "-framework", MetanMobileComposeApp, @@ -248,6 +739,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 +772,24 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 9E8E73D2306FD3740026D467 /* Build configuration list for PBXNativeTarget "MetanMobileWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9E8E73CF306FD3730026D467 /* Debug */, + 9E8E73D0306FD3730026D467 /* Release */, + ); + 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/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..54b84e1 --- /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 decode(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/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 new file mode 100644 index 0000000..2602ff1 --- /dev/null +++ b/iosApp/MetanMobileWidget/NearestStationWidgetView.swift @@ -0,0 +1,211 @@ +// +// 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 { configuration.theme.isDark(systemIsDark: systemColorScheme == .dark) } + + private var isFullColor: Bool { renderingMode == .fullColor } + + private var tiles: TilesOption { configuration.tiles.shown(isSmall: family == .systemSmall) } + + 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(nearestStationWidgetURL(tiles: tiles, snapshot: entry.snapshot)) + } + + @ViewBuilder + private func content(_ palette: WidgetPalette) -> some View { + if let snapshot = entry.snapshot, snapshot.hasContent { + 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 + } + } +} + +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/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) + ) + } +} 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/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 new file mode 100644 index 0000000..ced2db3 --- /dev/null +++ b/widget/nearest-station/build.gradle.kts @@ -0,0 +1,69 @@ +/* + * 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) + alias(libs.plugins.kotlin.serialization) +} + +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) + 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) + 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/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/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/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/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) +} 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 +}