Add Breezy Weather integration

This commit is contained in:
MM20 2025-04-28 20:58:59 +02:00
parent 4e2fbf965f
commit 5e59bf171a
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
14 changed files with 645 additions and 66 deletions

View File

@ -37,6 +37,7 @@ import de.mm20.launcher2.ui.overlays.OverlayHost
import de.mm20.launcher2.ui.settings.about.AboutSettingsScreen
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
import de.mm20.launcher2.ui.settings.calendarsearch.CalendarProviderSettingsScreen
import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsScreen
@ -244,6 +245,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/integrations/tasks") {
TasksIntegrationSettingsScreen()
}
composable("settings/integrations/breezyweather") {
BreezyWeatherSettingsScreen()
}
composable("settings/plugins") {
PluginsSettingsScreen()
}

View File

@ -0,0 +1,89 @@
package de.mm20.launcher2.ui.settings.breezyweather
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
@Composable
fun BreezyWeatherSettingsScreen() {
val activity = LocalActivity.current as AppCompatActivity
val viewModel: BreezyWeatherSettingsScreenVM = viewModel()
val isBreezyInstalled by viewModel.isBreezyInstalled.collectAsStateWithLifecycle(null)
val isWeatherProvider by viewModel.isBreezySetAsWeatherProvider.collectAsStateWithLifecycle(null)
PreferenceScreen(
title = stringResource(R.string.preference_breezyweather_integration)
) {
if (isBreezyInstalled == false) {
item {
Banner(
text = stringResource(
R.string.preference_breezyweather_integration_description,
stringResource(R.string.app_name),
),
icon = Icons.Rounded.Info,
modifier = Modifier.padding(16.dp),
primaryAction = {
Button(onClick = {
viewModel.downloadBreezyApp(activity)
}) {
Text(stringResource(R.string.action_install))
}
}
)
}
}
if (isBreezyInstalled == true) {
item {
Banner(
text = stringResource(
R.string.preference_breezyweather_integration_instructions,
stringResource(R.string.app_name),
),
icon = Icons.Rounded.Info,
modifier = Modifier.padding(16.dp),
)
}
item {
PreferenceCategory {
Preference(
title = stringResource(R.string.preference_breezyweather_integration),
summary = stringResource(
if (isWeatherProvider == true) R.string.plugin_weather_provider_enabled
else R.string.plugin_weather_provider_enable
),
enabled = isWeatherProvider == false,
onClick = {
viewModel.setBreezyAsWeatherProvider()
}
)
Preference(
title = stringResource(R.string.preference_launch_breezyweather_app),
icon = Icons.AutoMirrored.Rounded.OpenInNew,
onClick = {
viewModel.launchBreezyApp(activity)
}
)
}
}
}
}
}

View File

@ -0,0 +1,53 @@
package de.mm20.launcher2.ui.settings.breezyweather
import android.content.Intent
import android.os.Process
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.preferences.weather.WeatherSettings
import de.mm20.launcher2.weather.WeatherRepository
import de.mm20.launcher2.weather.breezy.BreezyWeatherProvider
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class BreezyWeatherSettingsScreenVM: ViewModel(), KoinComponent {
private val appRepository: AppRepository by inject()
private val weatherSettings: WeatherSettings by inject()
private val breezyWeatherApp = appRepository.findOne("org.breezyweather", Process.myUserHandle())
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
val isBreezyInstalled = breezyWeatherApp
.map { it != null }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
val isBreezySetAsWeatherProvider = weatherSettings.providerId
.map { it == BreezyWeatherProvider.Id }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)
fun setBreezyAsWeatherProvider() {
weatherSettings.setProvider(BreezyWeatherProvider.Id)
}
fun launchBreezyApp(activity: AppCompatActivity) {
viewModelScope.launch {
breezyWeatherApp.first()?.launch(activity, null)
}
}
fun downloadBreezyApp(activity: AppCompatActivity) {
activity.startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = "https://github.com/breezy-weather/breezy-weather/blob/main/INSTALL.md".toUri()
}
)
}
}

View File

@ -7,11 +7,13 @@ import androidx.compose.material.icons.rounded.PlayCircleOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.icons.BreezyWeather
import de.mm20.launcher2.icons.Google
import de.mm20.launcher2.icons.Nextcloud
import de.mm20.launcher2.icons.Owncloud
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.locals.LocalNavController
@ -22,41 +24,54 @@ fun IntegrationsSettingsScreen() {
PreferenceScreen(title = stringResource(R.string.preference_screen_integrations)) {
item {
Preference(
title = stringResource(R.string.preference_weather_integration),
icon = Icons.Rounded.LightMode,
onClick = {
navController?.navigate("settings/integrations/weather")
}
)
Preference(
title = stringResource(R.string.preference_media_integration),
icon = Icons.Rounded.PlayCircleOutline,
onClick = {
navController?.navigate("settings/integrations/media")
}
)
Preference(
title = stringResource(R.string.preference_nextcloud),
icon = Icons.Rounded.Nextcloud,
onClick = {
navController?.navigate("settings/integrations/nextcloud")
}
)
Preference(
title = stringResource(R.string.preference_owncloud),
icon = Icons.Rounded.Owncloud,
onClick = {
navController?.navigate("settings/integrations/owncloud")
}
)
Preference(
title = stringResource(R.string.preference_tasks_integration),
icon = Icons.Default.Check,
onClick = {
navController?.navigate("settings/integrations/tasks")
}
)
PreferenceCategory {
Preference(
title = stringResource(R.string.preference_weather_integration),
icon = Icons.Rounded.LightMode,
onClick = {
navController?.navigate("settings/integrations/weather")
}
)
Preference(
title = stringResource(R.string.preference_media_integration),
icon = Icons.Rounded.PlayCircleOutline,
onClick = {
navController?.navigate("settings/integrations/media")
}
)
}
}
item {
PreferenceCategory {
Preference(
title = stringResource(R.string.preference_nextcloud),
icon = Icons.Rounded.Nextcloud,
onClick = {
navController?.navigate("settings/integrations/nextcloud")
}
)
Preference(
title = stringResource(R.string.preference_owncloud),
icon = Icons.Rounded.Owncloud,
onClick = {
navController?.navigate("settings/integrations/owncloud")
}
)
Preference(
title = stringResource(R.string.preference_tasks_integration),
icon = Icons.Default.Check,
onClick = {
navController?.navigate("settings/integrations/tasks")
}
)
Preference(
title = stringResource(R.string.preference_breezyweather_integration),
icon = Icons.Rounded.BreezyWeather,
onClick = {
navController?.navigate("settings/integrations/breezyweather")
}
)
}
}
}
}

View File

@ -1,6 +1,7 @@
package de.mm20.launcher2.ui.settings.weather
import android.app.PendingIntent
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.padding
@ -18,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.ui.BuildConfig
import de.mm20.launcher2.ui.R
@ -26,6 +28,7 @@ import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.*
import de.mm20.launcher2.weather.WeatherProviderInfo
import de.mm20.launcher2.weather.breezy.BreezyWeatherProvider
@Composable
fun WeatherIntegrationSettingsScreen() {
@ -93,7 +96,16 @@ fun WeatherIntegrationSettingsScreen() {
}
item {
PreferenceCategory(title = stringResource(R.string.preference_category_location)) {
if (selectedProviderInfo?.managedLocation == true) {
if (selectedProviderInfo?.id == BreezyWeatherProvider.Id) {
Preference(
title = stringResource(R.string.preference_location),
summary = stringResource(R.string.preference_location_breezy),
onClick = {
val intent = context.packageManager.getLaunchIntentForPackage("org.breezyweather") ?: return@Preference
context.tryStartActivity(intent)
}
)
} else if (selectedProviderInfo?.managedLocation == true) {
Preference(
title = stringResource(R.string.preference_location_managed),
summary = stringResource(R.string.preference_location_managed_summary),

View File

@ -1681,4 +1681,63 @@ private val _PrivateSpace = materialIcon("Icons.Rounded.PrivateSpace") {
}
val Icons.Rounded.PrivateSpace
get() = _PrivateSpace
get() = _PrivateSpace
private val _BreezyWeather = materialIcon("Icons.Rounded.BreezyWeather") {
materialPath {
moveTo(11.51596f, 15.483644f)
curveToRelative(-0.270954f, 0.445157f, -0.619347f, 0.870954f, -1.006439f, 1.258046f)
curveToRelative(-0.5806338f, 0.580637f, -1.1999812f, 1.02579f, -1.8967447f, 1.354821f)
curveToRelative(0.8709544f, 0.483862f, 1.8580387f, 0.793534f, 2.9031837f, 0.851599f)
close()
moveTo(8.5160015f, 11.51596f)
curveTo(8.0708487f, 11.245006f, 7.6450471f, 10.896616f, 7.2579555f, 10.509525f)
curveTo(6.6966767f, 9.9482422f, 6.2515202f, 9.3288948f, 5.9224934f, 8.6514863f)
curveTo(5.4386307f, 9.5224406f, 5.1483101f, 10.49017f, 5.070894f, 11.535315f)
horizontalLineToRelative(3.4451075f)
close()
moveToRelative(6.9676425f, 0.967729f)
curveToRelative(0.445153f, 0.270955f, 0.870954f, 0.619344f, 1.258046f, 1.006436f)
curveToRelative(0.580634f, 0.580637f, 1.02579f, 1.199984f, 1.354817f, 1.877393f)
curveToRelative(0.483863f, -0.870954f, 0.774184f, -1.858038f, 0.8516f, -2.903184f)
horizontalLineTo(15.483644f)
close()
moveTo(12.483685f, 8.5160053f)
curveTo(12.75464f, 8.0708488f, 13.103033f, 7.6450509f, 13.490125f, 7.2579593f)
curveTo(14.051407f, 6.6966767f, 14.670751f, 6.251524f, 15.348159f, 5.9224934f)
curveTo(14.49656f, 5.4579857f, 13.509479f, 5.1483139f, 12.483685f, 5.090249f)
close()
moveTo(5.7483025f, 17.76748f)
curveToRelative(1.6064278f, 0f, 2.9225387f, -0.561279f, 4.0644549f, -1.703199f)
curveToRelative(1.0257906f, -1.02579f, 1.5677176f, -2.20642f, 1.6838476f, -3.580592f)
horizontalLineTo(0f)
curveToRelative(0.096756f, 1.393527f, 0.6580535f, 2.554802f, 1.6838476f, 3.580592f)
curveToRelative(1.1419162f, 1.14192f, 2.4580271f, 1.703199f, 4.0644549f, 1.703199f)
close()
moveTo(7.9353678f, 9.8127613f)
curveTo(8.961158f, 10.838552f, 10.141784f, 11.380479f, 11.51596f, 11.496605f)
verticalLineTo(0f)
curveTo(10.122429f, 0.096756f, 8.961158f, 0.6580573f, 7.9353678f, 1.6838476f)
curveTo(6.7934477f, 2.8257677f, 6.2321652f, 4.1612297f, 6.2321652f, 5.7483025f)
curveToRelative(0f, 1.5870766f, 0.5612825f, 2.9225387f, 1.7032026f, 4.0644588f)
close()
moveToRelative(4.5483172f, 2.6902827f)
verticalLineToRelative(11.51596f)
curveToRelative(1.393531f, -0.09676f, 2.593512f, -0.677412f, 3.580593f, -1.683848f)
curveToRelative(1.14192f, -1.14192f, 1.703202f, -2.477382f, 1.703202f, -4.083809f)
curveToRelative(0f, -1.606428f, -0.561282f, -2.922539f, -1.703202f, -4.064459f)
curveToRelative(-1.025791f, -1.02579f, -2.206417f, -1.587073f, -3.580593f, -1.683844f)
close()
moveToRelative(5.767658f, -6.270875f)
curveToRelative(-1.606428f, 0f, -2.922539f, 0.5612825f, -4.064455f, 1.7031988f)
curveTo(13.161098f, 8.9611581f, 12.61917f, 10.141788f, 12.50304f, 11.51596f)
horizontalLineTo(23.999646f)
curveTo(23.90289f, 10.122433f, 23.341592f, 8.9611581f, 22.315798f, 7.9353678f)
curveTo(21.173882f, 6.7934515f, 19.838416f, 6.232169f, 18.251343f, 6.232169f)
close()
}
}
val Icons.Rounded.BreezyWeather
get() = _BreezyWeather

View File

@ -345,6 +345,7 @@
<string name="weather_condition_snow">Snow</string>
<string name="weather_condition_hail">Hail</string>
<string name="weather_condition_thunderstorm">Thunderstorm</string>
<string name="weather_condition_thunder">Thunder</string>
<string name="weather_condition_heavysnowshowers">Heavy snow showers</string>
<string name="weather_condition_heavyrainshowers">Heavy rain showers</string>
<string name="weather_condition_rainshowersandthunder">Rain showers and thunder</string>
@ -356,6 +357,7 @@
<string name="weather_condition_heavyrainshowersandthunder">Heavy rain showers and thunder</string>
<string name="weather_condition_fair">Fair</string>
<string name="weather_condition_fog">Fog</string>
<string name="weather_condition_haze">Haze</string>
<string name="weather_condition_sleetshowersandthunder">Sleet showers and thunder</string>
<string name="weather_condition_rainandthunder">Rain and thunder</string>
<string name="weather_condition_lightsleet">Light sleet</string>
@ -448,12 +450,14 @@
<string name="provider_openweathermap">OpenWeatherMap</string>
<string name="provider_brightsky">Deutscher Wetterdienst (Germany only)</string>
<string name="provider_here">HERE</string>
<string name="provider_breezy">Breezy Weather</string>
<string name="preference_category_location">Location</string>
<string name="preference_automatic_location">Automatic location</string>
<string name="preference_automatic_location_summary">Use GPS and location services to determine location automatically</string>
<string name="preference_location_managed">Managed by plugin</string>
<string name="preference_location_managed_summary">The location for this provider is managed by the plugin app</string>
<string name="preference_location">Location</string>
<string name="preference_location_breezy">Manage location in Breezy Weather</string>
<string name="preference_imperial_units_summary">Use degrees Fahrenheit and miles per hour</string>
<string name="preference_imperial_units">Imperial units</string>
<string name="widget_config_weather_compact">Compact mode</string>
@ -671,6 +675,10 @@
<string name="preference_tasks_integration_description">Tasks is a free and open source to-do list and reminders app. If installed, %1$s can display and search your tasks from the Tasks app.</string>
<string name="preference_tasks_integration_ready">Tasks integration is fully set up and ready to use.</string>
<string name="preference_launch_tasks_app">Open Tasks app</string>
<string name="preference_breezyweather_integration">Breezy Weather</string>
<string name="preference_breezyweather_integration_description">Breezy Weather is a free and open source weather app. If installed, %1$s can use Breezy Weather as a data source for weather data.</string>
<string name="preference_breezyweather_integration_instructions">To use Breezy Weather as a weather provider:\n\n1. Go to Breezy Weather &gt; Settings &gt; Widgets &amp; Live wallpaper &gt; Send Gadgetbridge data &gt; Enable %1$s\n\n2. In %1$s, select Breezy Weather as weather provider</string>
<string name="preference_launch_breezyweather_app">Open Breezy Weather</string>
<string name="preference_contacts_call_on_tap">Tap to call</string>
<string name="preference_contacts_call_on_tap_summary">Immediately start a call when tapping a phone number</string>
<!-- Used in an info banner if a specific feature requires a Nextcloud account -->

View File

@ -1,25 +1,25 @@
package de.mm20.launcher2.weather
enum class WeatherIcon {
Unknown,
Clear,
Cloudy,
Cold,
Drizzle,
Haze,
Fog,
Hail,
HeavyThunderstorm,
HeavyThunderstormWithRain,
Hot,
MostlyCloudy,
PartlyCloudy,
Showers,
Sleet,
Snow,
Storm,
Thunderstorm,
ThunderstormWithRain,
Wind,
BrokenClouds,
enum class WeatherIcon(val id: Int) {
Unknown(-1),
Clear(0),
Cloudy(1),
Cold(2),
Drizzle(3),
Haze(4),
Fog(5),
Hail(6),
HeavyThunderstorm(7),
HeavyThunderstormWithRain(8),
Hot(9),
MostlyCloudy(10),
PartlyCloudy(11),
Showers(12),
Sleet(13),
Snow(14),
Storm(15),
Thunderstorm(16),
ThunderstormWithRain(17),
Wind(18),
BrokenClouds(19),
}

View File

@ -1,4 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application>
<receiver android:name=".breezy.BreezyWeatherReceiver" android:exported="true">
<intent-filter>
<action android:name="nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.weather
import de.mm20.launcher2.weather.breezy.BreezyWeatherProvider
import de.mm20.launcher2.weather.brightsky.BrightSkyProvider
import de.mm20.launcher2.weather.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider
@ -17,6 +18,7 @@ val weatherModule = module {
MetNoProvider.Id -> MetNoProvider(androidContext(), get())
HereProvider.Id -> HereProvider(androidContext())
BrightSkyProvider.Id -> BrightSkyProvider(androidContext())
BreezyWeatherProvider.Id -> BreezyWeatherProvider(androidContext())
else -> PluginWeatherProvider(androidContext(), providerId)
}
}

View File

@ -14,6 +14,7 @@ import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.preferences.LatLon
import de.mm20.launcher2.preferences.weather.WeatherLocation
import de.mm20.launcher2.preferences.weather.WeatherSettings
import de.mm20.launcher2.weather.breezy.BreezyWeatherProvider
import de.mm20.launcher2.weather.brightsky.BrightSkyProvider
import de.mm20.launcher2.weather.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider
@ -24,7 +25,9 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.time.Duration
import java.util.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
interface WeatherRepository {
fun getActiveProvider(): Flow<WeatherProviderInfo?>
@ -71,13 +74,6 @@ internal class WeatherRepositoryImpl(
}
init {
val weatherRequest =
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMinutes(60))
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather",
ExistingPeriodicWorkPolicy.UPDATE, weatherRequest
)
scope.launch {
hasLocationPermission.collectLatest {
@ -86,6 +82,14 @@ internal class WeatherRepositoryImpl(
}
scope.launch {
settings.collectLatest {
val provider = WeatherProvider.getInstance(it.provider)
val weatherRequest =
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMillis(provider.getUpdateInterval()))
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather",
ExistingPeriodicWorkPolicy.UPDATE, weatherRequest
)
requestUpdate()
}
}
@ -188,6 +192,15 @@ internal class WeatherRepositoryImpl(
)
)
}
if (BreezyWeatherProvider.isAvailable(context)) {
providers.add(
WeatherProviderInfo(
BreezyWeatherProvider.Id,
context.getString(R.string.provider_breezy),
managedLocation = true
)
)
}
val pluginProviders = pluginRepository.findMany(type = PluginType.Weather, enabled = true)
return pluginProviders.map {
providers + it.mapNotNull {

View File

@ -0,0 +1,85 @@
package de.mm20.launcher2.weather.breezy
import kotlinx.serialization.Serializable
@Serializable
internal data class BreezyWeatherData(
val timestamp: Long? = null,
val location: String? = null,
val currentTemp: Double? = null,
/**
* According to the spec this can be any OWM weather code (see https://openweathermap.org/weather-conditions),
* but in reality, only the following codes are ever used:
* 800, 801, 803, 500, 600, 771, 741, 751, 611, 511, 210, 211, 3200
* (see https://github.com/breezy-weather/breezy-weather/blob/main/app/src/main/java/org/breezyweather/sources/gadgetbridge/GadgetbridgeService.kt#L37)
*/
val currentConditionCode: Int? = null,
val currentCondition: String? = null,
val currentHumidity: Int? = null,
val todayMaxTemp: Int? = null,
val todayMinTemp: Int? = null,
val windSpeed: Float? = null,
val windDirection: Int? = null,
val uvIndex: Float? = null,
val precipProbability: Int? = null,
val dewPoint: Int? = null,
val pressure: Float? = null,
val cloudCover: Int? = null,
val visibility: Float? = null,
val sunRise: Int? = null,
val sunSet: Int? = null,
val moonRise: Int? = null,
val moonSet: Int? = null,
val moonPhase: Int? = null,
val feelsLikeTemp: Int? = null,
val forecasts: List<DailyForecast>? = null,
val hourly: List<HourlyForecast>? = null,
val airQuality: AirQuality? = null,
) {
@Serializable
data class AirQuality(
val aqi: Int? = null,
val co: Float? = null,
val no2: Float? = null,
val o3: Float? = null,
val pm10: Float? = null,
val pm25: Float? = null,
val so2: Float? = null,
val coAqi: Int? = null,
val no2Aqi: Int? = null,
val o3Aqi: Int? = null,
val pm10Aqi: Int? = null,
val pm25Aqi: Int? = null,
val so2Aqi: Int? = null,
)
@Serializable
data class DailyForecast(
val minTemp: Int? = null,
val maxTemp: Int? = null,
val conditionCode: Int? = null,
val humidity: Int? = null,
val windSpeed: Float? = null,
val windDirection: Int? = null,
val uvIndex: Float? = null,
val precipProbability: Int? = null,
val sunRise: Int? = null,
val sunSet: Int? = null,
val moonRise: Int? = null,
val moonSet: Int? = null,
val moonPhase: Int? = null,
val airQuality: AirQuality? = null,
)
@Serializable
data class HourlyForecast(
val timestamp: Int? = null,
val temp: Int? = null,
val conditionCode: Int? = null,
val humidity: Int? = null,
val windSpeed: Float? = null,
val windDirection: Int? = null,
val uvIndex: Float? = null,
val precipProbability: Int? = null,
)
}

View File

@ -0,0 +1,182 @@
package de.mm20.launcher2.weather.breezy
import android.content.Context
import android.content.pm.PackageManager
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.preferences.weather.WeatherLocation
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.R
import de.mm20.launcher2.weather.WeatherIcon
import de.mm20.launcher2.weather.WeatherProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class BreezyWeatherProvider(
private val context: Context,
) : WeatherProvider, KoinComponent {
private val database: AppDatabase by inject()
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
// Noop implementation, because Breezy weather is handled in a special way
return null
}
override suspend fun getWeatherData(
lat: Double,
lon: Double
): List<Forecast>? {
// Noop implementation, because Breezy weather is handled in a special way
return null
}
override suspend fun findLocation(query: String): List<WeatherLocation> {
// Noop implementation, because Breezy weather is handled in a special way
return emptyList()
}
override suspend fun getUpdateInterval(): Long {
// Updates are pushed, no need to pull
return Long.MAX_VALUE
}
internal suspend fun pushWeatherData(data: BreezyWeatherData) {
val result = mutableListOf<Forecast>()
val lastUpdate = System.currentTimeMillis()
result += Forecast(
timestamp = data.timestamp?.times(1000L) ?: return,
temperature = data.currentTemp ?: return,
icon = iconForId(data.currentConditionCode ?: return).id,
condition = data.currentCondition ?: return,
location = data.location ?: return,
provider = "Breezy Weather",
clouds = data.cloudCover,
humidity = data.currentHumidity?.toDouble(),
pressure = data.pressure?.toDouble(),
windSpeed = data.windSpeed?.toDouble()?.div(3.6),
precipProbability = data.precipProbability,
windDirection = data.windDirection?.toDouble(),
providerUrl = "de.mm20.launcher.plugin.breezyweather://-",
night = isNight(
data.timestamp.times(1000L),
data.sunRise?.times(1000L),
data.sunSet?.times(1000L)
),
updateTime = lastUpdate,
)
val sunrises = buildList {
if (data.sunRise != null) add(data.sunRise.times(1000L))
if (data.forecasts != null) addAll(data.forecasts.mapNotNull { it.sunRise?.times(1000L) })
}.sorted()
val sunsets = buildList {
if (data.sunSet != null) add(data.sunSet.times(1000L))
if (data.forecasts != null) addAll(data.forecasts.mapNotNull { it.sunSet?.times(1000L) })
}.sorted()
for (hourly in data.hourly ?: emptyList()) {
val timestamp = hourly.timestamp?.times(1000L) ?: continue
val lastSunrise = sunrises.findLast { it < timestamp }
val lastSunset = sunsets.findLast { it < timestamp }
val nextSunrise = sunrises.find { it > timestamp }
val nextSunset = sunsets.find { it > timestamp }
val isNight = when {
lastSunrise != null && lastSunset != null -> lastSunrise < lastSunset
nextSunrise != null && nextSunset != null -> nextSunrise < nextSunset
lastSunset != null && lastSunrise == null -> true
nextSunrise != null && nextSunset == null -> true
else -> false
}
result += Forecast(
timestamp = timestamp,
temperature = hourly.temp?.toDouble() ?: continue,
icon = iconForId(hourly.conditionCode ?: continue).id,
condition = textForId(hourly.conditionCode) ?: continue,
location = data.location,
provider = "Breezy Weather",
humidity = hourly.humidity?.toDouble(),
windSpeed = hourly.windSpeed?.toDouble()?.div(3.6),
precipProbability = hourly.precipProbability,
windDirection = hourly.windDirection?.toDouble(),
updateTime = lastUpdate,
providerUrl = "de.mm20.launcher.plugin.breezyweather://-",
night = isNight
)
}
withContext(Dispatchers.IO) {
database.weatherDao()
.replaceAll(result.map { it.toDatabaseEntity() })
}
}
private fun iconForId(id: Int): WeatherIcon {
return when (id) {
200, 201, in 230..232 -> WeatherIcon.ThunderstormWithRain
202 -> WeatherIcon.ThunderstormWithRain
210, 211 -> WeatherIcon.Thunderstorm
212, 221 -> WeatherIcon.HeavyThunderstorm
in 300..302, in 310..312 -> WeatherIcon.Drizzle
313, 314, 321, in 500..504, 511, in 520..522, 531 -> WeatherIcon.Showers
in 600..602 -> WeatherIcon.Snow
611, 612, 615, 616, in 620..622 -> WeatherIcon.Sleet
701, 711, 731, 741, 761, 762 -> WeatherIcon.Fog
721 -> WeatherIcon.Haze
771, 781, in 900..902, in 958..962 -> WeatherIcon.Storm
800 -> WeatherIcon.Clear
801 -> WeatherIcon.PartlyCloudy
802 -> WeatherIcon.MostlyCloudy
803 -> WeatherIcon.BrokenClouds
804, 951 -> WeatherIcon.Cloudy
903 -> WeatherIcon.Cold
904 -> WeatherIcon.Hot
905, in 952..957 -> WeatherIcon.Wind
906 -> WeatherIcon.Hail
else -> WeatherIcon.Unknown
}
}
private fun textForId(id: Int): String? {
val resId = when (id) {
800 -> R.string.weather_condition_clearsky
801 -> R.string.weather_condition_partlycloudy
803 -> R.string.weather_condition_cloudy
500 -> R.string.weather_condition_rain
600 -> R.string.weather_condition_snow
771 -> R.string.weather_condition_wind
741 -> R.string.weather_condition_fog
751 -> R.string.weather_condition_haze
611 -> R.string.weather_condition_sleet
511 -> R.string.weather_condition_hail
210 -> R.string.weather_condition_thunder
211 -> R.string.weather_condition_thunderstorm
else -> R.string.weather_condition_unknown
}
return context.getString(resId)
}
private fun isNight(timestamp: Long, sunrise: Long?, sunset: Long?): Boolean {
return (sunrise != null && timestamp < sunrise) || (sunset != null && timestamp > sunset)
}
companion object {
internal fun isAvailable(context: Context): Boolean {
return try {
context.packageManager.getPackageInfo("org.breezyweather", 0)
return true
} catch (_: PackageManager.NameNotFoundException) {
return false
}
}
const val Id = "breezy"
}
}

View File

@ -0,0 +1,49 @@
package de.mm20.launcher2.weather.breezy
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.preferences.weather.WeatherSettings
import de.mm20.launcher2.serialization.Json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class BreezyWeatherReceiver: BroadcastReceiver(), KoinComponent {
private val settings: WeatherSettings by inject()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onReceive(context: Context, intent: Intent) {
scope.launch {
val provider = settings.providerId.first()
if (provider != BreezyWeatherProvider.Id) {
return@launch
}
val weatherJson = intent.getStringExtra("WeatherJson")
if (weatherJson == null) {
Log.e("BreezyWeatherPlugin", "Broadcast was received but WeatherJson was null")
return@launch
}
val weatherData = try {
Json.Lenient.decodeFromString<BreezyWeatherData>(weatherJson)
} catch (e: SerializationException) {
CrashReporter.logException(e)
return@launch
}
BreezyWeatherProvider(context).pushWeatherData(weatherData)
}
}
}