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.about.AboutSettingsScreen
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen 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.buildinfo.BuildInfoSettingsScreen
import de.mm20.launcher2.ui.settings.calendarsearch.CalendarProviderSettingsScreen import de.mm20.launcher2.ui.settings.calendarsearch.CalendarProviderSettingsScreen
import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsScreen import de.mm20.launcher2.ui.settings.calendarsearch.CalendarSearchSettingsScreen
@ -244,6 +245,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/integrations/tasks") { composable("settings/integrations/tasks") {
TasksIntegrationSettingsScreen() TasksIntegrationSettingsScreen()
} }
composable("settings/integrations/breezyweather") {
BreezyWeatherSettingsScreen()
}
composable("settings/plugins") { composable("settings/plugins") {
PluginsSettingsScreen() 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.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.icons.BreezyWeather
import de.mm20.launcher2.icons.Google import de.mm20.launcher2.icons.Google
import de.mm20.launcher2.icons.Nextcloud import de.mm20.launcher2.icons.Nextcloud
import de.mm20.launcher2.icons.Owncloud import de.mm20.launcher2.icons.Owncloud
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.preferences.Preference 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.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.locals.LocalNavController import de.mm20.launcher2.ui.locals.LocalNavController
@ -22,41 +24,54 @@ fun IntegrationsSettingsScreen() {
PreferenceScreen(title = stringResource(R.string.preference_screen_integrations)) { PreferenceScreen(title = stringResource(R.string.preference_screen_integrations)) {
item { item {
Preference( PreferenceCategory {
title = stringResource(R.string.preference_weather_integration), Preference(
icon = Icons.Rounded.LightMode, title = stringResource(R.string.preference_weather_integration),
onClick = { icon = Icons.Rounded.LightMode,
navController?.navigate("settings/integrations/weather") onClick = {
} navController?.navigate("settings/integrations/weather")
) }
Preference( )
title = stringResource(R.string.preference_media_integration), Preference(
icon = Icons.Rounded.PlayCircleOutline, title = stringResource(R.string.preference_media_integration),
onClick = { icon = Icons.Rounded.PlayCircleOutline,
navController?.navigate("settings/integrations/media") onClick = {
} navController?.navigate("settings/integrations/media")
) }
Preference( )
title = stringResource(R.string.preference_nextcloud), }
icon = Icons.Rounded.Nextcloud, }
onClick = { item {
navController?.navigate("settings/integrations/nextcloud") PreferenceCategory {
} Preference(
) title = stringResource(R.string.preference_nextcloud),
Preference( icon = Icons.Rounded.Nextcloud,
title = stringResource(R.string.preference_owncloud), onClick = {
icon = Icons.Rounded.Owncloud, navController?.navigate("settings/integrations/nextcloud")
onClick = { }
navController?.navigate("settings/integrations/owncloud") )
} Preference(
) title = stringResource(R.string.preference_owncloud),
Preference( icon = Icons.Rounded.Owncloud,
title = stringResource(R.string.preference_tasks_integration), onClick = {
icon = Icons.Default.Check, navController?.navigate("settings/integrations/owncloud")
onClick = { }
navController?.navigate("settings/integrations/tasks") )
} 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 package de.mm20.launcher2.ui.settings.weather
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -18,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.sendWithBackgroundPermission import de.mm20.launcher2.ktx.sendWithBackgroundPermission
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.plugin.PluginState import de.mm20.launcher2.plugin.PluginState
import de.mm20.launcher2.ui.BuildConfig import de.mm20.launcher2.ui.BuildConfig
import de.mm20.launcher2.ui.R 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.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.* import de.mm20.launcher2.ui.component.preferences.*
import de.mm20.launcher2.weather.WeatherProviderInfo import de.mm20.launcher2.weather.WeatherProviderInfo
import de.mm20.launcher2.weather.breezy.BreezyWeatherProvider
@Composable @Composable
fun WeatherIntegrationSettingsScreen() { fun WeatherIntegrationSettingsScreen() {
@ -93,7 +96,16 @@ fun WeatherIntegrationSettingsScreen() {
} }
item { item {
PreferenceCategory(title = stringResource(R.string.preference_category_location)) { 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( Preference(
title = stringResource(R.string.preference_location_managed), title = stringResource(R.string.preference_location_managed),
summary = stringResource(R.string.preference_location_managed_summary), summary = stringResource(R.string.preference_location_managed_summary),

View File

@ -1682,3 +1682,62 @@ private val _PrivateSpace = materialIcon("Icons.Rounded.PrivateSpace") {
val 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_snow">Snow</string>
<string name="weather_condition_hail">Hail</string> <string name="weather_condition_hail">Hail</string>
<string name="weather_condition_thunderstorm">Thunderstorm</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_heavysnowshowers">Heavy snow showers</string>
<string name="weather_condition_heavyrainshowers">Heavy rain showers</string> <string name="weather_condition_heavyrainshowers">Heavy rain showers</string>
<string name="weather_condition_rainshowersandthunder">Rain showers and thunder</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_heavyrainshowersandthunder">Heavy rain showers and thunder</string>
<string name="weather_condition_fair">Fair</string> <string name="weather_condition_fair">Fair</string>
<string name="weather_condition_fog">Fog</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_sleetshowersandthunder">Sleet showers and thunder</string>
<string name="weather_condition_rainandthunder">Rain and thunder</string> <string name="weather_condition_rainandthunder">Rain and thunder</string>
<string name="weather_condition_lightsleet">Light sleet</string> <string name="weather_condition_lightsleet">Light sleet</string>
@ -448,12 +450,14 @@
<string name="provider_openweathermap">OpenWeatherMap</string> <string name="provider_openweathermap">OpenWeatherMap</string>
<string name="provider_brightsky">Deutscher Wetterdienst (Germany only)</string> <string name="provider_brightsky">Deutscher Wetterdienst (Germany only)</string>
<string name="provider_here">HERE</string> <string name="provider_here">HERE</string>
<string name="provider_breezy">Breezy Weather</string>
<string name="preference_category_location">Location</string> <string name="preference_category_location">Location</string>
<string name="preference_automatic_location">Automatic 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_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">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_managed_summary">The location for this provider is managed by the plugin app</string>
<string name="preference_location">Location</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_summary">Use degrees Fahrenheit and miles per hour</string>
<string name="preference_imperial_units">Imperial units</string> <string name="preference_imperial_units">Imperial units</string>
<string name="widget_config_weather_compact">Compact mode</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_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_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_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">Tap to call</string>
<string name="preference_contacts_call_on_tap_summary">Immediately start a call when tapping a phone number</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 --> <!-- Used in an info banner if a specific feature requires a Nextcloud account -->

View File

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

View File

@ -1,4 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <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> </manifest>

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.weather package de.mm20.launcher2.weather
import de.mm20.launcher2.weather.breezy.BreezyWeatherProvider
import de.mm20.launcher2.weather.brightsky.BrightSkyProvider import de.mm20.launcher2.weather.brightsky.BrightSkyProvider
import de.mm20.launcher2.weather.here.HereProvider import de.mm20.launcher2.weather.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider import de.mm20.launcher2.weather.metno.MetNoProvider
@ -17,6 +18,7 @@ val weatherModule = module {
MetNoProvider.Id -> MetNoProvider(androidContext(), get()) MetNoProvider.Id -> MetNoProvider(androidContext(), get())
HereProvider.Id -> HereProvider(androidContext()) HereProvider.Id -> HereProvider(androidContext())
BrightSkyProvider.Id -> BrightSkyProvider(androidContext()) BrightSkyProvider.Id -> BrightSkyProvider(androidContext())
BreezyWeatherProvider.Id -> BreezyWeatherProvider(androidContext())
else -> PluginWeatherProvider(androidContext(), providerId) 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.LatLon
import de.mm20.launcher2.preferences.weather.WeatherLocation import de.mm20.launcher2.preferences.weather.WeatherLocation
import de.mm20.launcher2.preferences.weather.WeatherSettings 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.brightsky.BrightSkyProvider
import de.mm20.launcher2.weather.here.HereProvider import de.mm20.launcher2.weather.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider import de.mm20.launcher2.weather.metno.MetNoProvider
@ -24,7 +25,9 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.time.Duration import java.time.Duration
import java.util.* import java.util.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
interface WeatherRepository { interface WeatherRepository {
fun getActiveProvider(): Flow<WeatherProviderInfo?> fun getActiveProvider(): Flow<WeatherProviderInfo?>
@ -71,13 +74,6 @@ internal class WeatherRepositoryImpl(
} }
init { init {
val weatherRequest =
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMinutes(60))
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather",
ExistingPeriodicWorkPolicy.UPDATE, weatherRequest
)
scope.launch { scope.launch {
hasLocationPermission.collectLatest { hasLocationPermission.collectLatest {
@ -86,6 +82,14 @@ internal class WeatherRepositoryImpl(
} }
scope.launch { scope.launch {
settings.collectLatest { 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() 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) val pluginProviders = pluginRepository.findMany(type = PluginType.Weather, enabled = true)
return pluginProviders.map { return pluginProviders.map {
providers + it.mapNotNull { 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)
}
}
}