From d5aa5e261c5216028a3103adec4a48de59522489 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 1 Oct 2026 13:07:53 +0530 Subject: [PATCH 01/20] refactor: Overcast integration --- app/src/main/AndroidManifest.xml | 23 +- .../com/sameerasw/essentials/EssentialsApp.kt | 1 - .../data/repository/SettingsRepository.kt | 69 +- .../essentials/domain/model/AppPermission.kt | 5 + .../domain/registry/FeatureRegistry.kt | 17 - .../domain/registry/PermissionRegistry.kt | 2 +- .../island/plugins/brief/BriefPlugin.kt | 9 +- .../island/plugins/weather/WeatherExpanded.kt | 15 +- .../island/plugins/weather/WeatherPlugin.kt | 41 +- .../ui/activities/FeatureSettingsActivity.kt | 16 - .../ui/activities/SettingsActivity.kt | 16 +- .../ui/activities/WeatherDetailActivity.kt | 33 - .../ui/composables/SetupFeatures.kt | 1 - .../ui/features/display/IslandSettingsUI.kt | 15 +- .../sheets/IslandWeatherOptionsBottomSheet.kt | 67 +- .../weather/LocationPermissionRequest.kt | 53 -- .../ui/features/weather/WeatherDetailSheet.kt | 892 ------------------ .../ui/features/weather/WeatherHeaderCard.kt | 132 --- .../ui/features/weather/WeatherPalette.kt | 99 -- .../ui/features/weather/WeatherSettingsUI.kt | 598 ------------ .../ui/modifiers/HeatHazeModifier.kt | 60 -- .../ui/modifiers/RainOnGlassModifier.kt | 176 ---- .../essentials/utils/WeatherLauncher.kt | 32 - .../essentials/utils/ui/PermissionUIHelper.kt | 26 + .../essentials/viewmodels/MainViewModel.kt | 16 + .../weather/SettingsWeatherConfig.kt | 16 - .../essentials/weather/WeatherConfig.kt | 13 - .../essentials/weather/WeatherRepository.kt | 88 +- .../essentials/weather/WeatherSources.kt | 16 + .../weather/effects/WeatherSimulation.kt | 89 -- .../weather/location/DeviceLocationSource.kt | 51 - .../essentials/weather/model/WeatherModels.kt | 27 +- .../weather/overcast/OvercastWeather.kt | 72 ++ .../weather/provider/MetNorwayProvider.kt | 155 --- .../weather/provider/NwsProvider.kt | 174 ---- .../weather/provider/OpenMeteoProvider.kt | 183 ---- .../provider/OpenWeatherMapProvider.kt | 138 --- .../weather/provider/PirateWeatherProvider.kt | 157 --- .../weather/provider/ProviderHttp.kt | 76 -- .../weather/provider/TomorrowIoProvider.kt | 158 ---- .../provider/VisualCrossingProvider.kt | 162 ---- .../weather/provider/WeatherApiProvider.kt | 208 ---- .../weather/provider/WeatherProvider.kt | 41 - app/src/main/res/drawable/rounded_key_24.xml | 10 - .../res/drawable/rounded_location_city_24.xml | 10 - .../main/res/drawable/rounded_wb_sunny_24.xml | 14 - app/src/main/res/values-vi/strings.xml | 4 - app/src/main/res/values/strings.xml | 62 +- app/src/main/res/values/themes.xml | 6 - 49 files changed, 256 insertions(+), 4088 deletions(-) delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/activities/WeatherDetailActivity.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/weather/LocationPermissionRequest.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherDetailSheet.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherHeaderCard.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherPalette.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherSettingsUI.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/modifiers/HeatHazeModifier.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/ui/modifiers/RainOnGlassModifier.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/utils/WeatherLauncher.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/SettingsWeatherConfig.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/WeatherConfig.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/weather/WeatherSources.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/effects/WeatherSimulation.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/location/DeviceLocationSource.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/weather/overcast/OvercastWeather.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/MetNorwayProvider.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/NwsProvider.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/OpenMeteoProvider.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/OpenWeatherMapProvider.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/PirateWeatherProvider.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/ProviderHttp.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/TomorrowIoProvider.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/VisualCrossingProvider.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/WeatherApiProvider.kt delete mode 100644 app/src/main/java/com/sameerasw/essentials/weather/provider/WeatherProvider.kt delete mode 100644 app/src/main/res/drawable/rounded_key_24.xml delete mode 100644 app/src/main/res/drawable/rounded_location_city_24.xml delete mode 100644 app/src/main/res/drawable/rounded_wb_sunny_24.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 82c9aab30b..5e7ec38c02 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -97,6 +97,7 @@ + - - - - - - - - - ? { - val parts = getString(KEY_WEATHER_MANUAL_LOCATION, null)?.split("|", limit = 3) ?: return null - if (parts.size != 3) return null - val lat = parts[0].toDoubleOrNull() ?: return null - val lon = parts[1].toDoubleOrNull() ?: return null - return Triple(lat, lon, parts[2]) - } - - fun setWeatherManualLocation(latitude: Double, longitude: Double, name: String) = - putString(KEY_WEATHER_MANUAL_LOCATION, "$latitude|$longitude|${name.replace("|", " ")}") - fun getWeatherUnits(): String = getString(KEY_WEATHER_UNITS, WEATHER_UNITS_SYSTEM) ?: WEATHER_UNITS_SYSTEM fun setWeatherUnits(units: String) = putString(KEY_WEATHER_UNITS, units) - fun getWeatherRefreshMinutes(): Int = getInt(KEY_WEATHER_REFRESH_MINUTES, 60) - fun setWeatherRefreshMinutes(minutes: Int) = putInt(KEY_WEATHER_REFRESH_MINUTES, minutes) - fun isIslandShowTimersEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_TIMERS, true) fun setIslandShowTimersEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_TIMERS, enabled) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt index 1f6f29762f..37b72551dd 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/AppPermission.kt @@ -132,6 +132,11 @@ enum class AppPermission( key = "NOTIFICATION_BUBBLES", titleRes = R.string.perm_bubbles_title, iconRes = R.drawable.rounded_bubble_24, + ), + OVERCAST_WEATHER( + key = "OVERCAST_WEATHER", + titleRes = R.string.perm_overcast_weather_title, + iconRes = R.drawable.rounded_partly_cloudy_day_24, ); companion object { diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index fca0299936..6982afeedc 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -2038,23 +2038,6 @@ object FeatureRegistry { context.startActivity(Intent(context, WatermarkActivity::class.java)) } }, - object : Feature( - id = "Weather", - title = R.string.feat_weather_title, - iconRes = R.drawable.rounded_partly_cloudy_day_24, - category = R.string.cat_tools, - description = R.string.feat_weather_desc, - aboutDescription = R.string.about_desc_weather, - showToggle = false, - ) { - override fun isEnabled(viewModel: MainViewModel) = true - - override fun onToggle( - viewModel: MainViewModel, - context: Context, - enabled: Boolean, - ) {} - }, object : Feature( id = "Daily Wallpaper", title = R.string.feat_daily_wallpaper_title, diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index 84b17eea97..8481baff2a 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -142,7 +142,7 @@ fun initPermissionRegistry() { // Location permission PermissionRegistry.register("LOCATION", R.string.feat_location_reached_title) - PermissionRegistry.register("LOCATION", R.string.lock_screen_clock_weather) + PermissionRegistry.register("OVERCAST_WEATHER", R.string.lock_screen_clock_weather) PermissionRegistry.register("BACKGROUND_LOCATION", R.string.feat_location_reached_title) PermissionRegistry.register("USE_FULL_SCREEN_INTENT", R.string.feat_location_reached_title) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt index dbdedcfb04..286c70f2cf 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/brief/BriefPlugin.kt @@ -8,7 +8,6 @@ import com.sameerasw.essentials.weather.effects.DeviceWeatherHaptics import com.sameerasw.essentials.weather.effects.WeatherEffectHaptics import com.sameerasw.essentials.weather.effects.WeatherEffectSpec import com.sameerasw.essentials.weather.effects.WeatherEffects -import com.sameerasw.essentials.weather.effects.WeatherSimulation import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.weather.WeatherFormat import com.sameerasw.essentials.weather.WeatherRepository @@ -138,8 +137,6 @@ class BriefPlugin : BaseIslandPlugin() { SettingsRepository.KEY_ISLAND_SHOW_WEATHER, SettingsRepository.KEY_ISLAND_WEATHER_EFFECTS, SettingsRepository.KEY_ISLAND_WEATHER_HAPTICS, - SettingsRepository.KEY_DEBUG_SIMULATED_WEATHER, - SettingsRepository.KEY_DEBUG_WEATHER_EXPERIMENTAL, SettingsRepository.KEY_WEATHER_UNITS, SettingsRepository.KEY_ISLAND_BRIEF_SHOW_ALARM, SettingsRepository.KEY_ISLAND_BRIEF_TWO_LINE_HEADER, @@ -163,7 +160,6 @@ class BriefPlugin : BaseIslandPlugin() { unit = WeatherFormat.unitFor(settings.getWeatherUnits()), effects = settings.isIslandShowWeatherEnabled() && settings.isIslandWeatherEffectsEnabled() && !DeviceUtils.isPowerSaveMode(context), haptics = settings.isIslandWeatherHapticsEnabled(), - simulated = settings.getSimulatedWeather()?.spec, ) publish( IslandItem( @@ -227,8 +223,8 @@ private fun BriefExpanded( val pageShape = RoundedCornerShape(scope.spec.expandedCorner) Box(propagateMinConstraints = true) { val weatherState by WeatherRepository.state.collectAsState() - val effectSpec = remember(weather.effects, weather.simulated, weatherState.snapshot) { - weather.simulated ?: weatherState.snapshot?.takeIf { weather.effects }?.let(WeatherEffectSpec::from) ?: WeatherEffectSpec.None + val effectSpec = remember(weather.effects, weatherState.snapshot) { + weatherState.snapshot?.takeIf { weather.effects }?.let(WeatherEffectSpec::from) ?: WeatherEffectSpec.None } val weatherHaptics = remember(context, weather.haptics) { DeviceWeatherHaptics(context).takeIf { weather.haptics } } if (!SurfaceBackdrop { BriefBackground(page, media, showGlow, scope, effectSpec, weatherHaptics, Modifier.fillMaxSize()) }) { @@ -343,7 +339,6 @@ private class BriefWeather( val unit: TemperatureUnit, val effects: Boolean, val haptics: Boolean, - val simulated: WeatherEffectSpec? = null, ) @Composable diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/weather/WeatherExpanded.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/weather/WeatherExpanded.kt index f2a1e0a0f3..b6de64e1b7 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/weather/WeatherExpanded.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/weather/WeatherExpanded.kt @@ -2,7 +2,7 @@ package com.sameerasw.essentials.island.plugins.weather import android.content.Context import kotlinx.coroutines.delay -import com.sameerasw.essentials.weather.provider.WeatherProviders +import com.sameerasw.essentials.weather.WeatherSources import androidx.compose.ui.text.style.TextAlign import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableLongStateOf @@ -72,7 +72,6 @@ fun WeatherExpanded( scope: IslandExpandedScope, effects: Boolean, haptics: Boolean, - simulated: WeatherEffectSpec? = null, onRefresh: () -> Unit, ) { val state by WeatherRepository.state.collectAsState() @@ -81,8 +80,8 @@ fun WeatherExpanded( val snapshot = state.snapshot val accent = MaterialTheme.colorScheme.primary val context = LocalContext.current - val effectSpec = remember(effects, snapshot, simulated) { - snapshot?.takeIf { effects }?.let { simulated ?: WeatherEffectSpec.from(it) } ?: WeatherEffectSpec.None + val effectSpec = remember(effects, snapshot) { + snapshot?.takeIf { effects }?.let(WeatherEffectSpec::from) ?: WeatherEffectSpec.None } val effectHaptics = remember(context, haptics) { DeviceWeatherHaptics(context).takeIf { haptics } } @@ -178,11 +177,9 @@ private fun WeatherContent( @Composable private fun errorText(error: WeatherError?): String = stringResource( when (error) { - WeatherError.MissingApiKey -> R.string.weather_error_missing_key - WeatherError.InvalidApiKey -> R.string.weather_error_invalid_key - WeatherError.LocationPermission -> R.string.weather_error_location_permission + WeatherError.OvercastMissing -> R.string.weather_error_overcast_missing + WeatherError.OvercastPermission -> R.string.weather_error_overcast_permission WeatherError.NoLocation -> R.string.weather_error_no_location - WeatherError.Network -> R.string.weather_error_network is WeatherError.Unknown, null -> R.string.weather_error_unknown }, ) @@ -281,7 +278,7 @@ private fun UpdatedRow( DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE, ).toString() - val provider = WeatherProviders.byId(snapshot.providerId).displayName + val provider = WeatherSources.displayName(snapshot.providerId) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { if (error != null) { Text( diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/weather/WeatherPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/weather/WeatherPlugin.kt index decda0d37a..6ec3453de3 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/weather/WeatherPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/weather/WeatherPlugin.kt @@ -3,12 +3,11 @@ package com.sameerasw.essentials.island.plugins.weather import com.sameerasw.essentials.island.plugins.brief.BriefPlugin import com.sameerasw.essentials.island.model.InteractionOverrides import com.sameerasw.essentials.utils.DeviceUtils +import android.content.Context import android.text.format.DateFormat import androidx.compose.material3.MaterialTheme import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R -import com.sameerasw.essentials.ui.activities.WeatherDetailActivity -import android.content.Intent import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.island.model.CompactCell import com.sameerasw.essentials.island.model.CompactPlacement @@ -22,8 +21,7 @@ import com.sameerasw.essentials.island.ui.components.IslandIcon import com.sameerasw.essentials.island.ui.components.RollingText import com.sameerasw.essentials.weather.WeatherFormat import com.sameerasw.essentials.weather.WeatherRepository -import com.sameerasw.essentials.weather.effects.WeatherSimulation -import com.sameerasw.essentials.weather.provider.WeatherProviders +import com.sameerasw.essentials.weather.overcast.OvercastWeather import com.sameerasw.essentials.weather.model.WeatherState import com.sameerasw.essentials.weather.work.WeatherScheduler import kotlinx.coroutines.Job @@ -39,22 +37,12 @@ class WeatherPlugin : BaseIslandPlugin() { SettingsRepository.KEY_ISLAND_SHOW_WEATHER, SettingsRepository.KEY_ISLAND_WEATHER_MODE, SettingsRepository.KEY_ISLAND_WEATHER_PEEK_ALERTS, - SettingsRepository.KEY_WEATHER_PROVIDER, - SettingsRepository.KEY_WEATHER_API_KEY, - SettingsRepository.KEY_DEBUG_SIMULATED_WEATHER, - SettingsRepository.KEY_DEBUG_WEATHER_EXPERIMENTAL, - SettingsRepository.KEY_WEATHER_OPENMETEO_MODEL, - *WeatherProviders.all.map { SettingsRepository.weatherApiKeyName(it.id) }.toTypedArray(), - SettingsRepository.KEY_WEATHER_LOCATION_MODE, - SettingsRepository.KEY_WEATHER_MANUAL_LOCATION, SettingsRepository.KEY_WEATHER_UNITS, SettingsRepository.KEY_ISLAND_WEATHER_EFFECTS, SettingsRepository.KEY_ISLAND_WEATHER_HAPTICS, - SettingsRepository.KEY_WEATHER_REFRESH_MINUTES, ) private var observer: Job? = null - private var sourceSignature: String? = null private var state = WeatherState() override fun onStart() { @@ -78,22 +66,12 @@ class WeatherPlugin : BaseIslandPlugin() { val c = ctx ?: return if (!settings.isIslandShowWeatherEnabled()) { WeatherScheduler.cancel(context) - sourceSignature = null render() return } - WeatherScheduler.schedule(context, settings.getWeatherRefreshMinutes()) - val signature = listOf( - settings.getWeatherProvider(), - settings.getWeatherOpenMeteoModel(), - WeatherRepository.config(context).let { it.providerId to it.apiKey }, - settings.getWeatherLocationMode(), - settings.getWeatherManualLocation()?.toString(), - ).joinToString("|") - val sourceChanged = sourceSignature != null && sourceSignature != signature - sourceSignature = signature - if (sourceChanged || WeatherRepository.isStale(context)) { - c.scope.launch { WeatherRepository.refresh(context, force = sourceChanged) } + WeatherScheduler.schedule(context, WeatherRepository.REFRESH_INTERVAL_MINUTES) + if (WeatherRepository.isStale()) { + c.scope.launch { WeatherRepository.refresh(context) } } render() } @@ -119,7 +97,6 @@ class WeatherPlugin : BaseIslandPlugin() { val mode = settings.getIslandWeatherMode() val effects = settings.isIslandWeatherEffectsEnabled() && !DeviceUtils.isPowerSaveMode(context) val haptics = settings.isIslandWeatherHapticsEnabled() - val simulated = settings.getSimulatedWeather()?.spec val alert = snapshot.activeAlerts().filter { it.severity.isSevere }.maxByOrNull { it.severity.ordinal } val temperature = WeatherFormat.temperature(snapshot.tempC, unit) val icon = if (alert != null) R.drawable.rounded_warning_24 else WeatherFormat.icon(snapshot.condition, snapshot.isDay) @@ -160,7 +137,6 @@ class WeatherPlugin : BaseIslandPlugin() { scope = scope, effects = effects, haptics = haptics, - simulated = simulated, onRefresh = { ctx?.scope?.launch { WeatherRepository.refresh(context, force = true) } }, ) }, @@ -188,12 +164,9 @@ class WeatherPlugin : BaseIslandPlugin() { } } -fun openWeatherDetails(context: android.content.Context) { +fun openWeatherDetails(context: Context) { try { - context.startActivity( - Intent(context, WeatherDetailActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), - ) + if (OvercastWeather.isInstalled(context)) OvercastWeather.openApp(context) else OvercastWeather.openInstallPage(context) } catch (_: Exception) { } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt index 3a97d00534..10647e63f1 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/FeatureSettingsActivity.kt @@ -605,15 +605,6 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } - if (featureId == "Weather") { - com.sameerasw.essentials.ui.features.weather.WeatherHeaderCard( - height = headerHeight - 16.dp, - minHeight = minHeaderHeight - 16.dp, - maxHeight = maxHeaderHeight - 16.dp, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - } - if (featureObj != null && featureObj.animationRes != 0) { LottieFeatureAnimation( resId = featureObj.animationRes, @@ -1125,13 +1116,6 @@ class FeatureSettingsActivity : AppCompatActivity() { ) } - "Weather" -> { - com.sameerasw.essentials.ui.features.weather.WeatherSettingsUI( - viewModel = viewModel, - modifier = Modifier.padding(top = 16.dp), - ) - } - "Batteries" -> { BatteriesSettingsUI( viewModel = viewModel, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt index a6d5570ac9..8d44d99637 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/SettingsActivity.kt @@ -10,7 +10,6 @@ package com.sameerasw.essentials import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.weather.effects.WeatherSimulation import com.sameerasw.essentials.ui.core.cards.ConfigPickerItem import android.Manifest import android.content.ClipData @@ -291,7 +290,7 @@ class SettingsActivity : AppCompatActivity() { grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode in 1001..1006) { + if (requestCode in 1001..1007) { viewModel.check(this) } } @@ -1394,19 +1393,6 @@ fun SettingsContent( } } - val devSettings = remember { SettingsRepository(context) } - var weatherExperimental by remember { mutableStateOf(devSettings.isWeatherExperimentalEnabled()) } - IconToggleItem( - iconRes = R.drawable.rounded_partly_cloudy_day_24, - title = stringResource(R.string.dev_weather_experimental_title), - isChecked = weatherExperimental, - onCheckedChange = { - HapticUtil.performVirtualKeyHaptic(view) - weatherExperimental = it - devSettings.putBoolean(SettingsRepository.KEY_DEBUG_WEATHER_EXPERIMENTAL, it) - }, - ) - Row( modifier = Modifier diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/WeatherDetailActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/WeatherDetailActivity.kt deleted file mode 100644 index 4088a09557..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/WeatherDetailActivity.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.sameerasw.essentials.ui.activities - -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import com.sameerasw.essentials.ui.features.weather.WeatherScreen - -class WeatherDetailActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - ) - super.onCreate(savedInstanceState) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - window.isNavigationBarContrastEnforced = false - window.isStatusBarContrastEnforced = false - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - window.attributes = window.attributes.apply { - layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - } - } - setContent { - WeatherScreen() - } - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt index e413da7bb9..08e8303f8d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt @@ -1415,7 +1415,6 @@ fun SetupFeatures( listOf( "Location reached", "Watermark", - "Weather", ), ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index df057d64c4..0ba97fede7 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -12,7 +12,6 @@ package com.sameerasw.essentials.ui.features.display import androidx.compose.foundation.background import com.sameerasw.essentials.ui.core.pickers.ColorSwatchPicker import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker -import com.sameerasw.essentials.data.repository.SettingsRepository import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.expandVertically @@ -77,6 +76,7 @@ import com.sameerasw.essentials.ui.features.display.sheets.IslandTimerOptionsBot import com.sameerasw.essentials.ui.features.display.sheets.IslandWeatherOptionsBottomSheet import com.sameerasw.essentials.ui.features.display.sheets.StatusGlanceCalendarOptionsBottomSheet import com.sameerasw.essentials.ui.modifiers.highlight +import com.sameerasw.essentials.weather.overcast.OvercastWeather import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.PermissionUIHelper import com.sameerasw.essentials.utils.PermissionUtils @@ -274,17 +274,8 @@ fun IslandSettingsUI( onCheckedChange = { checked -> HapticUtil.performVirtualKeyHaptic(view) viewModel.setIslandShowWeather(checked) - if (checked) { - val weatherSettings = SettingsRepository(context) - val weatherProvider = com.sameerasw.essentials.weather.provider.WeatherProviders - .resolve(weatherSettings.getWeatherProvider(), weatherSettings.getWeatherApiKey("weatherapi")) - if (weatherProvider.requiresApiKey && weatherSettings.getWeatherApiKey(weatherProvider.id) == null) { - context.startActivity(Intent(context, com.sameerasw.essentials.FeatureSettingsActivity::class.java).putExtra("feature", "Weather")) - } else if (weatherSettings.getWeatherLocationMode() != "manual" && - !com.sameerasw.essentials.weather.location.DeviceLocationSource.hasPermission(context) - ) { - requestingPermissionsFor = Pair(R.string.lock_screen_clock_weather, listOf("LOCATION")) - } + if (checked && !OvercastWeather.isAvailable(context)) { + requestingPermissionsFor = Pair(R.string.lock_screen_clock_weather, listOf("OVERCAST_WEATHER")) } }, onSettingsClick = { showWeatherOptionsSheet = true }, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandWeatherOptionsBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandWeatherOptionsBottomSheet.kt index f24ddc66c5..7857bf2999 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandWeatherOptionsBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/sheets/IslandWeatherOptionsBottomSheet.kt @@ -1,6 +1,5 @@ package com.sameerasw.essentials.ui.features.display.sheets -import android.content.Intent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.activity.ComponentActivity import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -29,13 +29,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.FeatureSettingsActivity import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker import com.sameerasw.essentials.ui.core.sheets.EssentialsBottomSheet import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.MainViewModel +import com.sameerasw.essentials.weather.overcast.OvercastWeather @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,6 +48,10 @@ fun IslandWeatherOptionsBottomSheet( val settings = remember { SettingsRepository(context) } var mode by remember { mutableStateOf(settings.getIslandWeatherMode()) } var peekAlerts by remember { mutableStateOf(settings.isIslandWeatherPeekAlertsEnabled()) } + var effects by remember { mutableStateOf(settings.isIslandWeatherEffectsEnabled()) } + var weatherHaptics by remember { mutableStateOf(settings.isIslandWeatherHapticsEnabled()) } + val installed = remember { OvercastWeather.isInstalled(context) } + var units by remember { mutableStateOf(settings.getWeatherUnits()) } EssentialsBottomSheet(onDismissRequest = onDismissRequest) { Column( @@ -98,6 +102,51 @@ fun IslandWeatherOptionsBottomSheet( settings.setIslandWeatherPeekAlertsEnabled(it) }, ) + IconToggleItem( + iconRes = R.drawable.rounded_rainy_24, + title = stringResource(R.string.island_weather_effects_title), + isChecked = effects, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + effects = it + settings.setIslandWeatherEffectsEnabled(it) + }, + ) + IconToggleItem( + iconRes = R.drawable.rounded_thunderstorm_24, + title = stringResource(R.string.island_weather_haptics_title), + isChecked = weatherHaptics, + enabled = effects, + onCheckedChange = { + HapticUtil.performVirtualKeyHaptic(view) + weatherHaptics = it + settings.setIslandWeatherHapticsEnabled(it) + }, + ) + } + + RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { + SegmentedPicker( + items = listOf( + SettingsRepository.WEATHER_UNITS_SYSTEM, + SettingsRepository.WEATHER_UNITS_CELSIUS, + SettingsRepository.WEATHER_UNITS_FAHRENHEIT, + ), + selectedItem = units, + onItemSelected = { + units = it + settings.setWeatherUnits(it) + }, + labelProvider = { + when (it) { + SettingsRepository.WEATHER_UNITS_CELSIUS -> "°C" + SettingsRepository.WEATHER_UNITS_FAHRENHEIT -> "°F" + else -> context.getString(R.string.weather_units_system) + } + }, + modifier = Modifier.fillMaxWidth(), + title = R.string.weather_units_title, + ) } IslandLauncherOnlyToggle(SettingsRepository.KEY_ISLAND_WEATHER_LAUNCHER_ONLY) @@ -110,14 +159,24 @@ fun IslandWeatherOptionsBottomSheet( .fillMaxWidth() .clickable { HapticUtil.performVirtualKeyHaptic(view) - context.startActivity(Intent(context, FeatureSettingsActivity::class.java).putExtra("feature", "Weather").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + when { + !installed -> OvercastWeather.openInstallPage(context) + !OvercastWeather.hasPermission(context) -> (context as? ComponentActivity)?.let { viewModel.requestOvercastWeatherPermission(it) } + else -> OvercastWeather.openApp(context) + } }, ) { Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Icon(painterResource(R.drawable.rounded_cloud_24), null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(16.dp)) Text( - stringResource(R.string.island_weather_open_settings), + stringResource( + when { + !installed -> R.string.island_weather_install_overcast + !OvercastWeather.hasPermission(context) -> R.string.island_weather_allow_overcast + else -> R.string.island_weather_open_overcast + }, + ), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/LocationPermissionRequest.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/weather/LocationPermissionRequest.kt deleted file mode 100644 index 840064c926..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/LocationPermissionRequest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.sameerasw.essentials.ui.features.weather - -import android.Manifest -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.platform.LocalContext -import androidx.core.app.ActivityCompat - -private val LOCATION_PERMISSIONS = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) - -@Composable -fun rememberLocationPermissionRequest(onResult: (Boolean) -> Unit = {}): () -> Unit { - val context = LocalContext.current - val currentOnResult = rememberUpdatedState(onResult) - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results -> - val granted = results.values.any { it } - if (!granted) { - val activity = context.findActivity() - val blocked = activity != null && LOCATION_PERMISSIONS.none { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } - if (blocked) openAppSettings(context) - } - currentOnResult.value(granted) - } - return remember(launcher) { { launcher.launch(LOCATION_PERMISSIONS) } } -} - -private fun Context.findActivity(): Activity? { - var current: Context? = this - while (current is ContextWrapper) { - if (current is Activity) return current - current = current.baseContext - } - return null -} - -private fun openAppSettings(context: Context) { - try { - context.startActivity( - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } catch (_: Exception) { - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherDetailSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherDetailSheet.kt deleted file mode 100644 index c436a1655b..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherDetailSheet.kt +++ /dev/null @@ -1,892 +0,0 @@ -package com.sameerasw.essentials.ui.features.weather - -import android.content.Context -import com.sameerasw.essentials.weather.effects.WeatherEffectHaptics -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.runtime.mutableStateOf -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.ui.layout.Layout -import com.sameerasw.essentials.ui.modifiers.progressiveBlur -import com.sameerasw.essentials.ui.modifiers.BlurDirection -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.util.lerp -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.layout -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.ui.geometry.Offset -import com.sameerasw.essentials.ui.modifiers.heatHaze -import com.sameerasw.essentials.ui.modifiers.rainOnGlass -import com.sameerasw.essentials.weather.effects.RainSurface -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.platform.LocalDensity -import android.os.Build -import androidx.compose.material3.dynamicDarkColorScheme -import android.text.format.DateFormat -import android.text.format.DateUtils -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel -import androidx.compose.material3.carousel.rememberCarouselState -import androidx.compose.material3.Surface -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontVariation -import androidx.compose.ui.text.ExperimentalTextApi -import androidx.compose.foundation.layout.PaddingValues -import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer -import com.sameerasw.essentials.ui.theme.Shapes -import com.sameerasw.essentials.ui.theme.Typography -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.rememberCoroutineScope -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.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.sameerasw.essentials.R -import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.utils.DeviceUtils -import com.sameerasw.essentials.utils.HapticUtil -import com.sameerasw.essentials.weather.WeatherFormat -import com.sameerasw.essentials.weather.WeatherRepository -import com.sameerasw.essentials.weather.effects.DeviceWeatherHaptics -import com.sameerasw.essentials.weather.location.DeviceLocationSource -import com.sameerasw.essentials.weather.effects.WeatherEffectSpec -import com.sameerasw.essentials.weather.effects.WeatherEffects -import com.sameerasw.essentials.weather.effects.WeatherSimulation -import com.sameerasw.essentials.weather.model.DailyForecast -import com.sameerasw.essentials.weather.model.TemperatureUnit -import com.sameerasw.essentials.weather.model.WeatherAlert -import com.sameerasw.essentials.weather.model.WeatherError -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import com.sameerasw.essentials.weather.provider.WeatherProviders -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import kotlin.math.roundToInt - -internal class WeatherPresentation( - val snapshot: WeatherSnapshot?, - val palette: WeatherPalette, - val effectSpec: WeatherEffectSpec, - val unit: TemperatureUnit, - val now: Long, - val haptics: WeatherEffectHaptics?, -) - -@Composable -internal fun rememberWeatherPresentation(real: WeatherSnapshot?): WeatherPresentation { - val context = LocalContext.current - val settings = remember { SettingsRepository(context) } - var settingsVersion by remember { mutableIntStateOf(0) } - androidx.compose.runtime.DisposableEffect(context) { - val prefs = context.getSharedPreferences(SettingsRepository.PREFS_NAME, Context.MODE_PRIVATE) - val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> settingsVersion++ } - prefs.registerOnSharedPreferenceChangeListener(listener) - onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } - } - val simulation = remember(settingsVersion) { settings.getSimulatedWeather() } - val timeOverride = remember(settingsVersion) { settings.getSimulatedTimeOfDay() } - val tempOverride = remember(settingsVersion) { settings.getSimulatedTempC() } - val simulated = real?.let { r -> simulation?.let { WeatherSimulation.apply(r, it) } ?: r } - val snapshot = simulated?.let { WeatherSimulation.withTimeOfDay(it, timeOverride) }?.let { s -> tempOverride?.let { s.copy(tempC = it) } ?: s } - val unit = remember(settingsVersion) { WeatherFormat.unitFor(settings.getWeatherUnits()) } - var clock by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) { - while (true) { - delay(60_000L) - clock = System.currentTimeMillis() - } - } - val now = WeatherSimulation.timeFor(snapshot, timeOverride, clock) - val palette = animatedPalette( - remember(snapshot?.condition, snapshot?.isDay, snapshot?.extras?.sunriseMillis, snapshot?.extras?.sunsetMillis, now / 60_000L, timeOverride) { - WeatherPalette.from(snapshot, now, timeOverride) - }, - ) - val effects = remember(settingsVersion) { settings.isIslandWeatherEffectsEnabled() && !DeviceUtils.isPowerSaveMode(context) } - val haptics = remember(settingsVersion, effects) { - DeviceWeatherHaptics(context).takeIf { effects && settings.isIslandWeatherHapticsEnabled() } - } - val effectSpec = remember(effects, snapshot?.condition, snapshot?.isDay, snapshot?.windKph, simulation?.id) { - snapshot?.takeIf { effects }?.let { simulation?.spec ?: WeatherEffectSpec.from(it) } ?: WeatherEffectSpec.None - } - return WeatherPresentation(snapshot, palette, effectSpec, unit, now, haptics) -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun WeatherScreen() { - val context = LocalContext.current - val view = LocalView.current - val scope = rememberCoroutineScope() - val settings = remember { SettingsRepository(context) } - val state by WeatherRepository.state.collectAsState() - val presentation = rememberWeatherPresentation(state.snapshot) - val snapshot = presentation.snapshot - val unit = presentation.unit - val now = presentation.now - val palette = presentation.palette - val effectSpec = presentation.effectSpec - val effectHaptics = presentation.haptics - - val requestLocation = rememberLocationPermissionRequest { granted -> - if (granted) scope.launch { WeatherRepository.refresh(context, force = true) } - } - - LaunchedEffect(Unit) { - WeatherRepository.ensureLoaded(context) - val needsLocation = settings.getWeatherLocationMode() != "manual" && !DeviceLocationSource.hasPermission(context) - if (needsLocation) requestLocation() else if (WeatherRepository.isStale(context)) WeatherRepository.refresh(context) - } - - val rainSurfaces = remember { androidx.compose.runtime.mutableStateMapOf() } - val headerBottom = remember { mutableFloatStateOf(0f) } - - MaterialTheme( - colorScheme = darkColorScheme( - primary = palette.accent, - onPrimary = Color.Black, - surface = palette.base, - onSurface = palette.onBase, - onSurfaceVariant = palette.onBaseMuted, - background = palette.base, - onBackground = palette.onBase, - ), - typography = Typography, - shapes = Shapes, - ) { - androidx.compose.runtime.CompositionLocalProvider(LocalRainSurfaces provides rainSurfaces) { - val glassRain = effectSpec.layers.filterIsInstance().maxByOrNull { it.intensity } - val density = LocalDensity.current - val collapse = remember { mutableFloatStateOf(0f) } - val scrollTick = remember { intArrayOf(0) } - val sky = snapshot?.let { skyState(it, now) } - val skyHeightPx = with(density) { SKY_HEIGHT.toPx() } - val collapseShiftPx = with(density) { 60.dp.toPx() } - val fallbackYPx = with(density) { 150.dp.toPx() } - Box(Modifier.fillMaxSize().rainOnGlass(glassRain?.intensity ?: 0f, glassRain?.slant ?: 0f) { - if (sky == null) { - androidx.compose.ui.geometry.Offset(0.5f, fallbackYPx - collapse.floatValue * collapseShiftPx) - } else { - val t = sky.first - androidx.compose.ui.geometry.Offset( - 0.9f - 0.8f * t, - skyHeightPx * (0.66f - 0.4f * kotlin.math.sin(Math.PI.toFloat() * t)) - collapse.floatValue * collapseShiftPx, - ) - } - }) { - Box( - Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - 0f to palette.glow, - 0.4f to palette.glowSecondary, - 0.8f to palette.base, - 1f to palette.base, - ), - ), - ) - snapshot?.let { SkyBody(it, now, { collapse.floatValue }) } - if (!effectSpec.isEmpty) { - WeatherEffects( - spec = effectSpec, - modifier = Modifier.matchParentSize(), - strength = 1.7f, - haptics = effectHaptics, - surfaces = { rainSurfaces.entries.mapNotNull { (key, source) -> source.resolve(key) }.filter { it.rect.top >= headerBottom.floatValue } }, - scrollTick = { scrollTick[0] }, - ) - } - val scrollState = rememberScrollState() - val maxCollapsePx = with(density) { COLLAPSE_RANGE.toPx() } - val connection = remember(maxCollapsePx) { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (available.y != 0f) scrollTick[0]++ - if (available.y >= 0f || collapse.floatValue >= 1f) return Offset.Zero - val next = (collapse.floatValue - available.y / maxCollapsePx).coerceIn(0f, 1f) - val consumed = -(next - collapse.floatValue) * maxCollapsePx - collapse.floatValue = next - return Offset(0f, consumed) - } - - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - if (available.y <= 0f || collapse.floatValue <= 0f) return Offset.Zero - val next = (collapse.floatValue - available.y / maxCollapsePx).coerceIn(0f, 1f) - val used = -(next - collapse.floatValue) * maxCollapsePx - collapse.floatValue = next - return Offset(0f, used) - } - } - } - if (snapshot == null) { - Column(Modifier.fillMaxSize()) { - Spacer(Modifier.height(120.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - if (state.loading) LoadingIndicator() else Text(errorLabel(context, state.error), color = palette.onBaseMuted) - } - if (state.error == WeatherError.LocationPermission) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - androidx.compose.material3.FilledTonalButton(onClick = { requestLocation() }) { - Text(stringResource(R.string.weather_grant_location)) - } - } - } - } - } else { - var topPx by headerBottom - val topFadePx = if (topPx > 0f) topPx + with(density) { 40.dp.toPx() } else 0f - val bottomFadePx = WindowInsets.navigationBars.getBottom(density) + with(density) { 20.dp.toPx() } - Box(Modifier.fillMaxSize().nestedScroll(connection)) { - Box( - Modifier - .fillMaxSize() - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .drawWithContent { - drawContent() - if (topFadePx > 0f) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black), - startY = 0f, - endY = topFadePx, - ), - blendMode = BlendMode.DstIn, - ) - } - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Black, Color.Transparent), - startY = size.height - bottomFadePx, - endY = size.height, - ), - topLeft = androidx.compose.ui.geometry.Offset(0f, size.height - bottomFadePx), - size = androidx.compose.ui.geometry.Size(size.width, bottomFadePx), - blendMode = BlendMode.DstIn, - ) - } - .progressiveBlur(blurRadius = 40f, height = topFadePx, direction = BlurDirection.TOP, showGradientOverlay = false) - .progressiveBlur(blurRadius = 14f, height = bottomFadePx, direction = BlurDirection.BOTTOM, showGradientOverlay = false), - ) { - Column( - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(top = with(density) { topPx.toDp() } + 28.dp) - .navigationBarsPadding() - .padding(bottom = 40.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - snapshot.activeAlerts().sortedByDescending { it.severity.ordinal } - .forEach { AlertCard(it, palette, Modifier.padding(horizontal = SIDE_PADDING)) } - HourlySection(snapshot, unit, palette) - snapshot.daily.orEmpty().takeIf { it.isNotEmpty() } - ?.let { DailySection(it, unit, palette, Modifier.padding(horizontal = SIDE_PADDING)) } - DetailsSection(snapshot, unit, palette, Modifier.padding(horizontal = SIDE_PADDING)) - SunSection(snapshot, palette, Modifier.padding(horizontal = SIDE_PADDING)) - Footer( - modifier = Modifier.padding(horizontal = SIDE_PADDING), - snapshot = snapshot, - loading = state.loading, - error = state.error, - palette = palette, - onRefresh = { - HapticUtil.performVirtualKeyHaptic(view) - scope.launch { WeatherRepository.refresh(context, force = true) } - }, - ) - } - } - Column( - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .onSizeChanged { topPx = it.height.toFloat() } - .statusBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - val progress = collapse.floatValue - Spacer(Modifier.height(14.dp)) - Box(Modifier.foldAway(((progress - 0.25f) / 0.5f).coerceIn(0f, 1f))) { - LocationChip(snapshot, palette, Modifier.padding(horizontal = SIDE_PADDING)) - } - Header(snapshot, unit, palette, progress, Modifier.padding(horizontal = SIDE_PADDING)) - } - } - } - } - } - } -} - -private fun skyState(snapshot: WeatherSnapshot, now: Long): Pair? { - val rise = snapshot.extras?.sunriseMillis ?: return null - val set = snapshot.extras?.sunsetMillis ?: return null - val day = 24 * 60 * 60_000L - val shift = Math.floorDiv(now - rise, day) * day - val r = rise + shift - val s = set + shift - val isSun = now in r..s - val t = when { - isSun -> (now - r).toFloat() / (s - r).coerceAtLeast(1L) - now > s -> (now - s).toFloat() / ((r + day) - s).coerceAtLeast(1L) - else -> (now - (s - day)).toFloat() / (r - (s - day)).coerceAtLeast(1L) - }.coerceIn(0f, 1f) - return t to isSun -} - -private val SKY_HEIGHT = 360.dp - -@Composable -private fun SkyBody(snapshot: WeatherSnapshot, now: Long, collapse: () -> Float) { - val (t, isSun) = skyState(snapshot, now) ?: return - val hide = (1f - collapse() * 1.6f).coerceIn(0f, 1f) - if (hide <= 0f) return - val horizonFade = (minOf(t, 1f - t) / 0.1f).coerceIn(0f, 1f) - val color = if (isSun) Color(0xFFFFE2A8) else Color(0xFFE6ECFF) - androidx.compose.foundation.Canvas( - Modifier - .fillMaxWidth() - .height(SKY_HEIGHT) - .graphicsLayer { translationY = -collapse() * 60.dp.toPx() }, - ) { - val x = size.width * (0.9f - 0.8f * t) - val y = size.height * 0.66f - size.height * 0.4f * kotlin.math.sin(Math.PI.toFloat() * t) - val alpha = 0.5f * hide * horizonFade - val glowRadius = 110.dp.toPx() - drawCircle( - brush = Brush.radialGradient( - colors = listOf(color.copy(alpha = alpha * 0.55f), Color.Transparent), - center = Offset(x, y), - radius = glowRadius, - ), - radius = glowRadius, - center = Offset(x, y), - ) - drawCircle(color.copy(alpha = alpha * 0.7f), radius = (if (isSun) 20.dp else 16.dp).toPx(), center = Offset(x, y)) - } -} - -private val LocalRainSurfaces = androidx.compose.runtime.staticCompositionLocalOf?> { null } - -private class RainSurfaceSource(val corner: Float, val mask: (() -> androidx.compose.ui.geometry.Rect)?) { - var coordinates: androidx.compose.ui.layout.LayoutCoordinates? = null - - fun resolve(key: String): RainSurface? { - val c = coordinates?.takeIf { it.isAttached } ?: return null - val position = c.positionInRoot() - val local = androidx.compose.ui.geometry.Rect(androidx.compose.ui.geometry.Offset.Zero, androidx.compose.ui.geometry.Size(c.size.width.toFloat(), c.size.height.toFloat())) - val visible = (mask?.invoke()?.intersect(local) ?: local).translate(position) - if (visible.width <= 0f || visible.height <= 0f) return null - return RainSurface(key, visible, corner, position.x, local.width) - } -} - -@Composable -private fun Modifier.rainSurface(key: String, corner: androidx.compose.ui.unit.Dp = 28.dp, mask: (() -> androidx.compose.ui.geometry.Rect)? = null): Modifier { - val registry = LocalRainSurfaces.current ?: return this - val cornerPx = with(LocalDensity.current) { corner.toPx() } - val source = remember(key, cornerPx) { RainSurfaceSource(cornerPx, mask) } - androidx.compose.runtime.DisposableEffect(key, source) { - registry[key] = source - onDispose { registry.remove(key) } - } - return this.onGloballyPositioned { source.coordinates = it } -} - -private val SIDE_PADDING = 20.dp -private val COLLAPSE_RANGE = 520.dp -private const val COMBINE_AT = 0.9f -private const val HEADER_EXTRAS_DP = 170f - -@Composable -private fun LocationChip(snapshot: WeatherSnapshot, palette: WeatherPalette, modifier: Modifier) { - val place = listOf(snapshot.locationName, snapshot.region).filter { it.isNotBlank() }.joinToString(", ") - if (place.isBlank()) return - AssistChip( - onClick = {}, - modifier = modifier, - label = { Text(place, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium) }, - leadingIcon = { Icon(painterResource(R.drawable.rounded_location_on_24), null, modifier = Modifier.size(20.dp)) }, - shape = CircleShape, - border = null, - colors = AssistChipDefaults.assistChipColors( - containerColor = palette.card, - labelColor = palette.onBase, - leadingIconContentColor = palette.accent, - ), - ) -} - -internal fun Modifier.foldAway(fraction: Float): Modifier = - this - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, (placeable.height * (1f - fraction)).roundToInt()) { placeable.placeRelative(0, 0) } - } - .graphicsLayer { alpha = (1f - fraction * 1.4f).coerceIn(0f, 1f) } - -private val temperatureFonts = java.util.concurrent.ConcurrentHashMap() - -@OptIn(ExperimentalTextApi::class) -internal fun temperatureFont(widthAxis: Int, weightAxis: Int): FontFamily = - temperatureFonts.getOrPut(widthAxis * 10_000 + weightAxis) { - FontFamily( - Font( - R.font.google_sans_flex, - weight = FontWeight(weightAxis), - variationSettings = FontVariation.Settings( - FontVariation.width(widthAxis.toFloat()), - FontVariation.weight(weightAxis), - FontVariation.Setting("ROND", 100f), - ), - ), - ) - } - -@Composable -private fun Header(snapshot: WeatherSnapshot, unit: TemperatureUnit, palette: WeatherPalette, progress: Float, modifier: Modifier) { - val number = WeatherFormat.temperature(snapshot.tempC, unit).removeSuffix("°") - - val screenHeightDp = androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp - val fontScale = LocalDensity.current.fontScale - - val heightCap = ((screenHeightDp * 0.6f - HEADER_EXTRAS_DP) / 0.86f / fontScale).coerceAtLeast(64f) - val expanded = minOf(if (number.length >= 3) 290f else 390f, heightCap) - val size = lerp(expanded, 44f, progress).sp - - val font = temperatureFont(lerp(52f, 125f, progress).roundToInt(), lerp(1f, 800f, progress).roundToInt()) - val hazeStrength = ((snapshot.tempC - 30.0) / 12.0).toFloat().coerceIn(0f, 1f) * (1f - progress * 5f).coerceIn(0f, 1f) - var combined by remember { mutableStateOf(false) } - if (progress >= COMBINE_AT) combined = true else if (progress < COMBINE_AT - 0.08f) combined = false - val morph by animateFloatAsState(if (combined) 1f else 0f, tween(320), label = "weatherHeaderMorph") - Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Spacer(Modifier.height(lerp(20f, 0f, progress).dp)) - TemperatureAndCondition( - progress = morph, - gap = lerp(4f, 16f, morph).dp, - digits = { - Row(verticalAlignment = Alignment.Top, modifier = Modifier.heatHaze(hazeStrength)) { - DegreeSign(size, font, Color.Transparent) - Text( - number, - color = palette.onBase, - fontFamily = font, - fontSize = size, - lineHeight = size * 0.86f, - letterSpacing = lerp(-14f, -1f, progress).sp, - maxLines = 1, - softWrap = false, - ) - DegreeSign(size, font, palette.onBase) - } - }, - condition = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Icon( - painterResource(WeatherFormat.icon(snapshot.condition, snapshot.isDay)), - contentDescription = null, - tint = palette.accent, - modifier = Modifier.size(32.dp), - ) - Text(snapshot.conditionText, color = palette.onBase, style = MaterialTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - }, - ) - Column(Modifier.foldAway((progress / 0.5f).coerceIn(0f, 1f))) { - Spacer(Modifier.height(6.dp)) - Text( - listOf( - stringResource(R.string.weather_high_low, WeatherFormat.temperature(snapshot.highC, unit), WeatherFormat.temperature(snapshot.lowC, unit)), - stringResource(R.string.weather_feels_like, WeatherFormat.temperature(snapshot.feelsLikeC, unit)), - ).joinToString(" · "), - color = palette.onBaseMuted, - style = MaterialTheme.typography.bodyLarge, - ) - } - } -} - -@Composable -private fun TemperatureAndCondition( - progress: Float, - gap: androidx.compose.ui.unit.Dp, - digits: @Composable () -> Unit, - condition: @Composable () -> Unit, -) { - Layout(content = { digits(); condition() }) { measurables, constraints -> - val gapPx = gap.roundToPx() - val loose = constraints.copy(minWidth = 0, minHeight = 0) - val d = measurables[0].measure(loose) - val cMax = lerp(constraints.maxWidth.toFloat(), (constraints.maxWidth - d.width - gapPx).coerceAtLeast(0).toFloat(), progress).roundToInt() - val c = measurables[1].measure(loose.copy(maxWidth = cMax)) - val width = constraints.maxWidth - val expandedHeight = d.height + gapPx + c.height - val collapsedHeight = maxOf(d.height, c.height) - val height = lerp(expandedHeight.toFloat(), collapsedHeight.toFloat(), progress).roundToInt() - val total = d.width + gapPx + c.width - val collapsedStart = (width - total) / 2 - layout(width, height) { - val dx = lerp(((width - d.width) / 2).toFloat(), collapsedStart.toFloat(), progress).roundToInt() - val dy = lerp(0f, ((collapsedHeight - d.height) / 2).toFloat(), progress).roundToInt() - val cx = lerp(((width - c.width) / 2).toFloat(), (collapsedStart + d.width + gapPx).toFloat(), progress).roundToInt() - val cy = lerp((d.height + gapPx).toFloat(), ((collapsedHeight - c.height) / 2).toFloat(), progress).roundToInt() - d.placeRelative(dx, dy) - c.placeRelative(cx, cy) - } - } -} - -@Composable -private fun DegreeSign(digitSize: androidx.compose.ui.unit.TextUnit, font: FontFamily, color: Color) { - val size = digitSize * 0.42f - Text( - "°", - color = color, - fontFamily = font, - fontSize = size, - lineHeight = size, - maxLines = 1, - softWrap = false, - ) -} - -@Composable -private fun AlertCard(alert: WeatherAlert, palette: WeatherPalette, modifier: Modifier) { - val context = LocalContext.current - val alertColor = MaterialTheme.colorScheme.error - Column( - modifier - .rainSurface("alert:${alert.id}") - .fillMaxWidth() - .clip(MaterialTheme.shapes.extraLarge) - .background(alertColor.copy(alpha = 0.22f)) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Icon(painterResource(R.drawable.rounded_warning_24), null, tint = alertColor, modifier = Modifier.size(24.dp)) - Text(alert.event, color = palette.onBase, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) - } - alert.expiresMillis?.let { - Text(stringResource(R.string.weather_alert_until, formatTime(context, it)), color = palette.onBaseMuted, style = MaterialTheme.typography.bodyMedium) - } - if (alert.headline != alert.event) Text(alert.headline, color = palette.onBase, style = MaterialTheme.typography.bodyMedium) - if (alert.description.isNotBlank()) Text(alert.description, color = palette.onBaseMuted, style = MaterialTheme.typography.bodySmall) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun HourlySection(snapshot: WeatherSnapshot, unit: TemperatureUnit, palette: WeatherPalette) { - if (snapshot.hourly.isEmpty()) return - val context = LocalContext.current - val hours = snapshot.hourly - val carouselState = rememberCarouselState { hours.size } - Column { - HorizontalMultiBrowseCarousel( - state = carouselState, - preferredItemWidth = 104.dp, - itemSpacing = 4.dp, - contentPadding = PaddingValues(horizontal = SIDE_PADDING), - modifier = Modifier.fillMaxWidth().height(176.dp), - ) { index -> - val hour = hours[index] - Column( - Modifier - .rainSurface("hourly:$index", mask = { carouselItemDrawInfo.maskRect }) - .fillMaxSize() - .maskClip(MaterialTheme.shapes.extraLarge) - .background(palette.card) - .padding(vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text(formatHour(context, hour.timeMillis), color = palette.onBaseMuted, style = MaterialTheme.typography.labelLarge, maxLines = 1, softWrap = false) - Icon( - painterResource(WeatherFormat.icon(hour.condition, hour.isDay)), - null, - tint = palette.accent, - modifier = Modifier.size(32.dp), - ) - Text(WeatherFormat.temperature(hour.tempC, unit), color = palette.onBase, style = MaterialTheme.typography.titleLarge, maxLines = 1, softWrap = false) - Text( - if (hour.chanceOfRain > 0) "${hour.chanceOfRain}%" else " ", - color = palette.accent, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - softWrap = false, - ) - } - } - } -} - -@Composable -private fun DailySection(days: List, unit: TemperatureUnit, palette: WeatherPalette, modifier: Modifier) { - val low = days.minOf { it.lowC } - val high = days.maxOf { it.highC } - val span = (high - low).coerceAtLeast(1.0) - Column(modifier) { - RoundedCardContainer(modifier = Modifier.rainSurface("daily"), spacing = 2.dp, cornerRadius = 28.dp) { - days.forEachIndexed { index, day -> - Surface(color = palette.card, shape = MaterialTheme.shapes.extraSmall, modifier = Modifier.fillMaxWidth()) { - Row( - Modifier.padding(horizontal = 20.dp, vertical = 18.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - if (index == 0) stringResource(R.string.weather_detail_today) else dayName(day.dayMillis), - color = palette.onBase, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.width(64.dp), - maxLines = 1, - ) - Icon(painterResource(WeatherFormat.icon(day.condition, true)), null, tint = palette.accent, modifier = Modifier.size(26.dp)) - Text( - if (day.chanceOfRain > 0) "${day.chanceOfRain}%" else "", - color = palette.accent, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.width(36.dp), - ) - Text(WeatherFormat.temperature(day.lowC, unit), color = palette.onBaseMuted, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.width(38.dp), textAlign = TextAlign.End) - Box(Modifier.weight(1f).height(8.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.12f))) { - val start = ((day.lowC - low) / span).toFloat().coerceIn(0f, 1f) - val end = ((day.highC - low) / span).toFloat().coerceIn(start, 1f) - Row(Modifier.matchParentSize()) { - if (start > 0f) Spacer(Modifier.weight(start)) - Box(Modifier.weight((end - start).coerceAtLeast(0.05f)).fillMaxHeight().clip(CircleShape).background(palette.accent)) - if (end < 1f) Spacer(Modifier.weight(1f - end)) - } - } - Text(WeatherFormat.temperature(day.highC, unit), color = palette.onBase, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.width(38.dp)) - } - } - } - } - } -} - -private class Detail(val icon: Int, val label: Int, val value: String) - -@Composable -private fun DetailsSection(snapshot: WeatherSnapshot, unit: TemperatureUnit, palette: WeatherPalette, modifier: Modifier) { - val imperial = unit == TemperatureUnit.FAHRENHEIT - val extras = snapshot.extras - val details = buildList { - add(Detail(R.drawable.rounded_water_drop_24, R.string.weather_detail_humidity, "${snapshot.humidity}%")) - add( - Detail( - R.drawable.rounded_air_24, - R.string.weather_detail_wind, - WeatherFormat.wind(snapshot.windKph, unit) + (extras?.windDirectionDeg?.let { " ${compass(it)}" } ?: ""), - ), - ) - extras?.windGustKph?.let { add(Detail(R.drawable.rounded_air_24, R.string.weather_detail_gusts, WeatherFormat.wind(it, unit))) } - add(Detail(R.drawable.rounded_rainy_24, R.string.weather_detail_rain_chance, "${snapshot.chanceOfRain}%")) - extras?.precipitationMm?.let { - add(Detail(R.drawable.rounded_rainy_24, R.string.weather_detail_precipitation, if (imperial) "%.2f in".format(Locale.US, it / 25.4) else "%.1f mm".format(Locale.US, it))) - } - extras?.uvIndex?.let { add(Detail(R.drawable.rounded_wb_sunny_24, R.string.weather_detail_uv, it.roundToInt().toString())) } - extras?.pressureHpa?.let { - add(Detail(R.drawable.rounded_cloud_24, R.string.weather_detail_pressure, if (imperial) "%.2f inHg".format(Locale.US, it * 0.02953) else "${it.roundToInt()} hPa")) - } - extras?.visibilityKm?.let { - add(Detail(R.drawable.rounded_visibility_24, R.string.weather_detail_visibility, if (imperial) "%.1f mi".format(Locale.US, it / 1.609344) else "%.1f km".format(Locale.US, it))) - } - extras?.dewPointC?.let { add(Detail(R.drawable.rounded_water_drop_24, R.string.weather_detail_dew_point, WeatherFormat.temperature(it, unit))) } - extras?.cloudCover?.let { add(Detail(R.drawable.rounded_cloud_24, R.string.weather_detail_cloud_cover, "$it%")) } - } - Column(modifier) { - Surface(color = palette.card, shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.rainSurface("details").fillMaxWidth()) { - Column(Modifier.padding(vertical = 12.dp, horizontal = 8.dp)) { - details.chunked(2).forEach { row -> - Row(Modifier.fillMaxWidth()) { - row.forEach { DetailTile(it, palette, Modifier.weight(1f)) } - if (row.size == 1) Spacer(Modifier.weight(1f)) - } - } - } - } - } -} - -@Composable -private fun DetailTile(detail: Detail, palette: WeatherPalette, modifier: Modifier) { - Row( - modifier.padding(horizontal = 12.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp), - ) { - Icon(painterResource(detail.icon), null, tint = palette.accent, modifier = Modifier.size(26.dp)) - Column { - Text(stringResource(detail.label), color = palette.onBaseMuted, style = MaterialTheme.typography.labelMedium) - Text(detail.value, color = palette.onBase, style = MaterialTheme.typography.titleMedium, maxLines = 1) - } - } -} - -@Composable -private fun SunSection(snapshot: WeatherSnapshot, palette: WeatherPalette, modifier: Modifier) { - val rise = snapshot.extras?.sunriseMillis ?: return - val set = snapshot.extras?.sunsetMillis ?: return - val context = LocalContext.current - Column(modifier) { - RoundedCardContainer(modifier = Modifier.rainSurface("sun"), spacing = 2.dp, cornerRadius = 28.dp) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) { - SunTile(R.string.weather_detail_sunrise, formatTime(context, rise), palette, Modifier.weight(1f)) - SunTile(R.string.weather_detail_sunset, formatTime(context, set), palette, Modifier.weight(1f)) - } - } - } -} - -@Composable -private fun SunTile(label: Int, time: String, palette: WeatherPalette, modifier: Modifier) { - Surface(color = palette.card, shape = MaterialTheme.shapes.extraSmall, modifier = modifier) { - Column(Modifier.padding(20.dp)) { - Text(stringResource(label), color = palette.onBaseMuted, style = MaterialTheme.typography.labelMedium) - Text(time, color = palette.onBase, style = MaterialTheme.typography.headlineSmall) - } - } -} - -@Composable -private fun Footer( - modifier: Modifier, - snapshot: WeatherSnapshot, - loading: Boolean, - error: WeatherError?, - palette: WeatherPalette, - onRefresh: () -> Unit, -) { - val context = LocalContext.current - var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(snapshot.updatedAt) { - while (true) { - now = System.currentTimeMillis() - delay(30_000L) - } - } - val age = DateUtils.getRelativeTimeSpanString( - snapshot.updatedAt, - maxOf(now, snapshot.updatedAt), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE, - ).toString() - Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text( - text = error?.let { errorLabel(context, it) } ?: "${WeatherProviders.byId(snapshot.providerId).displayName} - $age", - color = palette.onBaseMuted, - style = MaterialTheme.typography.labelLarge, - textAlign = TextAlign.End, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (loading) { - LoadingIndicator(Modifier.size(28.dp)) - } else { - Icon( - painterResource(R.drawable.rounded_refresh_24), - contentDescription = null, - tint = palette.onBase, - modifier = Modifier.clip(CircleShape).clickable(onClick = onRefresh).padding(8.dp).size(20.dp), - ) - } - } -} - -private fun errorLabel(context: Context, error: WeatherError?): String { - val base = context.getString( - when (error) { - WeatherError.MissingApiKey -> R.string.weather_error_missing_key - WeatherError.InvalidApiKey -> R.string.weather_error_invalid_key - WeatherError.LocationPermission -> R.string.weather_error_location_permission - WeatherError.NoLocation -> R.string.weather_error_no_location - WeatherError.Network -> R.string.weather_error_network - is WeatherError.Unknown, null -> R.string.weather_error_unknown - }, - ) - val detail = (error as? WeatherError.Unknown)?.message?.takeIf { it.isNotBlank() } - return if (detail != null) "$base: $detail" else base -} - -private fun compass(degrees: Double): String { - val points = listOf("N", "NE", "E", "SE", "S", "SW", "W", "NW") - return points[(((degrees % 360 + 360) % 360) / 45.0).roundToInt() % 8] -} - -private fun dayName(millis: Long): String = SimpleDateFormat("EEE", Locale.getDefault()).format(Date(millis)) - -private fun formatTime(context: Context, millis: Long): String { - val pattern = if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm a" - return SimpleDateFormat(pattern, Locale.getDefault()).format(Date(millis)) -} - -private fun formatHour(context: Context, millis: Long): String { - val pattern = if (DateFormat.is24HourFormat(context)) "HH" else "ha" - return SimpleDateFormat(pattern, Locale.getDefault()).format(Date(millis)).lowercase(Locale.getDefault()) -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherHeaderCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherHeaderCard.kt deleted file mode 100644 index 29faf354b9..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherHeaderCard.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.sameerasw.essentials.ui.features.weather - -import android.content.Intent -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -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.graphicsLayer -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.lerp -import com.sameerasw.essentials.R -import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.ui.activities.WeatherDetailActivity -import com.sameerasw.essentials.weather.WeatherFormat -import com.sameerasw.essentials.weather.WeatherRepository -import com.sameerasw.essentials.weather.effects.WeatherEffects -import com.sameerasw.essentials.weather.location.DeviceLocationSource -import kotlin.math.roundToInt - -@Composable -fun WeatherHeaderCard( - height: Dp, - minHeight: Dp, - maxHeight: Dp, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val state by WeatherRepository.state.collectAsState() - val presentation = rememberWeatherPresentation(state.snapshot) - val snapshot = presentation.snapshot - val palette = presentation.palette - - LaunchedEffect(Unit) { - WeatherRepository.ensureLoaded(context) - val settings = SettingsRepository(context) - val canLocate = settings.getWeatherLocationMode() == "manual" || DeviceLocationSource.hasPermission(context) - if (canLocate && WeatherRepository.isStale(context)) WeatherRepository.refresh(context) - } - - val expansion = ((height - minHeight) / (maxHeight - minHeight)).coerceIn(0f, 1f) - val shape = RoundedCornerShape(28.dp) - Box( - modifier - .fillMaxWidth() - .height(height) - .clip(shape) - .background(Brush.verticalGradient(0f to palette.glow, 0.55f to palette.glowSecondary, 1f to palette.base)) - .clickable { - context.startActivity(Intent(context, WeatherDetailActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - }, - contentAlignment = Alignment.Center, - ) { - if (!presentation.effectSpec.isEmpty) { - WeatherEffects(spec = presentation.effectSpec, modifier = Modifier.matchParentSize(), strength = 1.5f, haptics = presentation.haptics) - } - if (snapshot == null) { - if (state.loading) { - LoadingIndicator() - } else { - Icon(painterResource(R.drawable.rounded_cloud_24), null, tint = palette.onBaseMuted, modifier = Modifier.size(48.dp)) - } - } else { - val number = WeatherFormat.temperature(snapshot.tempC, presentation.unit).removeSuffix("\u00b0") - val digitSize = lerp(if (number.length >= 3) 70f else 84f, if (number.length >= 3) 120f else 150f, expansion).sp - val font = temperatureFont(lerp(125f, 52f, expansion).roundToInt(), lerp(400f, 60f, expansion).roundToInt()) - val place = listOf(snapshot.locationName, snapshot.region).filter { it.isNotBlank() }.joinToString(", ") - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { - if (place.isNotBlank()) { - Row( - Modifier - .foldAway((1f - (expansion - 0.3f) / 0.5f).coerceIn(0f, 1f)) - .clip(CircleShape) - .background(palette.card) - .padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon(painterResource(R.drawable.rounded_location_on_24), null, tint = palette.accent, modifier = Modifier.size(18.dp)) - Text(place, color = palette.onBase, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - Row(verticalAlignment = Alignment.Top) { - Text("\u00b0", color = androidx.compose.ui.graphics.Color.Transparent, fontFamily = font, fontSize = digitSize * 0.42f, lineHeight = digitSize * 0.42f, maxLines = 1, softWrap = false) - Text( - number, - color = palette.onBase, - fontFamily = font, - fontSize = digitSize, - lineHeight = digitSize * 0.86f, - letterSpacing = lerp(-1f, -10f, expansion).sp, - maxLines = 1, - softWrap = false, - ) - Text("\u00b0", color = palette.onBase, fontFamily = font, fontSize = digitSize * 0.42f, lineHeight = digitSize * 0.42f, maxLines = 1, softWrap = false) - } - Row( - Modifier.graphicsLayer { alpha = ((expansion - 0.15f) / 0.6f).coerceIn(0f, 1f) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon(painterResource(WeatherFormat.icon(snapshot.condition, snapshot.isDay)), null, tint = palette.accent, modifier = Modifier.size(24.dp)) - Text(snapshot.conditionText, color = palette.onBase, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - } - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherPalette.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherPalette.kt deleted file mode 100644 index 33c5de0df3..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherPalette.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.sameerasw.essentials.ui.features.weather - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import java.util.Calendar - -class WeatherPalette( - val glow: Color, - val glowSecondary: Color, - val accent: Color, - val base: Color = Color.Black, - val card: Color = Color.White.copy(alpha = 0.09f), - val onBase: Color = Color.White, - val onBaseMuted: Color = Color.White.copy(alpha = 0.66f), -) { - private enum class Phase(val sky: Color, val skyLow: Color, val accent: Color) { - NIGHT(Color(0xFF0B1230), Color(0xFF070B1C), Color(0xFFB8C4FF)), - DAWN(Color(0xFF4A3470), Color(0xFF8A4B5E), Color(0xFFFFB38A)), - DAY(Color(0xFF14609F), Color(0xFF0C3C66), Color(0xFFFFD166)), - DUSK(Color(0xFF5B2B66), Color(0xFF9A4A3C), Color(0xFFFF9E80)), - } - - private class Tint(val color: Color, val accent: Color, val amount: Float) - - companion object { - val Neutral = WeatherPalette(Color(0xFF1E1E1E), Color(0xFF121212), Color.White) - - private const val TWILIGHT_MS = 50 * 60_000L - - fun from(snapshot: WeatherSnapshot?, now: Long, override: String? = null): WeatherPalette { - if (snapshot == null) return Neutral - val phase = when (override) { - "dawn" -> Phase.DAWN - "day" -> Phase.DAY - "dusk" -> Phase.DUSK - "night" -> Phase.NIGHT - else -> phaseFor(snapshot, now) - } - val tint = tintFor(snapshot.condition) - val glow = lerp(phase.sky, tint.color, tint.amount) - val secondary = lerp(phase.skyLow, lerp(tint.color, Color.Black, 0.45f), tint.amount) - val accent = lerp(phase.accent, tint.accent, tint.amount * 0.7f) - return WeatherPalette(glow, secondary, accent) - } - - private fun phaseFor(snapshot: WeatherSnapshot, now: Long): Phase { - val rise = snapshot.extras?.sunriseMillis - val set = snapshot.extras?.sunsetMillis - if (rise != null && set != null) { - val day = 24 * 60 * 60_000L - val shift = Math.floorDiv(now - rise, day) * day - val r = rise + shift - val s = set + shift - return when { - now in (r - TWILIGHT_MS)..(r + TWILIGHT_MS) -> Phase.DAWN - now in (s - TWILIGHT_MS)..(s + TWILIGHT_MS) -> Phase.DUSK - now in r..s -> Phase.DAY - else -> Phase.NIGHT - } - } - val hour = Calendar.getInstance().apply { timeInMillis = now }.get(Calendar.HOUR_OF_DAY) - return when { - !snapshot.isDay -> Phase.NIGHT - hour in 5..7 -> Phase.DAWN - hour in 17..19 -> Phase.DUSK - else -> Phase.DAY - } - } - - private fun tintFor(condition: WeatherCondition): Tint = when (condition) { - WeatherCondition.CLEAR -> Tint(Color(0xFF14609F), Color(0xFFFFD166), 0f) - WeatherCondition.PARTLY_CLOUDY -> Tint(Color(0xFF3B5A75), Color(0xFFFFE0A0), 0.25f) - WeatherCondition.CLOUDY -> Tint(Color(0xFF3A434D), Color(0xFFB0BEC5), 0.6f) - WeatherCondition.FOG -> Tint(Color(0xFF424A50), Color(0xFFCFD8DC), 0.7f) - WeatherCondition.DRIZZLE -> Tint(Color(0xFF22384A), Color(0xFF7CC4FF), 0.55f) - WeatherCondition.RAIN -> Tint(Color(0xFF1C3145), Color(0xFF6EC1FF), 0.65f) - WeatherCondition.HEAVY_RAIN -> Tint(Color(0xFF142234), Color(0xFF4FA3E3), 0.75f) - WeatherCondition.SLEET -> Tint(Color(0xFF2B3A4A), Color(0xFFBBD9F2), 0.65f) - WeatherCondition.SNOW -> Tint(Color(0xFF3A4C60), Color(0xFFDCEBFF), 0.6f) - WeatherCondition.HAIL -> Tint(Color(0xFF26343F), Color(0xFFB7D4EA), 0.7f) - WeatherCondition.THUNDERSTORM -> Tint(Color(0xFF281E45), Color(0xFFFFE066), 0.8f) - WeatherCondition.UNKNOWN -> Tint(Color(0xFF2A2A2A), Color.White, 0.5f) - } - } -} - -@Composable -fun animatedPalette(target: WeatherPalette): WeatherPalette { - val glow by animateColorAsState(target.glow, tween(900), label = "weatherGlow") - val secondary by animateColorAsState(target.glowSecondary, tween(900), label = "weatherGlowSecondary") - val accent by animateColorAsState(target.accent, tween(900), label = "weatherAccent") - return WeatherPalette(glow, secondary, accent, target.base, target.card, target.onBase, target.onBaseMuted) -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherSettingsUI.kt deleted file mode 100644 index ea475767ab..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/weather/WeatherSettingsUI.kt +++ /dev/null @@ -1,598 +0,0 @@ -/* - * Copyright (c) 2026 sameerasw.com - * License: MIT License - * - * Feature Module: UI Feature - Display - * File: IslandWeatherOptionsBottomSheet.kt - * Description: Bottom sheet for configuring the Island weather plugin and its data source. - */ - -package com.sameerasw.essentials.ui.features.weather - -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.text.format.DateFormat -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import com.sameerasw.essentials.R -import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem -import com.sameerasw.essentials.ui.core.cards.ConfigPickerItem -import com.sameerasw.essentials.ui.core.cards.IconToggleItem -import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer -import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker -import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet -import com.sameerasw.essentials.utils.HapticUtil -import com.sameerasw.essentials.utils.PermissionUIHelper -import com.sameerasw.essentials.viewmodels.MainViewModel -import com.sameerasw.essentials.weather.WeatherRepository -import com.sameerasw.essentials.weather.effects.WeatherSimulation -import com.sameerasw.essentials.weather.location.DeviceLocationSource -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.WeatherError -import com.sameerasw.essentials.weather.provider.OpenMeteoModels -import com.sameerasw.essentials.weather.provider.WeatherProviderException -import com.sameerasw.essentials.weather.provider.WeatherProviders -import kotlinx.coroutines.launch -import java.util.Date - -import com.sameerasw.essentials.utils.WeatherLauncher - -private val REFRESH_OPTIONS = listOf(30, 60, 120, 180, 360) - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun WeatherSettingsUI( - viewModel: MainViewModel, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val view = LocalView.current - val scope = rememberCoroutineScope() - val settings = remember { SettingsRepository(context) } - var providerId by remember { mutableStateOf(WeatherProviders.resolve(settings.getWeatherProvider(), settings.getWeatherApiKey("weatherapi")).id) } - val provider = WeatherProviders.byId(providerId) - val weatherState by WeatherRepository.state.collectAsState() - - var experimental by remember { mutableStateOf(settings.isWeatherExperimentalEnabled()) } - var simulatedWeather by remember { mutableStateOf(settings.getString(SettingsRepository.KEY_DEBUG_SIMULATED_WEATHER, WeatherSimulation.OFF) ?: WeatherSimulation.OFF) } - var simulatedTime by remember { mutableStateOf(settings.getString(SettingsRepository.KEY_DEBUG_SIMULATED_TIME, "auto") ?: "auto") } - var simulatedTemp by remember { mutableStateOf(settings.getString(SettingsRepository.KEY_DEBUG_SIMULATED_TEMP, "auto") ?: "auto") } - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - androidx.compose.runtime.DisposableEffect(lifecycleOwner) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) experimental = settings.isWeatherExperimentalEnabled() - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - var showInLauncher by remember { mutableStateOf(WeatherLauncher.isVisible(context)) } - var effects by remember { mutableStateOf(settings.isIslandWeatherEffectsEnabled()) } - var weatherHaptics by remember { mutableStateOf(settings.isIslandWeatherHapticsEnabled()) } - var units by remember { mutableStateOf(settings.getWeatherUnits()) } - var refreshMinutes by remember { mutableIntStateOf(settings.getWeatherRefreshMinutes()) } - var savedKey by remember { mutableStateOf(settings.getWeatherApiKey(providerId).orEmpty()) } - var keyInput by remember { mutableStateOf(savedKey) } - var keyVisible by remember { mutableStateOf(false) } - var locationMode by remember { mutableStateOf(settings.getWeatherLocationMode()) } - var manualLabel by remember { mutableStateOf(settings.getWeatherManualLocation()?.third) } - var cityQuery by remember { mutableStateOf("") } - var cityResults by remember { mutableStateOf>(emptyList()) } - var searching by remember { mutableStateOf(false) } - var searchError by remember { mutableStateOf(null) } - - fun refreshNow() { - scope.launch { WeatherRepository.refresh(context, force = true) } - } - - val requestLocation = rememberLocationPermissionRequest { granted -> - viewModel.check(context) - if (granted) refreshNow() - } - - - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - SectionTitle(R.string.weather_section_source) - RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { - ConfigPickerItem( - title = stringResource(R.string.weather_provider_title), - selectedValue = provider.displayName, - iconRes = R.drawable.rounded_cloud_24, - modifier = Modifier.fillMaxWidth(), - ) { - WeatherProviders.all.forEach { option -> - SegmentedDropdownMenuItem( - text = { Text(option.displayName) }, - onClick = { - if (option.id != providerId) { - settings.setWeatherProvider(option.id) - providerId = option.id - savedKey = settings.getWeatherApiKey(option.id).orEmpty() - keyInput = savedKey - scope.launch { - WeatherRepository.clear(context) - WeatherRepository.refresh(context, force = true) - } - } - }, - ) - } - } - if (provider.id == "openmeteo") { - var modelId by remember { mutableStateOf(settings.getWeatherOpenMeteoModel()) } - ConfigPickerItem( - title = stringResource(R.string.weather_model_title), - selectedValue = OpenMeteoModels.label(modelId), - iconRes = R.drawable.rounded_cloud_24, - modifier = Modifier.fillMaxWidth(), - ) { - OpenMeteoModels.all.forEach { option -> - SegmentedDropdownMenuItem( - text = { Text(option.label) }, - onClick = { - if (option.id != modelId) { - settings.setWeatherOpenMeteoModel(option.id) - modelId = option.id - scope.launch { - WeatherRepository.clear(context) - WeatherRepository.refresh(context, force = true) - } - } - }, - ) - } - } - } - if (provider.requiresApiKey) { - Surface( - color = MaterialTheme.colorScheme.surfaceBright, - shape = MaterialTheme.shapes.extraSmall, - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = keyInput, - onValueChange = { keyInput = it.trim() }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringResource(R.string.weather_api_key_title)) }, - leadingIcon = { Icon(painterResource(R.drawable.rounded_key_24), contentDescription = null) }, - trailingIcon = { - Row { - IconButton(onClick = { - HapticUtil.performVirtualKeyHaptic(view) - clipboardText(context)?.let { keyInput = it.trim() } - }) { - Icon(painterResource(R.drawable.rounded_content_paste_24), contentDescription = stringResource(R.string.shorten_paste_button)) - } - IconButton(onClick = { - HapticUtil.performVirtualKeyHaptic(view) - keyVisible = !keyVisible - }) { - Icon( - painterResource(if (keyVisible) R.drawable.rounded_visibility_off_24 else R.drawable.rounded_visibility_24), - contentDescription = null, - ) - } - } - }, - visualTransformation = if (keyVisible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - autoCorrectEnabled = false, - imeAction = ImeAction.Done, - ), - shape = RoundedCornerShape(16.dp), - ) - Row(verticalAlignment = Alignment.CenterVertically) { - provider.signupUrl?.let { url -> - TextButton(onClick = { - HapticUtil.performVirtualKeyHaptic(view) - openUrl(context, url) - }) { - Text(stringResource(R.string.weather_get_api_key)) - } - } - Spacer(Modifier.weight(1f)) - Button( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - settings.setWeatherApiKey(providerId, keyInput) - savedKey = keyInput - refreshNow() - }, - enabled = keyInput != savedKey, - ) { - Text(stringResource(R.string.action_save)) - } - } - } - } - } - val sourceError = weatherState.error?.takeIf { it !is WeatherError.LocationPermission && it !is WeatherError.NoLocation } - AnimatedVisibility(visible = sourceError != null) { - val shownError = remember { mutableStateOf(sourceError) } - if (sourceError != null) shownError.value = sourceError - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.extraSmall, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - painterResource(R.drawable.rounded_warning_24), - contentDescription = null, - tint = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = shownError.value?.let { errorText(context, it) }.orEmpty(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } - } - } - } - - SectionTitle(R.string.perm_location_title) - RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { - SegmentedPicker( - items = listOf("device", "manual"), - selectedItem = locationMode, - onItemSelected = { - locationMode = it - settings.setWeatherLocationMode(it) - if (it == "device" && !DeviceLocationSource.hasPermission(context)) { - requestLocation() - } else { - refreshNow() - } - }, - labelProvider = { - context.getString(if (it == "manual") R.string.weather_location_manual else R.string.weather_location_device) - }, - modifier = Modifier.fillMaxWidth(), - ) - if (locationMode == "device" && !DeviceLocationSource.hasPermission(context)) { - IconToggleItem( - iconRes = R.drawable.rounded_my_location_24, - title = stringResource(R.string.weather_error_location_permission), - showToggle = false, - onClick = { requestLocation() }, - ) - } - if (locationMode == "manual") { - Surface(color = MaterialTheme.colorScheme.surfaceBright, modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - manualLabel?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.rounded_location_on_24), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Text(it, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 8.dp)) - } - } - OutlinedTextField( - value = cityQuery, - onValueChange = { cityQuery = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text(stringResource(R.string.weather_search_city)) }, - leadingIcon = { Icon(painterResource(R.drawable.rounded_location_city_24), contentDescription = null) }, - trailingIcon = { - if (searching) { - LoadingIndicator(Modifier.size(24.dp)) - } else { - IconButton( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - searching = true - searchError = null - scope.launch { - try { - cityResults = provider.searchCities(cityQuery, settings.getWeatherApiKey(providerId)) - if (cityResults.isEmpty()) searchError = R.string.weather_search_no_results - } catch (e: WeatherProviderException) { - searchError = if (e.reason == WeatherProviderException.Reason.INVALID_KEY) { - R.string.weather_error_missing_key - } else { - R.string.weather_error_network - } - } finally { - searching = false - } - } - }, - enabled = cityQuery.isNotBlank(), - ) { - Icon(painterResource(R.drawable.rounded_search_24), contentDescription = stringResource(R.string.action_search)) - } - } - }, - shape = RoundedCornerShape(16.dp), - ) - searchError?.let { - Text(stringResource(it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - cityResults.forEach { city -> - Text( - text = city.label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .fillMaxWidth() - .clickable { - HapticUtil.performVirtualKeyHaptic(view) - settings.setWeatherManualLocation(city.latitude, city.longitude, city.name) - manualLabel = city.name - cityResults = emptyList() - cityQuery = "" - refreshNow() - } - .padding(vertical = 10.dp), - ) - } - } - } - } - } - - SectionTitle(R.string.weather_section_display) - RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { - SegmentedPicker( - items = listOf( - SettingsRepository.WEATHER_UNITS_SYSTEM, - SettingsRepository.WEATHER_UNITS_CELSIUS, - SettingsRepository.WEATHER_UNITS_FAHRENHEIT, - ), - selectedItem = units, - onItemSelected = { - units = it - settings.setWeatherUnits(it) - }, - labelProvider = { - when (it) { - SettingsRepository.WEATHER_UNITS_CELSIUS -> "°C" - SettingsRepository.WEATHER_UNITS_FAHRENHEIT -> "°F" - else -> context.getString(R.string.weather_units_system) - } - }, - modifier = Modifier.fillMaxWidth(), - title = R.string.weather_units_title, - ) - ConfigPickerItem( - title = stringResource(R.string.weather_refresh_interval_title), - selectedValue = intervalLabel(context, refreshMinutes), - iconRes = R.drawable.rounded_schedule_24, - modifier = Modifier.fillMaxWidth(), - ) { - REFRESH_OPTIONS.forEach { minutes -> - SegmentedDropdownMenuItem( - text = { Text(intervalLabel(context, minutes)) }, - onClick = { - refreshMinutes = minutes - settings.setWeatherRefreshMinutes(minutes) - }, - ) - } - } - IconToggleItem( - iconRes = R.drawable.rounded_rainy_24, - title = stringResource(R.string.island_weather_effects_title), - isChecked = effects, - onCheckedChange = { - HapticUtil.performVirtualKeyHaptic(view) - effects = it - settings.setIslandWeatherEffectsEnabled(it) - }, - ) - IconToggleItem( - iconRes = R.drawable.rounded_thunderstorm_24, - title = stringResource(R.string.island_weather_haptics_title), - isChecked = weatherHaptics, - enabled = effects, - onCheckedChange = { - HapticUtil.performVirtualKeyHaptic(view) - weatherHaptics = it - settings.setIslandWeatherHapticsEnabled(it) - }, - ) - IconToggleItem( - iconRes = R.drawable.rounded_partly_cloudy_day_24, - title = stringResource(R.string.weather_show_in_launcher), - isChecked = showInLauncher, - onCheckedChange = { - HapticUtil.performVirtualKeyHaptic(view) - showInLauncher = it - WeatherLauncher.setVisible(context, it) - }, - ) - } - - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = statusText(context, weatherState.snapshot?.updatedAt, weatherState.error), - style = MaterialTheme.typography.bodyMedium, - color = if (weatherState.error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - if (weatherState.loading) { - LoadingIndicator(Modifier.size(32.dp)) - } else { - OutlinedButton(onClick = { - HapticUtil.performVirtualKeyHaptic(view) - refreshNow() - }) { - Text(stringResource(R.string.action_refresh)) - } - } - } - - if (experimental) { - SectionTitle(R.string.weather_section_experimental) - RoundedCardContainer(spacing = 2.dp, cornerRadius = 24.dp) { - ConfigPickerItem( - title = stringResource(R.string.dev_simulate_weather_title), - iconRes = R.drawable.rounded_partly_cloudy_day_24, - selectedValue = WeatherSimulation.presets.firstOrNull { it.id == simulatedWeather }?.label.orEmpty(), - modifier = Modifier.fillMaxWidth(), - ) { - WeatherSimulation.presets.forEach { preset -> - SegmentedDropdownMenuItem( - text = { Text(preset.label) }, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - simulatedWeather = preset.id - settings.putString(SettingsRepository.KEY_DEBUG_SIMULATED_WEATHER, preset.id) - }, - ) - } - } - SegmentedPicker( - items = listOf("auto", "dawn", "day", "dusk", "night"), - selectedItem = simulatedTime, - onItemSelected = { - simulatedTime = it - settings.putString(SettingsRepository.KEY_DEBUG_SIMULATED_TIME, it) - }, - labelProvider = { - context.getString( - when (it) { - "dawn" -> R.string.weather_sim_time_dawn - "day" -> R.string.weather_sim_time_day - "dusk" -> R.string.weather_sim_time_dusk - "night" -> R.string.weather_sim_time_night - else -> R.string.weather_sim_time_auto - }, - ) - }, - modifier = Modifier.fillMaxWidth(), - title = R.string.weather_sim_time_title, - ) - SegmentedPicker( - items = listOf("auto", "-15", "5", "22", "38", "50"), - selectedItem = simulatedTemp, - onItemSelected = { - simulatedTemp = it - settings.putString(SettingsRepository.KEY_DEBUG_SIMULATED_TEMP, it) - }, - labelProvider = { if (it == "auto") context.getString(R.string.weather_sim_time_auto) else "$it°" }, - modifier = Modifier.fillMaxWidth(), - title = R.string.weather_sim_temp_title, - ) - } - } - } -} - -@Composable -private fun SectionTitle(res: Int) { - Text( - text = stringResource(res), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp), - ) -} - -private fun intervalLabel(context: Context, minutes: Int): String = - if (minutes < 60) { - context.getString(R.string.weather_interval_minutes, minutes) - } else { - context.getString(R.string.weather_interval_hours, minutes / 60) - } - -private fun errorText(context: Context, error: WeatherError): String { - val base = context.getString( - when (error) { - WeatherError.MissingApiKey -> R.string.weather_error_missing_key - WeatherError.InvalidApiKey -> R.string.weather_error_invalid_key - WeatherError.LocationPermission -> R.string.weather_error_location_permission - WeatherError.NoLocation -> R.string.weather_error_no_location - WeatherError.Network -> R.string.weather_error_network - is WeatherError.Unknown -> R.string.weather_error_unknown - }, - ) - val detail = (error as? WeatherError.Unknown)?.message?.takeIf { it.isNotBlank() } - return if (detail != null) "$base: $detail" else base -} - -private fun statusText(context: Context, updatedAt: Long?, error: WeatherError?): String = when { - error != null -> errorText(context, error) - updatedAt != null -> context.getString( - R.string.weather_updated_at, - DateFormat.getTimeFormat(context).format(Date(updatedAt)), - ) - else -> context.getString(R.string.weather_not_loaded) -} - -private fun clipboardText(context: Context): String? { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return null - return clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString() -} - -private fun openUrl(context: Context, url: String) { - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - } catch (_: Exception) { - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/modifiers/HeatHazeModifier.kt b/app/src/main/java/com/sameerasw/essentials/ui/modifiers/HeatHazeModifier.kt deleted file mode 100644 index 28e5927172..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/modifiers/HeatHazeModifier.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.sameerasw.essentials.ui.modifiers - -import android.graphics.RenderEffect -import android.graphics.RuntimeShader -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asComposeRenderEffect -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity - -@Composable -fun Modifier.heatHaze(strength: Float): Modifier { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || strength <= 0f) return this - return HeatHazeApi33.apply(this, strength.coerceIn(0f, 1f), LocalDensity.current.density) -} - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -private object HeatHazeApi33 { - @Composable - fun apply(modifier: Modifier, strength: Float, density: Float): Modifier { - val shader = remember { runCatching { RuntimeShader(SOURCE) }.getOrNull() } ?: return modifier - val time = remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { - val start = withFrameNanos { it } - while (true) { - time.floatValue = withFrameNanos { (it - start) / 1_000_000_000f } - } - } - return modifier.graphicsLayer { - shader.setFloatUniform("resolution", size.width, size.height) - shader.setFloatUniform("time", time.floatValue) - shader.setFloatUniform("strength", strength) - shader.setFloatUniform("density", density) - renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "content").asComposeRenderEffect() - } - } - - private const val SOURCE = """ -uniform shader content; -uniform float2 resolution; -uniform float time; -uniform float strength; -uniform float density; - -half4 main(float2 p) { - float rise = p.y / density * 0.045 - time * 2.4; - float wobble = sin(rise) + 0.6 * sin(rise * 2.3 + p.x / density * 0.03 + time * 0.7); - float sway = sin(p.x / density * 0.05 + time * 1.3); - float lower = 0.35 + 0.65 * clamp(p.y / resolution.y, 0.0, 1.0); - float2 offset = float2(wobble * 1.5, sway * 0.7) * density * strength * lower; - return content.eval(p + offset); -} -""" -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/modifiers/RainOnGlassModifier.kt b/app/src/main/java/com/sameerasw/essentials/ui/modifiers/RainOnGlassModifier.kt deleted file mode 100644 index 2282296309..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/ui/modifiers/RainOnGlassModifier.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.sameerasw.essentials.ui.modifiers - -import android.graphics.RenderEffect -import android.graphics.RuntimeShader -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.asComposeRenderEffect -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity - -@Composable -fun Modifier.rainOnGlass(intensity: Float, slant: Float, light: () -> Offset): Modifier { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || intensity <= 0f) return this - return RainOnGlassApi33.apply(this, intensity.coerceIn(0f, 1f), slant, LocalDensity.current.density, light) -} - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -private object RainOnGlassApi33 { - @Composable - fun apply(modifier: Modifier, intensity: Float, slant: Float, density: Float, light: () -> Offset): Modifier { - val shader = remember { runCatching { RuntimeShader(SOURCE) }.getOrNull() } ?: return modifier - val time = remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { - val start = withFrameNanos { it } - while (true) { - time.floatValue = withFrameNanos { (it - start) / 1_000_000_000f } - } - } - return modifier.graphicsLayer { - shader.setFloatUniform("resolution", size.width, size.height) - shader.setFloatUniform("time", time.floatValue) - shader.setFloatUniform("intensity", intensity) - shader.setFloatUniform("slant", slant) - shader.setFloatUniform("density", density) - val l = light() - shader.setFloatUniform("lightPos", l.x * size.width, l.y) - renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "content").asComposeRenderEffect() - } - } - - private const val SOURCE = """ -uniform shader content; -uniform float2 resolution; -uniform float time; -uniform float intensity; -uniform float slant; -uniform float density; -uniform float2 lightPos; - -float hash11(float p) { - p = fract(p * 0.1031); - p *= p + 33.33; - p *= p + p; - return fract(p); -} - -float hash12(float2 p) { - float3 p3 = fract(float3(p.xyx) * 0.1031); - p3 += dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); -} - -void drop(float2 p, float2 c, float2 radii, inout float h, inout float2 g, inout float3 s, float2 L, inout float sh) { - float2 d = (p - c) / radii; - float r2 = dot(d, d); - if (r2 < 1.0) { - float w = 1.0 - r2; - h += w * w; - g += (-4.0 * w * d) * radii; - float cw = min(1.0, w * 4.0); - s += float3(d * cw, cw); - } else if (r2 < 2.6) { - float t = 1.0 - (r2 - 1.0) / 1.6; - sh += t * t * max(dot(-normalize(d), L), 0.0); - } -} - -float mergeY(float sN, float travel, float offset) { - return travel * (0.2 + 0.5 * hash11(sN * 6.7)) - offset; -} - -float pathX(float y, float sN, float col, float colW, float travel, float offset, float density, float slant) { - float ym = mergeY(sN, travel, offset); - float dev = (hash11(sN * 3.7) - 0.5) * colW * 0.9; - float wob = 0.16 * colW * (sin(y * 0.011 / density + sN * 5.0) + 0.5 * sin(y * 0.027 / density + sN * 9.0)); - float kink = smoothstep(ym, ym + 140.0 * density, y) * dev; - return (col + 0.5 + (hash11(sN * 4.9) - 0.5) * 0.35) * colW + wob + kink + slant * y * 0.12; -} - -half4 main(float2 p) { - float h = 0.0; - float2 g = float2(0.0); - float3 s = float3(0.0); - float sh = 0.0; - float2 L = normalize(lightPos - p + float2(0.0001, 0.0001)); - - float cell = 30.0 * density; - float2 cid = floor(p / cell); - float2 hh = float2(hash12(cid), hash12(cid + 17.3)); - if (hh.x < intensity * intensity * 0.3) { - float2 c = (cid + 0.25 + 0.5 * float2(hash12(cid + 3.1), hash12(cid + 9.7))) * cell; - float r = (1.8 + 3.0 * hh.y) * density; - drop(p, c, float2(r, r * 1.1), h, g, s, L, sh); - } - - float colW = 58.0 * density; - float colF = p.x / colW; - float col0 = floor(colF); - float neighbour = fract(colF) < 0.5 ? -1.0 : 1.0; - float travel = resolution.y + 320.0 * density; - float offset = 160.0 * density; - for (int side = 0; side < 2; side++) { - float col = col0 + (side == 0 ? 0.0 : neighbour); - for (int k = 0; k < 3; k++) { - float seed = col * 7.13 + float(k) * 19.7 + 3.0; - if (hash11(seed * 3.1) < clamp(intensity * 1.3 - float(k) * 0.35, 0.0, 1.0) * 0.75) { - float baseSpeed = (0.005 + 0.010 * hash11(seed * 5.3)) * resolution.y; - float s1 = 6.283 * hash11(seed * 1.3); - float s2 = 6.283 * hash11(seed * 2.9); - float w1 = 1.1 + 0.6 * hash11(seed * 6.1); - float w2 = 2.6 + 1.0 * hash11(seed * 7.7); - float phase = hash11(seed * 9.7) * travel; - - float raw = phase + baseSpeed * (1.5 * time + 0.4 * sin(w1 * time + s1) + 0.18 * sin(w2 * time + s2)); - float pass = floor(raw / travel); - float sN = seed + pass * 31.7; - float y = mod(raw, travel) - offset; - float big = hash11(sN * 2.3); - float r = (7.0 + 10.0 * big * big) * density; - float ym = mergeY(sN, travel, offset); - float grow = smoothstep(ym - 30.0 * density, ym + 40.0 * density, y); - float rr = r * mix(0.62, 1.0, grow); - float x = pathX(y, sN, col, colW, travel, offset, density, slant); - - float v = 1.5 + 0.4 * w1 * cos(w1 * time + s1) + 0.18 * w2 * cos(w2 * time + s2); - float sn = clamp(v / 2.83, 0.0, 1.0); - float rx = rr * (1.0 - 0.12 * sn); - float ry = rr * (1.15 + 0.2 * sn); - drop(p, float2(x, y - ry), float2(rx, ry), h, g, s, L, sh); - if (y < ym + 6.0 * density) { - float fside = hash11(sN * 1.9) < 0.5 ? -1.0 : 1.0; - float fr = r * (0.4 + 0.25 * hash11(sN * 2.1)); - float fx = pathX(ym, sN, col, colW, travel, offset, density, slant) + fside * (r * 0.7 + fr); - drop(p, float2(fx, ym + fr * 0.5), float2(fr, fr * 1.1), h, g, s, L, sh); - } - } - } - } - - half4 base = content.eval(p); - base.rgb *= 1.0 - min(sh, 1.0) * 0.07; - float mask = smoothstep(0.02, 0.14, h); - if (mask < 0.004) { - return base; - } - float2 sp = p + g * 0.22; - half4 refracted = (content.eval(sp) + content.eval(sp + float2(1.5, 0.0)) + content.eval(sp + float2(0.0, 1.5)) + content.eval(sp + float2(-1.5, -1.0))) * 0.25; - float2 nxy = s.xy / max(s.z, 0.0001); - float tl = length(nxy); - float2 n = nxy / max(tl, 0.0001); - float inset = smoothstep(0.2, 1.0, tl); - float lit = pow(max(dot(n, L), 0.0), 4.0) * inset * 0.34; - float shadow = pow(max(dot(n, -L), 0.0), 3.0) * inset * 0.3; - half4 color = mix(base, refracted, mask); - color.rgb = color.rgb * (1.0 - shadow * mask) + (lit + 0.03) * mask * color.a; - return color; -} -""" -} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/WeatherLauncher.kt b/app/src/main/java/com/sameerasw/essentials/utils/WeatherLauncher.kt deleted file mode 100644 index 6a83e29e4e..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/utils/WeatherLauncher.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.sameerasw.essentials.utils - -import android.content.ComponentName -import android.content.Context -import android.content.pm.PackageManager -import com.sameerasw.essentials.data.repository.SettingsRepository - -object WeatherLauncher { - private const val ALIAS = "com.sameerasw.essentials.WeatherLauncher" - - private fun component(context: Context) = ComponentName(context.packageName, ALIAS) - - fun isVisible(context: Context): Boolean = SettingsRepository(context).getBoolean(SettingsRepository.KEY_WEATHER_SHOW_IN_LAUNCHER, true) - - fun setVisible(context: Context, visible: Boolean) { - SettingsRepository(context).putBoolean(SettingsRepository.KEY_WEATHER_SHOW_IN_LAUNCHER, visible) - apply(context) - } - - fun apply(context: Context) { - val state = if (isVisible(context)) { - PackageManager.COMPONENT_ENABLED_STATE_ENABLED - } else { - PackageManager.COMPONENT_ENABLED_STATE_DISABLED - } - val target = component(context) - val pm = context.packageManager - if (pm.getComponentEnabledSetting(target) != state) { - pm.setComponentEnabledSetting(target, state, PackageManager.DONT_KILL_APP) - } - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt index d595d9c2eb..d0feb6c399 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/ui/PermissionUIHelper.kt @@ -19,6 +19,7 @@ import android.os.Build import android.provider.Settings import androidx.core.app.ActivityCompat import com.sameerasw.essentials.R +import com.sameerasw.essentials.weather.overcast.OvercastWeather import com.sameerasw.essentials.domain.model.AppPermission import com.sameerasw.essentials.domain.registry.PermissionRegistry import com.sameerasw.essentials.ui.core.sheets.PermissionItem @@ -517,6 +518,31 @@ object PermissionUIHelper { ) } + AppPermission.OVERCAST_WEATHER -> { + val installed = OvercastWeather.isInstalled(context) + val isGranted = viewModel.isOvercastWeatherPermissionGranted.value + PermissionItem( + iconRes = permission.iconRes, + title = permission.titleRes, + description = if (installed) R.string.perm_overcast_weather_desc else R.string.perm_overcast_weather_missing_desc, + dependentFeatures = PermissionRegistry.getFeatures(permission), + actionLabel = + when { + isGranted -> R.string.perm_action_granted + installed -> R.string.perm_action_grant + else -> R.string.perm_action_install_overcast + }, + action = { + if (!installed) { + OvercastWeather.openInstallPage(context) + } else { + (activity as? androidx.activity.ComponentActivity)?.let { viewModel.requestOvercastWeatherPermission(it) } + } + }, + isGranted = isGranted, + ) + } + AppPermission.NOTIFICATION_BUBBLES -> PermissionItem( iconRes = permission.iconRes, diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 3b84f9ea0c..4e4163a74a 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -76,6 +76,7 @@ import com.sameerasw.essentials.utils.AppIconUtil import com.sameerasw.essentials.utils.AppUtil import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.PermissionUtils +import com.sameerasw.essentials.weather.overcast.OvercastWeather import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.utils.RootUtils import com.sameerasw.essentials.utils.ShellUtils @@ -308,6 +309,7 @@ class MainViewModel : ViewModel() { val isFlashlightPocketTurnOffEnabled = mutableStateOf(false) val isFlashlightOverheatEnabled = mutableStateOf(true) val isLocationPermissionGranted = mutableStateOf(false) + val isOvercastWeatherPermissionGranted = mutableStateOf(false) val isBackgroundLocationPermissionGranted = mutableStateOf(false) val isFullScreenIntentPermissionGranted = mutableStateOf(false) val isBluetoothPermissionGranted = mutableStateOf(false) @@ -1849,6 +1851,7 @@ class MainViewModel : ViewModel() { PermissionUtils.isNotificationLightingAccessibilityServiceEnabled(context) isDefaultBrowserSet.value = PermissionUtils.isDefaultBrowser(context) isLocationPermissionGranted.value = PermissionUtils.hasLocationPermission(context) + isOvercastWeatherPermissionGranted.value = OvercastWeather.isAvailable(context) isBackgroundLocationPermissionGranted.value = PermissionUtils.hasBackgroundLocationPermission(context) isFullScreenIntentPermissionGranted.value = PermissionUtils.canUseFullScreenIntent(context) @@ -7563,6 +7566,19 @@ class MainViewModel : ViewModel() { ) } + /** + * Executes the request Overcast weather permission operation. + * + * @param activity [androidx.activity.ComponentActivity] Target activity. + */ + fun requestOvercastWeatherPermission(activity: androidx.activity.ComponentActivity) { + androidx.core.app.ActivityCompat.requestPermissions( + activity, + arrayOf(OvercastWeather.PERMISSION), + 1007, + ) + } + /** * Executes the request location permission operation. * diff --git a/app/src/main/java/com/sameerasw/essentials/weather/SettingsWeatherConfig.kt b/app/src/main/java/com/sameerasw/essentials/weather/SettingsWeatherConfig.kt deleted file mode 100644 index 54d86930d7..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/SettingsWeatherConfig.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.sameerasw.essentials.weather - -import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.provider.WeatherProviders - -class SettingsWeatherConfig(private val settings: SettingsRepository) : WeatherConfig { - override val providerId: String - get() = WeatherProviders.resolve(settings.getWeatherProvider(), settings.getWeatherApiKey("weatherapi")).id - override val apiKey: String? get() = settings.getWeatherApiKey(providerId) - override val locationMode: WeatherLocationMode - get() = if (settings.getWeatherLocationMode() == "manual") WeatherLocationMode.MANUAL else WeatherLocationMode.DEVICE - override val manualLocation: WeatherLocation? - get() = settings.getWeatherManualLocation()?.let { (lat, lon, name) -> WeatherLocation(lat, lon, name) } - override val refreshIntervalMinutes: Int get() = settings.getWeatherRefreshMinutes() -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/WeatherConfig.kt b/app/src/main/java/com/sameerasw/essentials/weather/WeatherConfig.kt deleted file mode 100644 index 1e32984079..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/WeatherConfig.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.sameerasw.essentials.weather - -import com.sameerasw.essentials.weather.model.WeatherLocation - -enum class WeatherLocationMode { DEVICE, MANUAL } - -interface WeatherConfig { - val providerId: String - val apiKey: String? - val locationMode: WeatherLocationMode - val manualLocation: WeatherLocation? - val refreshIntervalMinutes: Int -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/WeatherRepository.kt b/app/src/main/java/com/sameerasw/essentials/weather/WeatherRepository.kt index 90488fb26e..75e0fafe56 100644 --- a/app/src/main/java/com/sameerasw/essentials/weather/WeatherRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/weather/WeatherRepository.kt @@ -3,14 +3,10 @@ package com.sameerasw.essentials.weather import android.content.Context import androidx.annotation.Keep import com.google.gson.Gson -import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.weather.location.DeviceLocationSource import com.sameerasw.essentials.weather.model.WeatherError -import com.sameerasw.essentials.weather.model.WeatherLocation import com.sameerasw.essentials.weather.model.WeatherSnapshot import com.sameerasw.essentials.weather.model.WeatherState -import com.sameerasw.essentials.weather.provider.WeatherProviderException -import com.sameerasw.essentials.weather.provider.WeatherProviders +import com.sameerasw.essentials.weather.overcast.OvercastWeather import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,8 +18,10 @@ import kotlinx.coroutines.withContext import java.io.File object WeatherRepository { + const val REFRESH_INTERVAL_MINUTES = 30 + private const val CACHE_FILE = "weather_cache.json" - private const val MIN_INTERVAL_MS = 5 * 60_000L + private const val MIN_INTERVAL_MS = 2 * 60_000L private val gson = Gson() private val mutex = Mutex() @@ -36,12 +34,9 @@ object WeatherRepository { @Keep private data class WeatherCache( val snapshot: WeatherSnapshot? = null, - val lastDeviceLocation: WeatherLocation? = null, val notifiedAlertIds: Set? = null, ) - fun config(context: Context): WeatherConfig = SettingsWeatherConfig(SettingsRepository(context.applicationContext)) - suspend fun ensureLoaded(context: Context) { if (loaded) return mutex.withLock { @@ -59,13 +54,12 @@ object WeatherRepository { } } - fun isStale(context: Context): Boolean { + fun isStale(): Boolean { val snapshot = _state.value.snapshot ?: return true - val interval = config(context).refreshIntervalMinutes * 60_000L - return System.currentTimeMillis() - snapshot.updatedAt >= interval + return System.currentTimeMillis() - snapshot.updatedAt >= REFRESH_INTERVAL_MINUTES * 60_000L } - // Returns true when fresh data was stored. + // Returns true when newer data was stored. A forced refresh asks Overcast to fetch first. suspend fun refresh(context: Context, force: Boolean = false): Boolean { val app = context.applicationContext ensureLoaded(app) @@ -74,50 +68,26 @@ object WeatherRepository { if (!force && current != null && System.currentTimeMillis() - current.updatedAt < MIN_INTERVAL_MS) { return@withLock false } - val config = config(app) - val provider = WeatherProviders.byId(config.providerId) - if (provider.requiresApiKey && config.apiKey.isNullOrBlank()) { - _state.update { it.copy(error = WeatherError.MissingApiKey, loading = false) } + if (!OvercastWeather.isInstalled(app)) { + _state.update { it.copy(error = WeatherError.OvercastMissing, loading = false) } return@withLock false } - _state.update { it.copy(loading = true) } - val location = resolveLocation(app, config) - if (location == null) { - val error = if (config.locationMode == WeatherLocationMode.DEVICE && !DeviceLocationSource.hasPermission(app)) { - WeatherError.LocationPermission - } else { - WeatherError.NoLocation - } - _state.update { it.copy(error = error, loading = false) } + if (!OvercastWeather.hasPermission(app)) { + _state.update { it.copy(error = WeatherError.OvercastPermission, loading = false) } return@withLock false } - try { - val snapshot = provider.fetch(location, config.apiKey) - cache = cache.copy(snapshot = snapshot) - persist(app) - _state.value = WeatherState(snapshot = snapshot) - true - } catch (e: WeatherProviderException) { - val error = when (e.reason) { - WeatherProviderException.Reason.INVALID_KEY -> WeatherError.InvalidApiKey - WeatherProviderException.Reason.NETWORK -> WeatherError.Network - WeatherProviderException.Reason.BAD_RESPONSE -> WeatherError.Unknown(e.message) - } - _state.update { it.copy(error = error, loading = false) } - false - } catch (e: Exception) { - _state.update { it.copy(error = WeatherError.Unknown(e.message), loading = false) } - false + _state.update { it.copy(loading = true) } + if (force) OvercastWeather.requestRefresh(app) + val snapshot = OvercastWeather.fetch(app) + if (snapshot == null) { + _state.update { it.copy(error = WeatherError.NoLocation, loading = false) } + return@withLock false } - } - } - - suspend fun clear(context: Context) { - ensureLoaded(context) - mutex.withLock { - cache = cache.copy(snapshot = null) - persist(context.applicationContext) - _state.value = WeatherState() + val changed = snapshot.updatedAt != current?.updatedAt + cache = cache.copy(snapshot = snapshot) + persist(app) + _state.value = WeatherState(snapshot = snapshot) + changed } } @@ -136,20 +106,6 @@ object WeatherRepository { } } - private suspend fun resolveLocation(context: Context, config: WeatherConfig): WeatherLocation? = - when (config.locationMode) { - WeatherLocationMode.MANUAL -> config.manualLocation - WeatherLocationMode.DEVICE -> { - val fresh = DeviceLocationSource.current(context) - if (fresh != null) { - cache = cache.copy(lastDeviceLocation = fresh) - fresh - } else { - cache.lastDeviceLocation.takeIf { DeviceLocationSource.hasPermission(context) } - } - } - } - private suspend fun persist(context: Context) = withContext(Dispatchers.IO) { try { File(context.cacheDir, CACHE_FILE).writeText(gson.toJson(cache)) diff --git a/app/src/main/java/com/sameerasw/essentials/weather/WeatherSources.kt b/app/src/main/java/com/sameerasw/essentials/weather/WeatherSources.kt new file mode 100644 index 0000000000..9c6a599f51 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/weather/WeatherSources.kt @@ -0,0 +1,16 @@ +package com.sameerasw.essentials.weather + +object WeatherSources { + private val names = mapOf( + "openmeteo" to "Open-Meteo", + "metno" to "MET Norway (Yr)", + "nws" to "US National Weather Service", + "pirateweather" to "Pirate Weather", + "tomorrowio" to "Tomorrow.io", + "visualcrossing" to "Visual Crossing", + "openweathermap" to "OpenWeatherMap", + "weatherapi" to "WeatherAPI.com", + ) + + fun displayName(id: String): String = names[id] ?: id +} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/effects/WeatherSimulation.kt b/app/src/main/java/com/sameerasw/essentials/weather/effects/WeatherSimulation.kt deleted file mode 100644 index 0ceeafa4da..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/effects/WeatherSimulation.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.sameerasw.essentials.weather.effects - -import com.sameerasw.essentials.weather.effects.WeatherEffectLayer.Clouds -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import com.sameerasw.essentials.weather.effects.WeatherEffectLayer.Fog -import com.sameerasw.essentials.weather.effects.WeatherEffectLayer.Hail -import com.sameerasw.essentials.weather.effects.WeatherEffectLayer.Lightning -import com.sameerasw.essentials.weather.effects.WeatherEffectLayer.Rain -import com.sameerasw.essentials.weather.effects.WeatherEffectLayer.Snow -import com.sameerasw.essentials.weather.effects.WeatherEffectLayer.Stars -import com.sameerasw.essentials.weather.effects.WeatherEffectLayer.SunGlow - -class WeatherSimulationPreset(val id: String, val label: String, val spec: WeatherEffectSpec) - -object WeatherSimulation { - const val OFF = "off" - - val presets = listOf( - WeatherSimulationPreset(OFF, "Off", WeatherEffectSpec.None), - preset("sunny", "Sunny", SunGlow(1f)), - preset("clear_night", "Clear night", Stars(1f)), - preset("partly_cloudy", "Partly cloudy", SunGlow(0.5f), Clouds(0.4f)), - preset("partly_cloudy_night", "Partly cloudy night", Stars(0.4f), Clouds(0.4f)), - preset("cloudy", "Cloudy", Clouds(0.85f)), - preset("overcast", "Overcast", Clouds(1f)), - preset("light_fog", "Light fog", Fog(0.5f)), - preset("fog", "Dense fog", Fog(1f)), - preset("drizzle", "Drizzle", Clouds(0.5f), Rain(0.3f, 0f)), - preset("rain", "Rain", Clouds(0.6f), Rain(0.6f, 0.1f)), - preset("heavy_rain", "Heavy rain", Clouds(0.8f), Rain(1f, 0.2f)), - preset("windy_rain", "Windy rain", Clouds(0.7f), Rain(0.8f, 0.6f)), - preset("sleet", "Sleet", Rain(0.45f, 0.1f), Snow(0.4f)), - preset("light_snow", "Light snow", Clouds(0.4f), Snow(0.35f)), - preset("snow", "Snow", Clouds(0.4f), Snow(0.75f)), - preset("blizzard", "Blizzard", Clouds(0.9f), Snow(1f), Fog(0.5f)), - preset("hail", "Hail", Clouds(0.6f), Hail(0.8f)), - preset("thunderstorm", "Thunderstorm", Clouds(0.9f), Rain(0.8f, 0.2f), Lightning(1f)), - preset("dry_lightning", "Dry lightning", Clouds(0.7f), Lightning(1f)), - preset("everything", "Everything", Clouds(1f), Rain(1f, 0.4f), Snow(1f), Hail(1f), Lightning(1f)), - ) - - fun apply(snapshot: WeatherSnapshot, preset: WeatherSimulationPreset): WeatherSnapshot = - snapshot.copy( - condition = conditionOf(preset.spec), - conditionText = preset.label, - isDay = preset.spec.layers.none { it is Stars }, - ) - - fun withTimeOfDay(snapshot: WeatherSnapshot, override: String?): WeatherSnapshot = - if (override == null) snapshot else snapshot.copy(isDay = override != "night") - - fun timeFor(snapshot: WeatherSnapshot?, override: String?, clock: Long): Long { - val rise = snapshot?.extras?.sunriseMillis - val set = snapshot?.extras?.sunsetMillis - if (override == null || rise == null || set == null) return clock - val day = 24 * 60 * 60_000L - val shift = Math.floorDiv(clock - rise, day) * day - val r = rise + shift - val s = set + shift - return when (override) { - "dawn" -> r - "day" -> (r + s) / 2 - "dusk" -> s - else -> (s + r + day) / 2 - } - } - - private fun conditionOf(spec: WeatherEffectSpec): WeatherCondition { - val layers = spec.layers - val rain = layers.filterIsInstance().firstOrNull() - return when { - layers.any { it is Lightning } -> WeatherCondition.THUNDERSTORM - layers.any { it is Hail } -> WeatherCondition.HAIL - layers.any { it is Snow } && rain != null -> WeatherCondition.SLEET - layers.any { it is Snow } -> WeatherCondition.SNOW - rain != null -> if (rain.intensity >= 0.9f) WeatherCondition.HEAVY_RAIN else if (rain.intensity > 0.4f) WeatherCondition.RAIN else WeatherCondition.DRIZZLE - layers.any { it is Fog } -> WeatherCondition.FOG - layers.any { it is Clouds && it.intensity > 0.7f } -> WeatherCondition.CLOUDY - layers.any { it is Clouds } -> WeatherCondition.PARTLY_CLOUDY - else -> WeatherCondition.CLEAR - } - } - - fun find(id: String?): WeatherSimulationPreset? = presets.firstOrNull { it.id == id && it.id != OFF } - - private fun preset(id: String, label: String, vararg layers: WeatherEffectLayer) = - WeatherSimulationPreset(id, label, WeatherEffectSpec(layers.toList())) -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/location/DeviceLocationSource.kt b/app/src/main/java/com/sameerasw/essentials/weather/location/DeviceLocationSource.kt deleted file mode 100644 index 27702a4665..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/location/DeviceLocationSource.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.sameerasw.essentials.weather.location - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Context -import android.content.pm.PackageManager -import android.location.Location -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.Priority -import com.google.android.gms.tasks.CancellationTokenSource -import com.google.android.gms.tasks.Task -import com.sameerasw.essentials.weather.model.WeatherLocation -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.coroutines.resume - -object DeviceLocationSource { - private const val TIMEOUT_MS = 10_000L - - fun hasPermission(context: Context): Boolean = - context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED || - context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED - - // Coarse, one-shot fix. Falls back to the last known location, which is what background refreshes usually get. - @SuppressLint("MissingPermission") - suspend fun current(context: Context): WeatherLocation? { - if (!hasPermission(context)) return null - val client = LocationServices.getFusedLocationProviderClient(context) - val tokenSource = CancellationTokenSource() - val fresh = withTimeoutOrNull(TIMEOUT_MS) { - try { - client.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, tokenSource.token).awaitOrNull() - } catch (_: SecurityException) { - null - } - } - tokenSource.cancel() - val location = fresh ?: try { - client.lastLocation.awaitOrNull() - } catch (_: SecurityException) { - null - } - return location?.let { WeatherLocation(it.latitude, it.longitude) } - } - - private suspend fun Task.awaitOrNull(): Location? = suspendCancellableCoroutine { cont -> - addOnSuccessListener { if (cont.isActive) cont.resume(it) } - addOnFailureListener { if (cont.isActive) cont.resume(null) } - addOnCanceledListener { if (cont.isActive) cont.resume(null) } - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/model/WeatherModels.kt b/app/src/main/java/com/sameerasw/essentials/weather/model/WeatherModels.kt index 3914a8ba48..c4517ef363 100644 --- a/app/src/main/java/com/sameerasw/essentials/weather/model/WeatherModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/weather/model/WeatherModels.kt @@ -1,7 +1,6 @@ package com.sameerasw.essentials.weather.model import androidx.annotation.Keep -import java.util.Locale @Keep enum class WeatherCondition { @@ -33,26 +32,6 @@ enum class AlertSeverity { enum class TemperatureUnit { CELSIUS, FAHRENHEIT } -@Keep -data class WeatherLocation( - val latitude: Double, - val longitude: Double, - val name: String? = null, -) { - // ~1km precision is plenty for weather and keeps the exact position off the wire. - val query: String get() = "%.2f,%.2f".format(Locale.US, latitude, longitude) -} - -data class CityResult( - val name: String, - val region: String, - val country: String, - val latitude: Double, - val longitude: Double, -) { - val label: String get() = listOf(name, region, country).filter { it.isNotBlank() }.distinct().joinToString(", ") -} - @Keep data class HourlyForecast( val timeMillis: Long, @@ -124,11 +103,9 @@ data class WeatherSnapshot( } sealed interface WeatherError { - data object MissingApiKey : WeatherError - data object InvalidApiKey : WeatherError + data object OvercastMissing : WeatherError + data object OvercastPermission : WeatherError data object NoLocation : WeatherError - data object LocationPermission : WeatherError - data object Network : WeatherError data class Unknown(val message: String?) : WeatherError } diff --git a/app/src/main/java/com/sameerasw/essentials/weather/overcast/OvercastWeather.kt b/app/src/main/java/com/sameerasw/essentials/weather/overcast/OvercastWeather.kt new file mode 100644 index 0000000000..ca5099a6f9 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/weather/overcast/OvercastWeather.kt @@ -0,0 +1,72 @@ +package com.sameerasw.essentials.weather.overcast + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import androidx.core.net.toUri +import com.google.gson.Gson +import com.sameerasw.essentials.weather.model.WeatherSnapshot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object OvercastWeather { + const val PACKAGE = "com.sameerasw.overcast" + const val PERMISSION = "com.sameerasw.overcast.permission.READ_WEATHER" + const val INSTALL_URL = "https://github.com/sameerasw/Overcast/releases/latest" + + private const val AUTHORITY = "com.sameerasw.overcast.weather" + private const val METHOD_REFRESH = "refresh" + private const val COLUMN_JSON = "json" + private const val SUPPORTED_SCHEMA = 1 + + private val snapshotUri: Uri = "content://$AUTHORITY/snapshot".toUri() + private val gson = Gson() + + fun isInstalled(context: Context): Boolean = + try { + context.packageManager.getPackageInfo(PACKAGE, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + + fun hasPermission(context: Context): Boolean = + context.checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED + + fun isAvailable(context: Context): Boolean = isInstalled(context) && hasPermission(context) + + fun openApp(context: Context) { + val launch = context.packageManager.getLaunchIntentForPackage(PACKAGE) ?: return + context.startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun openInstallPage(context: Context) { + context.startActivity(Intent(Intent.ACTION_VIEW, INSTALL_URL.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + // Last snapshot Overcast has. Null when it has nothing yet or the provider can't be reached. + suspend fun fetch(context: Context): WeatherSnapshot? = withContext(Dispatchers.IO) { + if (!isAvailable(context)) return@withContext null + try { + context.contentResolver.query(snapshotUri, null, null, null, null)?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val schema = cursor.getColumnIndex("schema").takeIf { it >= 0 }?.let { cursor.getInt(it) } ?: SUPPORTED_SCHEMA + if (schema > SUPPORTED_SCHEMA) return@use null + gson.fromJson(cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_JSON)), WeatherSnapshot::class.java) + } + } catch (_: Exception) { + null + } + } + + // Asks Overcast to fetch fresh data first. Returns false when it couldn't. + suspend fun requestRefresh(context: Context): Boolean = withContext(Dispatchers.IO) { + if (!isAvailable(context)) return@withContext false + try { + context.contentResolver.call(snapshotUri, METHOD_REFRESH, null, null)?.getBoolean("success", false) == true + } catch (_: Exception) { + false + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/MetNorwayProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/MetNorwayProvider.kt deleted file mode 100644 index f22d6549c6..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/MetNorwayProvider.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.WeatherExtras -import com.sameerasw.essentials.weather.model.HourlyForecast -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import org.json.JSONException -import org.json.JSONObject -import java.time.Instant -import java.util.Locale -import kotlin.math.exp - -class MetNorwayProvider : WeatherProvider { - override val id = "metno" - override val displayName = "MET Norway (Yr)" - override val requiresApiKey = false - override val signupUrl: String? = null - - override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { - val url = "https://api.met.no/weatherapi/locationforecast/2.0/compact?" + - String.format(Locale.US, "lat=%.4f&lon=%.4f", location.latitude, location.longitude) - val json = JSONObject(ProviderHttp.get(url)) - val place = location.name?.let { it to "" } ?: ProviderHttp.reverseGeocode(location.latitude, location.longitude) - return try { - parse(json, place) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - } - - override suspend fun searchCities(query: String, apiKey: String?): List = ProviderHttp.searchCities(query) - - private fun parse(json: JSONObject, place: Pair?): WeatherSnapshot { - val now = System.currentTimeMillis() - val series = json.getJSONObject("properties").getJSONArray("timeseries") - val first = series.getJSONObject(0) - val details = first.getJSONObject("data").getJSONObject("instant").getJSONObject("details") - val temp = details.getDouble("air_temperature") - val humidity = details.optDouble("relative_humidity", 50.0) - val windMs = details.optDouble("wind_speed", 0.0) - - val hourly = mutableListOf() - val next24 = mutableListOf() - var rainChance = 0 - for (i in 0 until series.length()) { - val entry = series.getJSONObject(i) - val time = Instant.parse(entry.getString("time")).toEpochMilli() - if (time > now + 24 * HOUR_MS) break - val data = entry.getJSONObject("data") - val t = data.getJSONObject("instant").getJSONObject("details").optDouble("air_temperature", Double.NaN) - if (!t.isNaN()) next24 += t - val next = data.optJSONObject("next_1_hours") ?: continue - if (time + HOUR_MS <= now) continue - val symbol = next.optJSONObject("summary")?.optString("symbol_code").orEmpty() - val precip = next.optJSONObject("details")?.optDouble("precipitation_amount", 0.0) ?: 0.0 - val chance = chanceFor(precip) - if (hourly.size < HOURLY_COUNT) { - hourly += HourlyForecast( - timeMillis = time, - tempC = t.takeUnless { it.isNaN() } ?: temp, - condition = conditionFor(symbol), - isDay = !symbol.endsWith("_night"), - chanceOfRain = chance, - ) - } - if (i < 12) rainChance = maxOf(rainChance, chance) - } - - val currentSymbol = first.getJSONObject("data").let { - (it.optJSONObject("next_1_hours") ?: it.optJSONObject("next_6_hours")) - ?.optJSONObject("summary")?.optString("symbol_code").orEmpty() - } - val condition = conditionFor(currentSymbol) - return WeatherSnapshot( - locationName = place?.first.orEmpty(), - region = place?.second.orEmpty(), - tempC = temp, - feelsLikeC = apparent(temp, humidity, windMs), - condition = condition, - conditionText = textFor(condition), - isDay = !currentSymbol.endsWith("_night"), - humidity = humidity.toInt(), - windKph = windMs * 3.6, - chanceOfRain = rainChance, - highC = next24.maxOrNull() ?: temp, - lowC = next24.minOrNull() ?: temp, - hourly = hourly, - alerts = emptyList(), - updatedAt = now, - providerId = id, - extras = WeatherExtras( - pressureHpa = details.optDouble("air_pressure_at_sea_level").takeUnless { it.isNaN() }, - dewPointC = details.optDouble("dew_point_temperature").takeUnless { it.isNaN() }, - cloudCover = details.optDouble("cloud_area_fraction").takeUnless { it.isNaN() }?.toInt(), - windGustKph = details.optDouble("wind_speed_of_gust").takeUnless { it.isNaN() }?.times(3.6), - windDirectionDeg = details.optDouble("wind_from_direction").takeUnless { it.isNaN() }, - uvIndex = details.optDouble("ultraviolet_index_clear_sky").takeUnless { it.isNaN() }, - ), - ) - } - - // MET only reports amounts, so the chance is derived from how much rain is expected that hour. - private fun chanceFor(mm: Double): Int = when { - mm >= 1.0 -> 90 - mm >= 0.3 -> 70 - mm > 0.0 -> 40 - else -> 0 - } - - // Australian Bureau of Meteorology apparent temperature. - private fun apparent(tempC: Double, humidity: Double, windMs: Double): Double { - val vapour = humidity / 100.0 * 6.105 * exp(17.27 * tempC / (237.7 + tempC)) - return tempC + 0.33 * vapour - 0.70 * windMs - 4.0 - } - - // https://api.met.no/weatherapi/weathericon/2.0/documentation - private fun conditionFor(symbol: String): WeatherCondition { - val s = symbol.substringBefore('_') - return when { - s.contains("thunder") -> WeatherCondition.THUNDERSTORM - s.contains("sleet") -> WeatherCondition.SLEET - s.contains("snow") -> WeatherCondition.SNOW - s.startsWith("heavyrain") -> WeatherCondition.HEAVY_RAIN - s.startsWith("lightrain") -> WeatherCondition.DRIZZLE - s.contains("rain") -> WeatherCondition.RAIN - s == "fog" -> WeatherCondition.FOG - s == "clearsky" || s == "fair" -> WeatherCondition.CLEAR - s == "partlycloudy" -> WeatherCondition.PARTLY_CLOUDY - s == "cloudy" -> WeatherCondition.CLOUDY - else -> WeatherCondition.UNKNOWN - } - } - - private fun textFor(condition: WeatherCondition): String = when (condition) { - WeatherCondition.CLEAR -> "Clear" - WeatherCondition.PARTLY_CLOUDY -> "Partly cloudy" - WeatherCondition.CLOUDY -> "Cloudy" - WeatherCondition.FOG -> "Fog" - WeatherCondition.DRIZZLE -> "Light rain" - WeatherCondition.RAIN -> "Rain" - WeatherCondition.HEAVY_RAIN -> "Heavy rain" - WeatherCondition.SLEET -> "Sleet" - WeatherCondition.SNOW -> "Snow" - WeatherCondition.HAIL -> "Hail" - WeatherCondition.THUNDERSTORM -> "Thunderstorm" - WeatherCondition.UNKNOWN -> "Unknown" - } - - private companion object { - const val HOUR_MS = 60 * 60 * 1000L - const val HOURLY_COUNT = 24 - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/NwsProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/NwsProvider.kt deleted file mode 100644 index 14119cca2e..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/NwsProvider.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.AlertSeverity -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.HourlyForecast -import com.sameerasw.essentials.weather.model.WeatherAlert -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import org.json.JSONException -import org.json.JSONObject -import java.time.OffsetDateTime -import java.time.format.DateTimeParseException -import java.util.Locale -import kotlin.math.exp - -class NwsProvider : WeatherProvider { - override val id = "nws" - override val displayName = "US National Weather Service" - override val requiresApiKey = false - override val signupUrl: String? = null - - private val headers = mapOf("Accept" to "application/geo+json") - - override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { - val point = String.format(Locale.US, "%.4f,%.4f", location.latitude, location.longitude) - val points = try { - JSONObject(ProviderHttp.get("https://api.weather.gov/points/$point", headers)) - } catch (e: WeatherProviderException) { - if (e.message?.contains("404") == true) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, "NWS only covers the United States") - } - throw e - } - return try { - val props = points.getJSONObject("properties") - val hourlyUrl = props.getString("forecastHourly") - val hourly = JSONObject(ProviderHttp.get("$hourlyUrl?units=si", headers)) - val alerts = try { - JSONObject(ProviderHttp.get("https://api.weather.gov/alerts/active?point=$point", headers)) - } catch (_: Exception) { - null - } - val relative = props.optJSONObject("relativeLocation")?.optJSONObject("properties") - val name = location.name ?: relative?.optString("city").orEmpty() - val region = relative?.optString("state").orEmpty() - parse(hourly, alerts, name to region) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - } - - override suspend fun searchCities(query: String, apiKey: String?): List = - ProviderHttp.searchCities(query).filter { it.country.equals("United States", ignoreCase = true) } - - private fun parse(hourlyJson: JSONObject, alertsJson: JSONObject?, place: Pair): WeatherSnapshot { - val now = System.currentTimeMillis() - val periods = hourlyJson.getJSONObject("properties").getJSONArray("periods") - val first = periods.getJSONObject(0) - val temp = first.getDouble("temperature") - val humidity = first.optJSONObject("relativeHumidity")?.optDouble("value", 50.0) ?: 50.0 - val windKph = parseWind(first.optString("windSpeed")) - - val hourly = mutableListOf() - val next24 = mutableListOf() - var rainChance = 0 - for (i in 0 until periods.length()) { - val p = periods.getJSONObject(i) - val time = parseTime(p.optString("startTime")) ?: continue - val t = p.optDouble("temperature", Double.NaN) - val pop = p.optJSONObject("probabilityOfPrecipitation")?.optDouble("value", 0.0)?.toInt() ?: 0 - if (i < 24 && !t.isNaN()) next24 += t - if (i < 12) rainChance = maxOf(rainChance, pop) - if (time + HOUR_MS > now && hourly.size < HOURLY_COUNT && !t.isNaN()) { - hourly += HourlyForecast( - timeMillis = time, - tempC = t, - condition = conditionFor(p.optString("shortForecast")), - isDay = p.optBoolean("isDaytime", true), - chanceOfRain = pop, - ) - } - } - - val features = alertsJson?.optJSONArray("features") - val alerts = buildList { - if (features != null) { - for (i in 0 until features.length()) { - val a = features.getJSONObject(i).optJSONObject("properties") ?: continue - val event = a.optString("event") - add( - WeatherAlert( - id = "$id:${a.optString("id")}".hashCode().toString(), - event = event, - headline = a.optString("headline").ifBlank { event }, - severity = severityFor(a.optString("severity")), - description = a.optString("description").trim(), - effectiveMillis = parseTime(a.optString("effective")), - expiresMillis = parseTime(a.optString("expires")), - ), - ) - } - } - }.distinctBy { it.id } - - val shortForecast = first.optString("shortForecast") - return WeatherSnapshot( - locationName = place.first, - region = place.second, - tempC = temp, - feelsLikeC = apparent(temp, humidity, windKph / 3.6), - condition = conditionFor(shortForecast), - conditionText = shortForecast, - isDay = first.optBoolean("isDaytime", true), - humidity = humidity.toInt(), - windKph = windKph, - chanceOfRain = rainChance, - highC = next24.maxOrNull() ?: temp, - lowC = next24.minOrNull() ?: temp, - hourly = hourly, - alerts = alerts, - updatedAt = now, - providerId = id, - ) - } - - // "10 km/h" or "5 to 10 km/h": use the upper bound. - private fun parseWind(value: String): Double = - Regex("[0-9]+(?:\\.[0-9]+)?").findAll(value).mapNotNull { it.value.toDoubleOrNull() }.maxOrNull() ?: 0.0 - - private fun apparent(tempC: Double, humidity: Double, windMs: Double): Double { - val vapour = humidity / 100.0 * 6.105 * exp(17.27 * tempC / (237.7 + tempC)) - return tempC + 0.33 * vapour - 0.70 * windMs - 4.0 - } - - private fun parseTime(value: String?): Long? { - if (value.isNullOrBlank()) return null - return try { - OffsetDateTime.parse(value).toInstant().toEpochMilli() - } catch (_: DateTimeParseException) { - null - } - } - - private fun severityFor(value: String): AlertSeverity = when (value.trim().lowercase()) { - "minor" -> AlertSeverity.MINOR - "moderate" -> AlertSeverity.MODERATE - "severe" -> AlertSeverity.SEVERE - "extreme" -> AlertSeverity.EXTREME - else -> AlertSeverity.UNKNOWN - } - - private fun conditionFor(text: String): WeatherCondition { - val s = text.lowercase() - return when { - "thunder" in s -> WeatherCondition.THUNDERSTORM - "sleet" in s || "freezing" in s || "wintry" in s || "ice" in s -> WeatherCondition.SLEET - "snow" in s || "flurries" in s || "blizzard" in s -> WeatherCondition.SNOW - "heavy rain" in s -> WeatherCondition.HEAVY_RAIN - "drizzle" in s -> WeatherCondition.DRIZZLE - "rain" in s || "showers" in s -> WeatherCondition.RAIN - "fog" in s || "haze" in s || "smoke" in s || "mist" in s -> WeatherCondition.FOG - "partly" in s -> WeatherCondition.PARTLY_CLOUDY - "mostly cloudy" in s || "cloudy" in s || "overcast" in s -> WeatherCondition.CLOUDY - "sunny" in s || "clear" in s || "fair" in s -> WeatherCondition.CLEAR - else -> WeatherCondition.UNKNOWN - } - } - - private companion object { - const val HOUR_MS = 60 * 60 * 1000L - const val HOURLY_COUNT = 24 - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/OpenMeteoProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/OpenMeteoProvider.kt deleted file mode 100644 index 840c92146c..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/OpenMeteoProvider.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.EssentialsApp -import com.sameerasw.essentials.data.repository.SettingsRepository -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.DailyForecast -import com.sameerasw.essentials.weather.model.HourlyForecast -import com.sameerasw.essentials.weather.model.WeatherExtras -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import org.json.JSONException -import org.json.JSONObject -import java.util.Locale - -object OpenMeteoModels { - class Model(val id: String?, val label: String) - - val all = listOf( - Model(null, "Auto (best match)"), - Model("ecmwf_ifs025", "ECMWF IFS"), - Model("gfs_seamless", "NOAA GFS / HRRR"), - Model("icon_seamless", "DWD ICON"), - Model("meteofrance_seamless", "Météo-France"), - Model("ukmo_seamless", "UK Met Office"), - Model("gem_seamless", "Environment Canada GEM"), - Model("jma_seamless", "JMA"), - ) - - fun label(id: String?): String = all.firstOrNull { it.id == id }?.label ?: all.first().label - - fun selected(): String? = - SettingsRepository(EssentialsApp.context).getWeatherOpenMeteoModel()?.takeIf { id -> all.any { it.id == id } } -} - -class OpenMeteoProvider : WeatherProvider { - override val id = "openmeteo" - override val displayName = "Open-Meteo" - override val requiresApiKey = false - override val signupUrl: String? = null - - override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { - val url = "https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" + - "¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m," + - "surface_pressure,wind_gusts_10m,wind_direction_10m,cloud_cover,precipitation,dew_point_2m" + - "&hourly=temperature_2m,precipitation_probability,weather_code,is_day,visibility" + - "&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code,uv_index_max,sunrise,sunset" + - "&forecast_days=7&timezone=auto&wind_speed_unit=kmh&timeformat=unixtime" + - (OpenMeteoModels.selected()?.let { "&models=$it" } ?: "") - val json = JSONObject(ProviderHttp.get(url)) - val place = location.name?.let { it to "" } ?: ProviderHttp.reverseGeocode(location.latitude, location.longitude) - return try { - parse(json, place) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - } - - override suspend fun searchCities(query: String, apiKey: String?): List = ProviderHttp.searchCities(query) - - private fun parse(json: JSONObject, place: Pair?): WeatherSnapshot { - val now = System.currentTimeMillis() - val current = json.getJSONObject("current") - val hourlyJson = json.getJSONObject("hourly") - val daily = json.getJSONObject("daily") - - val times = hourlyJson.getJSONArray("time") - val temps = hourlyJson.getJSONArray("temperature_2m") - val rain = hourlyJson.optJSONArray("precipitation_probability") - val codes = hourlyJson.getJSONArray("weather_code") - val day = hourlyJson.optJSONArray("is_day") - val hourly = buildList { - for (i in 0 until times.length()) { - val time = times.getLong(i) * 1000L - if (time + HOUR_MS <= now || temps.isNull(i)) continue - add( - HourlyForecast( - timeMillis = time, - tempC = temps.getDouble(i), - condition = conditionFor(codes.optInt(i)), - isDay = day?.optInt(i, 1) != 0, - chanceOfRain = rain?.optInt(i) ?: 0, - ), - ) - } - }.take(HOURLY_COUNT) - - val code = current.optInt("weather_code") - val temp = current.getDouble("temperature_2m") - val visibilityArray = hourlyJson.optJSONArray("visibility") - val currentHourIndex = (0 until times.length()).firstOrNull { times.getLong(it) * 1000L + HOUR_MS > now } ?: 0 - val dayTimes = daily.getJSONArray("time") - val dailyCodes = daily.optJSONArray("weather_code") - val dailyRain = daily.optJSONArray("precipitation_probability_max") - val dailyHigh = daily.getJSONArray("temperature_2m_max") - val dailyLow = daily.getJSONArray("temperature_2m_min") - val dailyList = (0 until dayTimes.length()).filter { !dailyHigh.isNull(it) && !dailyLow.isNull(it) }.map { i -> - DailyForecast( - dayMillis = dayTimes.getLong(i) * 1000L, - highC = dailyHigh.getDouble(i), - lowC = dailyLow.getDouble(i), - condition = conditionFor(dailyCodes?.optInt(i) ?: -1), - chanceOfRain = dailyRain?.optInt(i) ?: 0, - ) - } - fun optNumber(name: String): Double? = if (current.has(name) && !current.isNull(name)) current.getDouble(name) else null - return WeatherSnapshot( - locationName = place?.first.orEmpty(), - region = place?.second.orEmpty(), - tempC = temp, - feelsLikeC = current.optDouble("apparent_temperature", temp), - condition = conditionFor(code), - conditionText = textFor(code), - isDay = current.optInt("is_day", 1) == 1, - humidity = current.optInt("relative_humidity_2m"), - windKph = current.optDouble("wind_speed_10m"), - chanceOfRain = daily.optJSONArray("precipitation_probability_max")?.optInt(0) ?: 0, - highC = daily.getJSONArray("temperature_2m_max").getDouble(0), - lowC = daily.getJSONArray("temperature_2m_min").getDouble(0), - hourly = hourly, - alerts = emptyList(), - updatedAt = now, - providerId = id, - extras = WeatherExtras( - pressureHpa = optNumber("surface_pressure"), - visibilityKm = visibilityArray?.takeIf { !it.isNull(currentHourIndex) }?.getDouble(currentHourIndex)?.div(1000.0), - dewPointC = optNumber("dew_point_2m"), - cloudCover = optNumber("cloud_cover")?.toInt(), - uvIndex = daily.optJSONArray("uv_index_max")?.takeIf { !it.isNull(0) }?.getDouble(0), - windGustKph = optNumber("wind_gusts_10m"), - windDirectionDeg = optNumber("wind_direction_10m"), - precipitationMm = optNumber("precipitation"), - sunriseMillis = daily.optJSONArray("sunrise")?.takeIf { !it.isNull(0) }?.getLong(0)?.times(1000L), - sunsetMillis = daily.optJSONArray("sunset")?.takeIf { !it.isNull(0) }?.getLong(0)?.times(1000L), - ), - daily = dailyList, - ) - } - - // https://open-meteo.com/en/docs (WMO weather interpretation codes) - private fun conditionFor(code: Int): WeatherCondition = when (code) { - 0, 1 -> WeatherCondition.CLEAR - 2 -> WeatherCondition.PARTLY_CLOUDY - 3 -> WeatherCondition.CLOUDY - 45, 48 -> WeatherCondition.FOG - 51, 53, 55 -> WeatherCondition.DRIZZLE - 56, 57, 66, 67 -> WeatherCondition.SLEET - 61, 63, 80, 81 -> WeatherCondition.RAIN - 65, 82 -> WeatherCondition.HEAVY_RAIN - 71, 73, 75, 77, 85, 86 -> WeatherCondition.SNOW - 95, 96, 99 -> WeatherCondition.THUNDERSTORM - else -> WeatherCondition.UNKNOWN - } - - private fun textFor(code: Int): String = when (code) { - 0 -> "Clear sky" - 1 -> "Mainly clear" - 2 -> "Partly cloudy" - 3 -> "Overcast" - 45, 48 -> "Fog" - 51, 53, 55 -> "Drizzle" - 56, 57 -> "Freezing drizzle" - 61 -> "Light rain" - 63 -> "Rain" - 65 -> "Heavy rain" - 66, 67 -> "Freezing rain" - 71 -> "Light snow" - 73 -> "Snow" - 75, 86 -> "Heavy snow" - 77 -> "Snow grains" - 80, 81 -> "Rain showers" - 82 -> "Violent rain showers" - 85 -> "Snow showers" - 95 -> "Thunderstorm" - 96, 99 -> "Thunderstorm with hail" - else -> String.format(Locale.ROOT, "Weather %d", code) - } - - private companion object { - const val HOUR_MS = 60 * 60 * 1000L - const val HOURLY_COUNT = 24 - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/OpenWeatherMapProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/OpenWeatherMapProvider.kt deleted file mode 100644 index 0313f0069d..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/OpenWeatherMapProvider.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.WeatherExtras -import com.sameerasw.essentials.weather.model.HourlyForecast -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject -import java.util.Locale - -class OpenWeatherMapProvider : WeatherProvider { - override val id = "openweathermap" - override val displayName = "OpenWeatherMap" - override val requiresApiKey = true - override val signupUrl = "https://home.openweathermap.org/users/sign_up" - - override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { - val key = apiKey?.trim().orEmpty() - if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - val common = "lat=${location.latitude}&lon=${location.longitude}&units=metric&appid=${ProviderHttp.encode(key)}" - val current = JSONObject(ProviderHttp.get("$BASE_URL/data/2.5/weather?$common")) - val forecast = JSONObject(ProviderHttp.get("$BASE_URL/data/2.5/forecast?$common&cnt=10")) - return try { - parse(current, forecast, location) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - } - - override suspend fun searchCities(query: String, apiKey: String?): List { - val key = apiKey?.trim().orEmpty() - if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - if (query.isBlank()) return emptyList() - val array = try { - JSONArray(ProviderHttp.get("$BASE_URL/geo/1.0/direct?q=${ProviderHttp.encode(query.trim())}&limit=8&appid=${ProviderHttp.encode(key)}")) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - return (0 until array.length()).map { i -> - val o = array.getJSONObject(i) - CityResult( - name = o.optString("name"), - region = o.optString("state"), - country = o.optString("country"), - latitude = o.optDouble("lat"), - longitude = o.optDouble("lon"), - ) - } - } - - private fun parse(current: JSONObject, forecast: JSONObject, requested: WeatherLocation): WeatherSnapshot { - val now = System.currentTimeMillis() - val main = current.getJSONObject("main") - val weather = current.getJSONArray("weather").getJSONObject(0) - val sys = current.optJSONObject("sys") - val dt = current.optLong("dt", now / 1000L) - val sunrise = sys?.optLong("sunrise", 0L) ?: 0L - val sunset = sys?.optLong("sunset", 0L) ?: 0L - val isDay = if (sunrise > 0L && sunset > 0L) dt in sunrise until sunset else true - val temp = main.getDouble("temp") - - val list = forecast.getJSONArray("list") - val hourly = mutableListOf() - val temps = mutableListOf(temp) - var rainChance = 0 - for (i in 0 until list.length()) { - val entry = list.getJSONObject(i) - val time = entry.getLong("dt") * 1000L - val entryMain = entry.getJSONObject("main") - temps += entryMain.optDouble("temp_max", entryMain.getDouble("temp")) - temps += entryMain.optDouble("temp_min", entryMain.getDouble("temp")) - val pop = (entry.optDouble("pop", 0.0) * 100).toInt() - if (time + THREE_HOURS_MS > now && hourly.size < HOURLY_COUNT) { - hourly += HourlyForecast( - timeMillis = time, - tempC = entryMain.getDouble("temp"), - condition = conditionFor(entry.getJSONArray("weather").getJSONObject(0).optInt("id")), - isDay = entry.optJSONObject("sys")?.optString("pod") != "n", - chanceOfRain = pop, - ) - } - if (i < 4) rainChance = maxOf(rainChance, pop) - } - - val description = weather.optString("description") - return WeatherSnapshot( - locationName = requested.name ?: current.optString("name"), - region = sys?.optString("country").orEmpty(), - tempC = temp, - feelsLikeC = main.optDouble("feels_like", temp), - condition = conditionFor(weather.optInt("id")), - conditionText = description.replaceFirstChar { it.titlecase(Locale.getDefault()) }, - isDay = isDay, - humidity = main.optInt("humidity"), - windKph = current.optJSONObject("wind")?.optDouble("speed", 0.0)?.times(3.6) ?: 0.0, - chanceOfRain = rainChance, - highC = temps.max(), - lowC = temps.min(), - hourly = hourly, - alerts = emptyList(), - updatedAt = now, - providerId = id, - extras = WeatherExtras( - pressureHpa = main.optDouble("pressure").takeUnless { it.isNaN() }, - visibilityKm = current.optDouble("visibility").takeUnless { it.isNaN() }?.div(1000.0), - cloudCover = current.optJSONObject("clouds")?.optInt("all", -1)?.takeIf { it >= 0 }, - windGustKph = current.optJSONObject("wind")?.optDouble("gust")?.takeUnless { it.isNaN() }?.times(3.6), - windDirectionDeg = current.optJSONObject("wind")?.optDouble("deg")?.takeUnless { it.isNaN() }, - sunriseMillis = sunrise.takeIf { it > 0 }?.times(1000L), - sunsetMillis = sunset.takeIf { it > 0 }?.times(1000L), - ), - ) - } - - // https://openweathermap.org/weather-conditions - private fun conditionFor(code: Int): WeatherCondition = when (code) { - in 200..232 -> WeatherCondition.THUNDERSTORM - in 300..321 -> WeatherCondition.DRIZZLE - 500, 501, 520, 521 -> WeatherCondition.RAIN - 502, 503, 504, 522, 531 -> WeatherCondition.HEAVY_RAIN - 511, in 611..616 -> WeatherCondition.SLEET - in 600..602, 620, 621, 622 -> WeatherCondition.SNOW - 701, 711, 721, 741 -> WeatherCondition.FOG - 800 -> WeatherCondition.CLEAR - 801, 802 -> WeatherCondition.PARTLY_CLOUDY - 803, 804 -> WeatherCondition.CLOUDY - else -> WeatherCondition.UNKNOWN - } - - private companion object { - const val BASE_URL = "https://api.openweathermap.org" - const val THREE_HOURS_MS = 3 * 60 * 60 * 1000L - const val HOURLY_COUNT = 24 - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/PirateWeatherProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/PirateWeatherProvider.kt deleted file mode 100644 index 6ec92612af..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/PirateWeatherProvider.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.AlertSeverity -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.WeatherExtras -import com.sameerasw.essentials.weather.model.DailyForecast -import com.sameerasw.essentials.weather.model.HourlyForecast -import com.sameerasw.essentials.weather.model.WeatherAlert -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import org.json.JSONException -import org.json.JSONObject - -class PirateWeatherProvider : WeatherProvider { - override val id = "pirateweather" - override val displayName = "Pirate Weather" - override val requiresApiKey = true - override val signupUrl = "https://pirate-weather.apiable.io/" - - override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { - val key = apiKey?.trim().orEmpty() - if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - val url = "https://api.pirateweather.net/forecast/${ProviderHttp.encode(key)}/${location.latitude},${location.longitude}" + - "?units=si&exclude=minutely" - val json = JSONObject(ProviderHttp.get(url)) - val place = location.name?.let { it to "" } ?: ProviderHttp.reverseGeocode(location.latitude, location.longitude) - return try { - parse(json, place) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - } - - override suspend fun searchCities(query: String, apiKey: String?): List = ProviderHttp.searchCities(query) - - private fun parse(json: JSONObject, place: Pair?): WeatherSnapshot { - val now = System.currentTimeMillis() - val current = json.getJSONObject("currently") - val hourlyData = json.getJSONObject("hourly").getJSONArray("data") - val dailyArray = json.getJSONObject("daily").getJSONArray("data") - val dailyToday = dailyArray.getJSONObject(0) - fun num(o: JSONObject, name: String): Double? = if (o.has(name) && !o.isNull(name)) o.getDouble(name) else null - - val hourly = buildList { - for (i in 0 until hourlyData.length()) { - val h = hourlyData.getJSONObject(i) - val time = h.getLong("time") * 1000L - if (time + HOUR_MS <= now) continue - add( - HourlyForecast( - timeMillis = time, - tempC = h.getDouble("temperature"), - condition = conditionFor(h.optString("icon"), h.optDouble("precipIntensity", 0.0)), - isDay = !h.optString("icon").endsWith("-night"), - chanceOfRain = (h.optDouble("precipProbability", 0.0) * 100).toInt(), - ), - ) - } - }.take(HOURLY_COUNT) - - val alertArray = json.optJSONArray("alerts") - val alerts = buildList { - if (alertArray != null) { - for (i in 0 until alertArray.length()) { - val a = alertArray.getJSONObject(i) - val title = a.optString("title") - add( - WeatherAlert( - id = "$id:$title:${a.optLong("time")}".hashCode().toString(), - event = title, - headline = title, - severity = severityFor(a.optString("severity")), - description = a.optString("description").trim(), - effectiveMillis = a.optLong("time", 0L).takeIf { it > 0 }?.times(1000L), - expiresMillis = a.optLong("expires", 0L).takeIf { it > 0 }?.times(1000L), - ), - ) - } - } - }.distinctBy { it.id } - - val icon = current.optString("icon") - val temp = current.getDouble("temperature") - return WeatherSnapshot( - locationName = place?.first.orEmpty(), - region = place?.second.orEmpty(), - tempC = temp, - feelsLikeC = current.optDouble("apparentTemperature", temp), - condition = conditionFor(icon, current.optDouble("precipIntensity", 0.0)), - conditionText = current.optString("summary"), - isDay = !icon.endsWith("-night"), - humidity = (current.optDouble("humidity", 0.0) * 100).toInt(), - windKph = current.optDouble("windSpeed", 0.0) * 3.6, - chanceOfRain = (dailyToday.optDouble("precipProbability", 0.0) * 100).toInt(), - highC = dailyToday.optDouble("temperatureHigh", temp), - lowC = dailyToday.optDouble("temperatureLow", temp), - hourly = hourly, - alerts = alerts, - updatedAt = now, - providerId = id, - extras = WeatherExtras( - pressureHpa = num(current, "pressure"), - visibilityKm = num(current, "visibility"), - dewPointC = num(current, "dewPoint"), - cloudCover = num(current, "cloudCover")?.times(100)?.toInt(), - uvIndex = num(current, "uvIndex"), - windGustKph = num(current, "windGust")?.times(3.6), - windDirectionDeg = num(current, "windBearing"), - precipitationMm = num(current, "precipIntensity"), - sunriseMillis = num(dailyToday, "sunriseTime")?.toLong()?.times(1000L), - sunsetMillis = num(dailyToday, "sunsetTime")?.toLong()?.times(1000L), - ), - daily = (0 until dailyArray.length()).map { i -> - val d = dailyArray.getJSONObject(i) - DailyForecast( - dayMillis = d.getLong("time") * 1000L, - highC = d.optDouble("temperatureHigh", d.optDouble("temperatureMax", temp)), - lowC = d.optDouble("temperatureLow", d.optDouble("temperatureMin", temp)), - condition = conditionFor(d.optString("icon"), d.optDouble("precipIntensity", 0.0)), - chanceOfRain = (d.optDouble("precipProbability", 0.0) * 100).toInt(), - ) - }, - ) - } - - private fun severityFor(value: String): AlertSeverity = when (value.trim().lowercase()) { - "minor" -> AlertSeverity.MINOR - "moderate" -> AlertSeverity.MODERATE - "severe" -> AlertSeverity.SEVERE - "extreme" -> AlertSeverity.EXTREME - else -> AlertSeverity.UNKNOWN - } - - private fun conditionFor(icon: String, intensityMmH: Double): WeatherCondition = when { - icon.startsWith("clear") -> WeatherCondition.CLEAR - icon.startsWith("partly-cloudy") -> WeatherCondition.PARTLY_CLOUDY - icon == "cloudy" -> WeatherCondition.CLOUDY - icon == "fog" -> WeatherCondition.FOG - icon == "rain" -> when { - intensityMmH >= 4.0 -> WeatherCondition.HEAVY_RAIN - intensityMmH in 0.0..0.5 -> WeatherCondition.DRIZZLE - else -> WeatherCondition.RAIN - } - icon == "snow" -> WeatherCondition.SNOW - icon == "sleet" -> WeatherCondition.SLEET - icon == "hail" -> WeatherCondition.HAIL - icon == "thunderstorm" -> WeatherCondition.THUNDERSTORM - icon == "wind" -> WeatherCondition.CLOUDY - else -> WeatherCondition.UNKNOWN - } - - private companion object { - const val HOUR_MS = 60 * 60 * 1000L - const val HOURLY_COUNT = 24 - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/ProviderHttp.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/ProviderHttp.kt deleted file mode 100644 index 6a19dde704..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/ProviderHttp.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.CityResult -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONException -import org.json.JSONObject -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.net.URLEncoder - -internal object ProviderHttp { - const val USER_AGENT = "Essentials/1.0 github.com/sameerasw/essentials" - private const val TIMEOUT_MS = 15_000 - - fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") - - suspend fun get(url: String, headers: Map = emptyMap()): String = withContext(Dispatchers.IO) { - val connection = try { - URL(url).openConnection() as HttpURLConnection - } catch (e: IOException) { - throw WeatherProviderException(WeatherProviderException.Reason.NETWORK, e.message) - } - try { - connection.connectTimeout = TIMEOUT_MS - connection.readTimeout = TIMEOUT_MS - connection.setRequestProperty("User-Agent", USER_AGENT) - headers.forEach { (k, v) -> connection.setRequestProperty(k, v) } - val code = connection.responseCode - if (code == HttpURLConnection.HTTP_UNAUTHORIZED || code == HttpURLConnection.HTTP_FORBIDDEN) { - throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - } - if (code !in 200..299) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, "HTTP $code") - } - connection.inputStream.bufferedReader().use { it.readText() } - } catch (e: IOException) { - throw WeatherProviderException(WeatherProviderException.Reason.NETWORK, e.message) - } finally { - connection.disconnect() - } - } - - suspend fun searchCities(query: String): List { - if (query.isBlank()) return emptyList() - val json = try { - JSONObject(get("https://geocoding-api.open-meteo.com/v1/search?name=${encode(query.trim())}&count=8&language=en&format=json")) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - val results = json.optJSONArray("results") ?: return emptyList() - return (0 until results.length()).map { i -> - val o = results.getJSONObject(i) - CityResult( - name = o.optString("name"), - region = o.optString("admin1"), - country = o.optString("country"), - latitude = o.optDouble("latitude"), - longitude = o.optDouble("longitude"), - ) - } - } - - // Best effort; providers without their own geocoder fall back to an empty name when this fails. - suspend fun reverseGeocode(latitude: Double, longitude: Double): Pair? = try { - val json = JSONObject( - get("https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=$latitude&longitude=$longitude&localityLanguage=en"), - ) - val name = json.optString("city").ifBlank { json.optString("locality") } - val region = json.optString("principalSubdivision").ifBlank { json.optString("countryName") } - if (name.isBlank()) null else name to region - } catch (_: Exception) { - null - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/TomorrowIoProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/TomorrowIoProvider.kt deleted file mode 100644 index e75028c927..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/TomorrowIoProvider.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.WeatherExtras -import com.sameerasw.essentials.weather.model.DailyForecast -import com.sameerasw.essentials.weather.model.HourlyForecast -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import org.json.JSONException -import org.json.JSONObject -import java.time.Instant - -class TomorrowIoProvider : WeatherProvider { - override val id = "tomorrowio" - override val displayName = "Tomorrow.io" - override val requiresApiKey = true - override val signupUrl = "https://app.tomorrow.io/signup" - - override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { - val key = apiKey?.trim().orEmpty() - if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - val url = "https://api.tomorrow.io/v4/weather/forecast?location=${location.latitude},${location.longitude}" + - "×teps=1h,1d&units=metric&apikey=${ProviderHttp.encode(key)}" - val json = JSONObject(ProviderHttp.get(url, mapOf("Accept" to "application/json"))) - val place = location.name?.let { it to "" } ?: ProviderHttp.reverseGeocode(location.latitude, location.longitude) - return try { - parse(json, place) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - } - - override suspend fun searchCities(query: String, apiKey: String?): List = ProviderHttp.searchCities(query) - - private fun parse(json: JSONObject, place: Pair?): WeatherSnapshot { - val now = System.currentTimeMillis() - val timelines = json.getJSONObject("timelines") - val hours = timelines.getJSONArray("hourly") - val days = timelines.getJSONArray("daily") - - val suns = (0 until days.length()).mapNotNull { i -> - val v = days.getJSONObject(i).getJSONObject("values") - val rise = v.optString("sunriseTime").takeIf { it.isNotBlank() }?.let { Instant.parse(it).toEpochMilli() } - val set = v.optString("sunsetTime").takeIf { it.isNotBlank() }?.let { Instant.parse(it).toEpochMilli() } - if (rise != null && set != null) rise to set else null - } - fun isDay(time: Long) = suns.isEmpty() || suns.any { (rise, set) -> time in rise until set } - - val currentValues = hours.getJSONObject(0).getJSONObject("values") - val forecast = buildList { - for (i in 0 until hours.length()) { - val h = hours.getJSONObject(i) - val time = Instant.parse(h.getString("time")).toEpochMilli() - if (time + HOUR_MS <= now) continue - val v = h.getJSONObject("values") - add( - HourlyForecast( - timeMillis = time, - tempC = v.getDouble("temperature"), - condition = conditionFor(v.optInt("weatherCode")), - isDay = isDay(time), - chanceOfRain = v.optDouble("precipitationProbability", 0.0).toInt(), - ), - ) - } - }.take(HOURLY_COUNT) - - val today = days.getJSONObject(0).getJSONObject("values") - fun num(o: JSONObject, name: String): Double? = if (o.has(name) && !o.isNull(name)) o.getDouble(name) else null - val temp = currentValues.getDouble("temperature") - val code = currentValues.optInt("weatherCode") - return WeatherSnapshot( - locationName = place?.first.orEmpty(), - region = place?.second.orEmpty(), - tempC = temp, - feelsLikeC = currentValues.optDouble("temperatureApparent", temp), - condition = conditionFor(code), - conditionText = textFor(code), - isDay = isDay(now), - humidity = currentValues.optDouble("humidity", 0.0).toInt(), - windKph = currentValues.optDouble("windSpeed", 0.0) * 3.6, - chanceOfRain = today.optDouble("precipitationProbabilityMax", today.optDouble("precipitationProbabilityAvg", 0.0)).toInt(), - highC = today.optDouble("temperatureMax", temp), - lowC = today.optDouble("temperatureMin", temp), - hourly = forecast, - alerts = emptyList(), - updatedAt = now, - providerId = id, - extras = WeatherExtras( - pressureHpa = num(currentValues, "pressureSurfaceLevel"), - visibilityKm = num(currentValues, "visibility"), - dewPointC = num(currentValues, "dewPoint"), - cloudCover = num(currentValues, "cloudCover")?.toInt(), - uvIndex = num(currentValues, "uvIndex"), - windGustKph = num(currentValues, "windGust")?.times(3.6), - windDirectionDeg = num(currentValues, "windDirection"), - precipitationMm = num(currentValues, "rainIntensity"), - sunriseMillis = suns.firstOrNull()?.first, - sunsetMillis = suns.firstOrNull()?.second, - ), - daily = (0 until days.length()).map { i -> - val d = days.getJSONObject(i) - val v = d.getJSONObject("values") - DailyForecast( - dayMillis = Instant.parse(d.getString("time")).toEpochMilli(), - highC = v.optDouble("temperatureMax", temp), - lowC = v.optDouble("temperatureMin", temp), - condition = conditionFor(v.optInt("weatherCodeMax", v.optInt("weatherCode"))), - chanceOfRain = v.optDouble("precipitationProbabilityMax", 0.0).toInt(), - ) - }, - ) - } - - // https://docs.tomorrow.io/reference/data-layers-weather-codes - private fun conditionFor(code: Int): WeatherCondition = when (code) { - 1000, 1100 -> WeatherCondition.CLEAR - 1101 -> WeatherCondition.PARTLY_CLOUDY - 1102, 1001 -> WeatherCondition.CLOUDY - 2000, 2100 -> WeatherCondition.FOG - 4000, 4200 -> WeatherCondition.DRIZZLE - 4001 -> WeatherCondition.RAIN - 4201 -> WeatherCondition.HEAVY_RAIN - 5000, 5001, 5100, 5101 -> WeatherCondition.SNOW - 6000, 6001, 6200, 6201, 7000, 7101, 7102 -> WeatherCondition.SLEET - 8000 -> WeatherCondition.THUNDERSTORM - else -> WeatherCondition.UNKNOWN - } - - private fun textFor(code: Int): String = when (code) { - 1000 -> "Clear" - 1100 -> "Mostly clear" - 1101 -> "Partly cloudy" - 1102 -> "Mostly cloudy" - 1001 -> "Cloudy" - 2000 -> "Fog" - 2100 -> "Light fog" - 4000 -> "Drizzle" - 4001 -> "Rain" - 4200 -> "Light rain" - 4201 -> "Heavy rain" - 5000 -> "Snow" - 5001 -> "Flurries" - 5100 -> "Light snow" - 5101 -> "Heavy snow" - 6000, 6200 -> "Freezing drizzle" - 6001, 6201 -> "Freezing rain" - 7000, 7101, 7102 -> "Ice pellets" - 8000 -> "Thunderstorm" - else -> "Unknown" - } - - private companion object { - const val HOUR_MS = 60 * 60 * 1000L - const val HOURLY_COUNT = 24 - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/VisualCrossingProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/VisualCrossingProvider.kt deleted file mode 100644 index 074e59e3b2..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/VisualCrossingProvider.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.AlertSeverity -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.WeatherExtras -import com.sameerasw.essentials.weather.model.DailyForecast -import com.sameerasw.essentials.weather.model.HourlyForecast -import com.sameerasw.essentials.weather.model.WeatherAlert -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import org.json.JSONException -import org.json.JSONObject -import java.time.OffsetDateTime -import java.time.format.DateTimeParseException - -class VisualCrossingProvider : WeatherProvider { - override val id = "visualcrossing" - override val displayName = "Visual Crossing" - override val requiresApiKey = true - override val signupUrl = "https://www.visualcrossing.com/sign-up" - - override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { - val key = apiKey?.trim().orEmpty() - if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - val url = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/" + - "${location.latitude},${location.longitude}/next7days?unitGroup=metric&include=current,hours,days,alerts" + - "&contentType=json&key=${ProviderHttp.encode(key)}" - val json = JSONObject(ProviderHttp.get(url)) - val place = location.name?.let { it to "" } ?: ProviderHttp.reverseGeocode(location.latitude, location.longitude) - return try { - parse(json, place) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - } - - override suspend fun searchCities(query: String, apiKey: String?): List = ProviderHttp.searchCities(query) - - private fun parse(json: JSONObject, place: Pair?): WeatherSnapshot { - val now = System.currentTimeMillis() - val current = json.getJSONObject("currentConditions") - val days = json.getJSONArray("days") - val today = days.getJSONObject(0) - - val hourly = buildList { - for (d in 0 until days.length()) { - val hours = days.getJSONObject(d).optJSONArray("hours") ?: continue - for (h in 0 until hours.length()) { - val hour = hours.getJSONObject(h) - val time = hour.getLong("datetimeEpoch") * 1000L - if (time + HOUR_MS <= now) continue - val icon = hour.optString("icon") - add( - HourlyForecast( - timeMillis = time, - tempC = hour.getDouble("temp"), - condition = conditionFor(icon), - isDay = !icon.endsWith("-night"), - chanceOfRain = hour.optDouble("precipprob", 0.0).toInt(), - ), - ) - } - } - }.take(HOURLY_COUNT) - - val alertArray = json.optJSONArray("alerts") - val alerts = buildList { - if (alertArray != null) { - for (i in 0 until alertArray.length()) { - val a = alertArray.getJSONObject(i) - val event = a.optString("event").ifBlank { a.optString("headline") } - add( - WeatherAlert( - id = "$id:${a.optString("id")}:${a.optString("onset")}:$event".hashCode().toString(), - event = event, - headline = a.optString("headline").ifBlank { event }, - severity = AlertSeverity.UNKNOWN, - description = a.optString("description").trim(), - effectiveMillis = parseTime(a.optString("onsetEpoch").ifBlank { null }, a.optString("onset")), - expiresMillis = parseTime(a.optString("endsEpoch").ifBlank { null }, a.optString("ends")), - ), - ) - } - } - }.distinctBy { it.id } - - val icon = current.optString("icon") - val temp = current.getDouble("temp") - return WeatherSnapshot( - locationName = place?.first.orEmpty(), - region = place?.second.orEmpty(), - tempC = temp, - feelsLikeC = current.optDouble("feelslike", temp), - condition = conditionFor(icon), - conditionText = current.optString("conditions"), - isDay = !icon.endsWith("-night"), - humidity = current.optDouble("humidity", 0.0).toInt(), - windKph = current.optDouble("windspeed", 0.0), - chanceOfRain = today.optDouble("precipprob", 0.0).toInt(), - highC = today.optDouble("tempmax", temp), - lowC = today.optDouble("tempmin", temp), - hourly = hourly, - alerts = alerts, - updatedAt = now, - providerId = id, - extras = WeatherExtras( - pressureHpa = current.optDouble("pressure").takeUnless { it.isNaN() }, - visibilityKm = current.optDouble("visibility").takeUnless { it.isNaN() }, - dewPointC = current.optDouble("dew").takeUnless { it.isNaN() }, - cloudCover = current.optDouble("cloudcover").takeUnless { it.isNaN() }?.toInt(), - uvIndex = current.optDouble("uvindex").takeUnless { it.isNaN() }, - windGustKph = current.optDouble("windgust").takeUnless { it.isNaN() }, - windDirectionDeg = current.optDouble("winddir").takeUnless { it.isNaN() }, - precipitationMm = current.optDouble("precip").takeUnless { it.isNaN() }, - sunriseMillis = current.optLong("sunriseEpoch", 0L).takeIf { it > 0 }?.times(1000L), - sunsetMillis = current.optLong("sunsetEpoch", 0L).takeIf { it > 0 }?.times(1000L), - ), - daily = (0 until days.length()).map { i -> - val d = days.getJSONObject(i) - DailyForecast( - dayMillis = d.getLong("datetimeEpoch") * 1000L, - highC = d.getDouble("tempmax"), - lowC = d.getDouble("tempmin"), - condition = conditionFor(d.optString("icon")), - chanceOfRain = d.optDouble("precipprob", 0.0).toInt(), - ) - }, - ) - } - - private fun parseTime(epoch: String?, iso: String?): Long? { - epoch?.toLongOrNull()?.let { return it * 1000L } - if (iso.isNullOrBlank()) return null - return try { - OffsetDateTime.parse(iso).toInstant().toEpochMilli() - } catch (_: DateTimeParseException) { - null - } - } - - // https://www.visualcrossing.com/resources/documentation/weather-api/defining-icon-set-in-the-weather-api/ - private fun conditionFor(icon: String): WeatherCondition = when { - icon.startsWith("thunder") -> WeatherCondition.THUNDERSTORM - icon.contains("snow") && icon.contains("rain") -> WeatherCondition.SLEET - icon.contains("snow") -> WeatherCondition.SNOW - icon.contains("sleet") || icon.contains("freezing") -> WeatherCondition.SLEET - icon.contains("hail") -> WeatherCondition.HAIL - icon.startsWith("showers") -> WeatherCondition.DRIZZLE - icon.contains("rain") -> WeatherCondition.RAIN - icon == "fog" -> WeatherCondition.FOG - icon.startsWith("clear") -> WeatherCondition.CLEAR - icon.startsWith("partly-cloudy") -> WeatherCondition.PARTLY_CLOUDY - icon == "cloudy" || icon == "wind" -> WeatherCondition.CLOUDY - else -> WeatherCondition.UNKNOWN - } - - private companion object { - const val HOUR_MS = 60 * 60 * 1000L - const val HOURLY_COUNT = 24 - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/WeatherApiProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/WeatherApiProvider.kt deleted file mode 100644 index 3141fd994c..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/WeatherApiProvider.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.AlertSeverity -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.DailyForecast -import com.sameerasw.essentials.weather.model.HourlyForecast -import com.sameerasw.essentials.weather.model.WeatherExtras -import com.sameerasw.essentials.weather.model.WeatherAlert -import com.sameerasw.essentials.weather.model.WeatherCondition -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import java.net.URLEncoder -import java.time.OffsetDateTime -import java.time.format.DateTimeParseException - -class WeatherApiProvider : WeatherProvider { - override val id = "weatherapi" - override val displayName = "WeatherAPI.com" - override val requiresApiKey = true - override val signupUrl = "https://www.weatherapi.com/signup.aspx" - - override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { - val key = apiKey?.trim().orEmpty() - if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - val json = JSONObject(get("forecast.json", "key=${encode(key)}&q=${encode(location.query)}&days=3&aqi=no&alerts=yes")) - return try { - parseForecast(json, location) - } catch (e: JSONException) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) - } - } - - override suspend fun searchCities(query: String, apiKey: String?): List { - val key = apiKey?.trim().orEmpty() - if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - if (query.isBlank()) return emptyList() - val array = JSONArray(get("search.json", "key=${encode(key)}&q=${encode(query.trim())}")) - return (0 until array.length()).map { i -> - val o = array.getJSONObject(i) - CityResult( - name = o.optString("name"), - region = o.optString("region"), - country = o.optString("country"), - latitude = o.optDouble("lat"), - longitude = o.optDouble("lon"), - ) - } - } - - private suspend fun get(endpoint: String, query: String): String = withContext(Dispatchers.IO) { - val connection = try { - URL("$BASE_URL/$endpoint?$query").openConnection() as HttpURLConnection - } catch (e: IOException) { - throw WeatherProviderException(WeatherProviderException.Reason.NETWORK, e.message) - } - try { - connection.connectTimeout = TIMEOUT_MS - connection.readTimeout = TIMEOUT_MS - val code = connection.responseCode - if (code == HttpURLConnection.HTTP_UNAUTHORIZED || code == HttpURLConnection.HTTP_FORBIDDEN) { - throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) - } - if (code !in 200..299) { - throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, "HTTP $code") - } - connection.inputStream.bufferedReader().use { it.readText() } - } catch (e: IOException) { - throw WeatherProviderException(WeatherProviderException.Reason.NETWORK, e.message) - } finally { - connection.disconnect() - } - } - - private fun parseForecast(json: JSONObject, requested: WeatherLocation): WeatherSnapshot { - val location = json.getJSONObject("location") - val current = json.getJSONObject("current") - val days = json.getJSONObject("forecast").getJSONArray("forecastday") - val today = days.getJSONObject(0).getJSONObject("day") - val now = System.currentTimeMillis() - - val hourly = buildList { - for (d in 0 until days.length()) { - val hours = days.getJSONObject(d).getJSONArray("hour") - for (h in 0 until hours.length()) { - val hour = hours.getJSONObject(h) - val time = hour.getLong("time_epoch") * 1000L - if (time + HOUR_MS <= now) continue - add( - HourlyForecast( - timeMillis = time, - tempC = hour.getDouble("temp_c"), - condition = conditionFor(hour.getJSONObject("condition").optInt("code")), - isDay = hour.optInt("is_day", 1) == 1, - chanceOfRain = hour.optInt("chance_of_rain"), - ), - ) - } - } - }.take(HOURLY_COUNT) - - val alertArray = json.optJSONObject("alerts")?.optJSONArray("alert") ?: JSONArray() - val alerts = (0 until alertArray.length()).map { i -> - val a = alertArray.getJSONObject(i) - val event = a.optString("event").ifBlank { a.optString("headline") } - val effective = parseTime(a.optString("effective")) - WeatherAlert( - id = "$id:${a.optString("headline")}:${a.optString("effective")}:$event".hashCode().toString(), - event = event, - headline = a.optString("headline").ifBlank { event }, - severity = severityFor(a.optString("severity")), - description = a.optString("desc").trim(), - effectiveMillis = effective, - expiresMillis = parseTime(a.optString("expires")), - ) - }.distinctBy { it.id } - - val condition = current.getJSONObject("condition") - return WeatherSnapshot( - locationName = requested.name ?: location.optString("name"), - region = location.optString("region").ifBlank { location.optString("country") }, - tempC = current.getDouble("temp_c"), - feelsLikeC = current.optDouble("feelslike_c", current.getDouble("temp_c")), - condition = conditionFor(condition.optInt("code")), - conditionText = condition.optString("text"), - isDay = current.optInt("is_day", 1) == 1, - humidity = current.optInt("humidity"), - windKph = current.optDouble("wind_kph"), - chanceOfRain = today.optInt("daily_chance_of_rain"), - highC = today.getDouble("maxtemp_c"), - lowC = today.getDouble("mintemp_c"), - hourly = hourly, - alerts = alerts, - updatedAt = now, - providerId = id, - extras = WeatherExtras( - pressureHpa = current.optDouble("pressure_mb").takeUnless { it.isNaN() }, - visibilityKm = current.optDouble("vis_km").takeUnless { it.isNaN() }, - dewPointC = current.optDouble("dewpoint_c").takeUnless { it.isNaN() }, - cloudCover = current.optInt("cloud", -1).takeIf { it >= 0 }, - uvIndex = current.optDouble("uv").takeUnless { it.isNaN() }, - windGustKph = current.optDouble("gust_kph").takeUnless { it.isNaN() }, - windDirectionDeg = current.optDouble("wind_degree").takeUnless { it.isNaN() }, - precipitationMm = current.optDouble("precip_mm").takeUnless { it.isNaN() }, - ), - daily = (0 until days.length()).map { i -> - val d = days.getJSONObject(i) - val day = d.getJSONObject("day") - DailyForecast( - dayMillis = d.getLong("date_epoch") * 1000L, - highC = day.getDouble("maxtemp_c"), - lowC = day.getDouble("mintemp_c"), - condition = conditionFor(day.getJSONObject("condition").optInt("code")), - chanceOfRain = day.optInt("daily_chance_of_rain"), - ) - }, - ) - } - - private fun parseTime(value: String?): Long? { - if (value.isNullOrBlank()) return null - return try { - OffsetDateTime.parse(value).toInstant().toEpochMilli() - } catch (_: DateTimeParseException) { - null - } - } - - private fun severityFor(value: String?): AlertSeverity = when (value?.trim()?.lowercase()) { - "minor" -> AlertSeverity.MINOR - "moderate" -> AlertSeverity.MODERATE - "severe" -> AlertSeverity.SEVERE - "extreme" -> AlertSeverity.EXTREME - else -> AlertSeverity.UNKNOWN - } - - // https://www.weatherapi.com/docs/weather_conditions.json - private fun conditionFor(code: Int): WeatherCondition = when (code) { - 1000 -> WeatherCondition.CLEAR - 1003 -> WeatherCondition.PARTLY_CLOUDY - 1006, 1009 -> WeatherCondition.CLOUDY - 1030, 1135, 1147 -> WeatherCondition.FOG - 1063, 1150, 1153, 1180, 1183, 1240 -> WeatherCondition.DRIZZLE - 1186, 1189, 1243 -> WeatherCondition.RAIN - 1192, 1195, 1246 -> WeatherCondition.HEAVY_RAIN - 1069, 1072, 1168, 1171, 1198, 1201, 1204, 1207, 1249, 1252 -> WeatherCondition.SLEET - 1066, 1114, 1117, 1210, 1213, 1216, 1219, 1222, 1225, 1255, 1258 -> WeatherCondition.SNOW - 1237, 1261, 1264 -> WeatherCondition.HAIL - 1087, 1273, 1276, 1279, 1282 -> WeatherCondition.THUNDERSTORM - else -> WeatherCondition.UNKNOWN - } - - private fun encode(value: String) = URLEncoder.encode(value, "UTF-8") - - private companion object { - const val BASE_URL = "https://api.weatherapi.com/v1" - const val TIMEOUT_MS = 15_000 - const val HOUR_MS = 60 * 60 * 1000L - const val HOURLY_COUNT = 24 - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/weather/provider/WeatherProvider.kt b/app/src/main/java/com/sameerasw/essentials/weather/provider/WeatherProvider.kt deleted file mode 100644 index dedd5f1600..0000000000 --- a/app/src/main/java/com/sameerasw/essentials/weather/provider/WeatherProvider.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.sameerasw.essentials.weather.provider - -import com.sameerasw.essentials.weather.model.CityResult -import com.sameerasw.essentials.weather.model.WeatherLocation -import com.sameerasw.essentials.weather.model.WeatherSnapshot - -interface WeatherProvider { - val id: String - val displayName: String - val requiresApiKey: Boolean - val signupUrl: String? - - suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot - - suspend fun searchCities(query: String, apiKey: String?): List -} - -class WeatherProviderException(val reason: Reason, message: String? = null) : Exception(message) { - enum class Reason { INVALID_KEY, NETWORK, BAD_RESPONSE } -} - -object WeatherProviders { - val all: List = listOf( - OpenMeteoProvider(), - MetNorwayProvider(), - NwsProvider(), - PirateWeatherProvider(), - TomorrowIoProvider(), - VisualCrossingProvider(), - OpenWeatherMapProvider(), - WeatherApiProvider(), - ) - - val default: WeatherProvider get() = all.first() - - fun byId(id: String?): WeatherProvider = all.firstOrNull { it.id == id } ?: default - - // Users who set up a WeatherAPI key before other sources existed keep using it until they pick another. - fun resolve(savedId: String?, apiKey: String?): WeatherProvider = - if (savedId == null && !apiKey.isNullOrBlank()) byId("weatherapi") else byId(savedId) -} diff --git a/app/src/main/res/drawable/rounded_key_24.xml b/app/src/main/res/drawable/rounded_key_24.xml deleted file mode 100644 index 79d211d066..0000000000 --- a/app/src/main/res/drawable/rounded_key_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/rounded_location_city_24.xml b/app/src/main/res/drawable/rounded_location_city_24.xml deleted file mode 100644 index b6ec08d6f7..0000000000 --- a/app/src/main/res/drawable/rounded_location_city_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/rounded_wb_sunny_24.xml b/app/src/main/res/drawable/rounded_wb_sunny_24.xml deleted file mode 100644 index 52043f1ece..0000000000 --- a/app/src/main/res/drawable/rounded_wb_sunny_24.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 7d66dc5f5f..bdaf7834ab 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1826,11 +1826,7 @@ Cần có quyền quản trị thiết bị nếu sử dụng chế độ khóa. Xem trước các cảnh báo nghiêm trọng Tóm tắt ảnh hưởng của thời tiết Phản hồi xúc giác về thời tiết - Nhà cung cấp - Vị trí hiện tại - Chọn thành phố Đơn vị - Làm mới mỗi Bài hát thay đổi Hãy tiếp tục theo kịp tiến độ khi tạm dừng Cổng ý thức diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 924882be06..7ebdf7c5af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2894,7 +2894,6 @@ Do not auto collapse Hide when locked 2 line header - Simulate weather Show alignment hint Preview stage Auto @@ -2916,64 +2915,21 @@ Peek on severe alerts Weather effects Simulation haptics - Source - Display - Provider - API key - Get a free key - Current location - Pick a city - Search for a city - No cities found Units System - Refresh every - %1$d min - %1$d h H:%1$s L:%2$s Feels like %1$s Until %1$s - Updated %1$s - Not loaded yet - Weather experiments - Experimental - Time of day - Auto - Temperature - Dawn - Day - Dusk - Night - Allow location - Show in launcher - Pick a forecast source, set your location, units and refresh interval, and choose whether the full weather screen appears in your app list. The island and Brief use these settings. - Weather - Configure providers and settings - Weather settings - Weather - Hourly - Next days - Today - Details - Humidity - Wind - Gusts - Chance of rain - Precipitation - UV index - Pressure - Visibility - Dew point - Cloud cover - Sun - Sunrise - Sunset - Forecast model - Add an API key in weather options - The API key was rejected - Location permission is needed for current location + Overcast weather access + Lets Essentials read the latest weather for your current or saved location from Overcast. + Weather comes from the Overcast app. Install it, then allow access here. + Install Overcast + Install Overcast + Allow access to Overcast + Open Overcast + Install Overcast to see weather + Allow access to Overcast weather Couldn\'t get a location - Couldn\'t reach the weather service Couldn\'t load weather Timer options Show screen recorder diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 190c82473d..f7434c70f2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -8,12 +8,6 @@ true - -