feat: current weather on home screen (MET/Yr API, no key)

The Yr app (no.nrk.yr) exposes no data to third parties; its backend,
api.met.no locationforecast 2.0, works with no API key (User-Agent header
only). New toggle 'Show Current Weather' under Display, right after 'Show
App Icons'. The home element is formatted exactly like the daily word and
shows a nerd-font weather glyph (nf-weather-*, from the installed
JetBrainsMonoNerdFont) plus the temperature in Celsius, e.g. '\uE312 16°'.

A 95KB subset of JetBrainsMonoNerdFont (ASCII + weather glyphs, OFL) is
bundled as R.font.jetbrains_mono_nf_weather so the glyphs render even if
the launcher font setting changes.

Uses the launcher's existing saved location (lat/lon prefs).
This commit is contained in:
2026-08-19 11:15:21 +02:00
parent 5288c980d0
commit 010ff8a0c8
14 changed files with 330 additions and 1 deletions

View File

@@ -0,0 +1 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is available with a FAQ at: https://scripts.sil.org/OFL

View File

@@ -33,14 +33,18 @@ import com.github.droidworksstudio.launcher.R
import com.github.droidworksstudio.launcher.accessibility.ActionService import com.github.droidworksstudio.launcher.accessibility.ActionService
import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO import com.github.droidworksstudio.launcher.data.dao.AppInfoDAO
import com.github.droidworksstudio.launcher.data.entities.AppInfo import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse import com.github.droidworksstudio.launcher.helper.weather.WeatherResponse
import com.github.droidworksstudio.launcher.utils.Constants import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.MetApiService
import com.github.droidworksstudio.launcher.utils.MetSymbolMapper
import com.github.droidworksstudio.launcher.utils.WeatherApiService import com.github.droidworksstudio.launcher.utils.WeatherApiService
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonReader
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlin.math.roundToInt
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.net.UnknownHostException import java.net.UnknownHostException
@@ -552,4 +556,89 @@ class AppHelper @Inject constructor() {
// Data class to hold cached weather data along with timestamp // Data class to hold cached weather data along with timestamp
data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse) data class CachedWeatherData(val timestamp: Long, val weatherResponse: WeatherResponse)
sealed class MetWeatherResult {
data class Success(val temperature: Int, val symbolCode: String) : MetWeatherResult()
data class Failure(val errorMessage: String) : MetWeatherResult()
}
/**
* Fetches the current temperature and symbol from the MET/Yr API
* (api.met.no locationforecast). No API key needed - only a
* descriptive User-Agent header. Result is cached for 15 minutes.
*/
fun fetchMetWeather(
context: Context,
latitude: Float,
longitude: Float,
): MetWeatherResult {
if (latitude == 0f && longitude == 0f) {
return MetWeatherResult.Failure("No location available")
}
val cached = context.getMetWeatherFromCache()
if (cached?.let {
System.currentTimeMillis() - it.first < TimeUnit.MINUTES.toMillis(15)
} == true
) {
return cached.second
}
try {
val retrofit = Retrofit.Builder()
.baseUrl("https://api.met.no/weatherapi/locationforecast/2.0/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(MetApiService::class.java)
val response = service.getCompact(latitude.toDouble(), longitude.toDouble()).execute()
if (response.isSuccessful) {
val body = response.body()
val timeseries = body?.properties?.timeseries
val first = timeseries?.firstOrNull()
if (first != null) {
val temperature = first.data.instant.details.airTemperature.roundToInt()
val symbolCode = first.data.next1Hours?.summary?.symbolCode
?: first.data.next6Hours?.summary?.symbolCode
?: "cloudy"
val result = MetWeatherResult.Success(temperature, symbolCode)
context.cacheMetWeather(result)
return result
}
return MetWeatherResult.Failure("Empty forecast")
} else {
return MetWeatherResult.Failure("MET API error: ${response.code()}")
}
} catch (e: UnknownHostException) {
return MetWeatherResult.Failure("Unknown host: api.met.no")
} catch (e: Exception) {
return MetWeatherResult.Failure(e.message ?: "MET fetch failed")
}
}
/**
* Builds the home-screen weather text: nerd-font weather glyph followed
* by the temperature in Celsius, e.g. "\uE312 16°".
*/
fun buildCurrentWeatherText(temperature: Int, symbolCode: String): String {
return "${MetSymbolMapper.glyphFor(symbolCode)} $temperature°"
}
private fun Context.cacheMetWeather(result: MetWeatherResult.Success) {
val sharedPreferences = getSharedPreferences(Constants.MET_WEATHER_PREFS, Context.MODE_PRIVATE)
sharedPreferences.edit()
.putLong(Constants.MET_WEATHER_TIMESTAMP, System.currentTimeMillis())
.putInt(Constants.MET_WEATHER_TEMPERATURE, result.temperature)
.putString(Constants.MET_WEATHER_SYMBOL, result.symbolCode)
.apply()
}
private fun Context.getMetWeatherFromCache(): Pair<Long, MetWeatherResult>? {
val sharedPreferences = getSharedPreferences(Constants.MET_WEATHER_PREFS, Context.MODE_PRIVATE)
val timestamp = sharedPreferences.getLong(Constants.MET_WEATHER_TIMESTAMP, -1L)
if (timestamp == -1L) return null
val temperature = sharedPreferences.getInt(Constants.MET_WEATHER_TEMPERATURE, 0)
val symbol = sharedPreferences.getString(Constants.MET_WEATHER_SYMBOL, "cloudy") ?: "cloudy"
return timestamp to MetWeatherResult.Success(temperature, symbol)
}
} }

View File

@@ -202,6 +202,10 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
get() = prefs.getBoolean(Constants.SHOW_NOTIFICATION_DOTS, false) get() = prefs.getBoolean(Constants.SHOW_NOTIFICATION_DOTS, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_NOTIFICATION_DOTS, value).apply() set(value) = prefs.edit().putBoolean(Constants.SHOW_NOTIFICATION_DOTS, value).apply()
var showCurrentWeather: Boolean
get() = prefs.getBoolean(Constants.SHOW_CURRENT_WEATHER, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_CURRENT_WEATHER, value).apply()
var searchEngines: Constants.SearchEngines var searchEngines: Constants.SearchEngines
get() { get() {
return try { return try {

View File

@@ -0,0 +1,46 @@
package com.github.droidworksstudio.launcher.helper.weather
import com.google.gson.annotations.SerializedName
/**
* Locationforecast 2.0 compact response (api.met.no) - only the fields the
* home-screen current-weather element needs.
*/
data class MetForecastResponse(
val properties: MetProperties
)
data class MetProperties(
val timeseries: List<MetTimeSeries>
)
data class MetTimeSeries(
val time: String,
val data: MetData
)
data class MetData(
val instant: MetInstant,
@SerializedName("next_1_hours")
val next1Hours: MetNextHours? = null,
@SerializedName("next_6_hours")
val next6Hours: MetNextHours? = null
)
data class MetInstant(
val details: MetDetails
)
data class MetDetails(
@SerializedName("air_temperature")
val airTemperature: Double = 0.0
)
data class MetNextHours(
val summary: MetSummary
)
data class MetSummary(
@SerializedName("symbol_code")
val symbolCode: String = ""
)

View File

@@ -22,6 +22,7 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -57,6 +58,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@@ -114,6 +116,12 @@ class HomeFragment : Fragment(),
setupRecyclerView() setupRecyclerView()
observeSwipeTouchListener() observeSwipeTouchListener()
observeUserInterfaceSettings() observeUserInterfaceSettings()
// Nerd-font weather glyphs (nf-weather-*) live in the bundled
// subset font; the system font here is JetBrainsMonoNerdFont but
// this guarantees rendering even if the launcher font is changed.
binding.currentWeather.typeface =
ResourcesCompat.getFont(requireContext(), R.font.jetbrains_mono_nf_weather)
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
@@ -323,6 +331,17 @@ class HomeFragment : Fragment(),
) )
} }
preferenceViewModel.showCurrentWeatherLiveData.observe(viewLifecycleOwner) {
appHelper.updateUI(
binding.currentWeather,
preferenceHelper.homeDailyWordAlignment,
preferenceHelper.dailyWordColor,
preferenceHelper.dailyWordTextSize,
it
)
if (it) loadCurrentWeather()
}
binding.apply { binding.apply {
mainView.hideKeyboard() mainView.hideKeyboard()
@@ -343,6 +362,41 @@ class HomeFragment : Fragment(),
} }
} }
/**
* Fetches the current temperature from the MET/Yr API and shows it as a
* nerd-font weather glyph plus Celsius value, e.g. "\uE312 16°".
* Formatted like the daily word (same alignment/color/size settings).
*/
private fun loadCurrentWeather() {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val sharedPreferences =
requireContext().getSharedPreferences(Constants.WEATHER_PREFS, Context.MODE_PRIVATE)
val latitude = sharedPreferences.getFloat(Constants.LATITUDE, 0f)
val longitude = sharedPreferences.getFloat(Constants.LONGITUDE, 0f)
val result = appHelper.fetchMetWeather(requireContext(), latitude, longitude)
withContext(Dispatchers.Main) {
when (result) {
is AppHelper.MetWeatherResult.Success -> {
binding.currentWeather.text = appHelper.buildCurrentWeatherText(
result.temperature,
result.symbolCode
)
binding.currentWeather.visibility = View.VISIBLE
}
is AppHelper.MetWeatherResult.Failure -> {
// No data (offline, no location): keep any cached text,
// otherwise hide the element.
if (binding.currentWeather.text.isNullOrEmpty()) {
binding.currentWeather.visibility = View.GONE
}
}
}
}
}
}
private fun observeBioAuthCheck(appInfo: AppInfo) { private fun observeBioAuthCheck(appInfo: AppInfo) {
if (!appInfo.lock) if (!appInfo.lock)
context.launchApp(appInfo) context.launchApp(appInfo)
@@ -607,6 +661,7 @@ class HomeFragment : Fragment(),
observeUserInterfaceSettings() observeUserInterfaceSettings()
observeFavoriteAppList() observeFavoriteAppList()
observeNotificationBadges() observeNotificationBadges()
if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
} }
override fun onAppClicked(appInfo: AppInfo) { override fun onAppClicked(appInfo: AppInfo) {

View File

@@ -95,6 +95,7 @@ class SettingsLookFeelFragment : Fragment(),
alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock
dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord
appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon
currentWeatherSwitchCompat.isChecked = preferenceHelper.showCurrentWeather
} }
} }
@@ -188,6 +189,12 @@ class SettingsLookFeelFragment : Fragment(),
val feedbackType = if (isChecked) "on" else "off" val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType) appHelper.triggerHapticFeedback(context, feedbackType)
} }
currentWeatherSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setShowCurrentWeather(isChecked)
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
} }
} }

View File

@@ -20,6 +20,10 @@ object Constants {
const val WIDGET_BATTERY = "WIDGET_BATTERY" const val WIDGET_BATTERY = "WIDGET_BATTERY"
const val WEATHER_PREFS = "EasyWeather.pref" const val WEATHER_PREFS = "EasyWeather.pref"
const val MET_WEATHER_PREFS = "met_weather_prefs"
const val MET_WEATHER_TIMESTAMP = "MET_WEATHER_TIMESTAMP"
const val MET_WEATHER_TEMPERATURE = "MET_WEATHER_TEMPERATURE"
const val MET_WEATHER_SYMBOL = "MET_WEATHER_SYMBOL"
const val WEATHER_RESPONSE = "WEATHER_RESPONSE" const val WEATHER_RESPONSE = "WEATHER_RESPONSE"
const val WEATHER_UNITS = "WEATHER_UNITS" const val WEATHER_UNITS = "WEATHER_UNITS"
const val LATITUDE = "LATITUDE" const val LATITUDE = "LATITUDE"
@@ -71,6 +75,7 @@ object Constants {
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK" const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS" const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS" const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS"
const val SHOW_CURRENT_WEATHER = "SHOW_CURRENT_WEATHER"
const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT" const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT" const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"

View File

@@ -0,0 +1,19 @@
package com.github.droidworksstudio.launcher.utils
import com.github.droidworksstudio.launcher.helper.weather.MetForecastResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
interface MetApiService {
@Headers(
"User-Agent: app.easy.launcher (https://gitea.haugesenspil.dk/jonas/EasyLauncher)"
)
@GET("compact")
fun getCompact(
@Query("lat") latitude: Double,
@Query("lon") longitude: Double
): Call<MetForecastResponse>
}

View File

@@ -0,0 +1,57 @@
package com.github.droidworksstudio.launcher.utils
/**
* Maps MET/Yr symbol codes to Nerd Font weather glyphs
* (nf-weather-* codepoints from the Weather Icons font embedded in
* JetBrainsMonoNerdFont). Rendered with R.font.jetbrains_mono_nf_weather.
*/
object MetSymbolMapper {
/**
* Returns the nf-weather glyph char for a MET `symbol_code`
* (e.g. "partlycloudy_day"). Unknown codes fall back to a cloud.
*/
fun glyphFor(symbolCode: String): Char {
val normalized = symbolCode.removeSuffix("_polartwilight")
val night = normalized.endsWith("_night")
val base = normalized
.removeSuffix("_day")
.removeSuffix("_night")
return when (base) {
"clearsky", "fair" -> if (night) '\uE32B' else '\uE30D'
"partlycloudy" -> if (night) '\uE379' else '\uE302'
"cloudy" -> '\uE312'
"fog" -> '\uE313'
"lightrain" -> '\uE31B'
"rain" -> '\uE318'
"heavyrain" -> '\uE319'
"lightrainshowers" -> if (night) '\uE328' else '\uE30B'
"rainshowers" -> if (night) '\uE326' else '\uE309'
"heavyrainshowers" -> if (night) '\uE324' else '\uE307'
"lightrainandthunder" -> '\uE315'
"rainandthunder" -> '\uE31D'
"heavyrainandthunder" -> '\uE31C'
"lightrainshowersandthunder" -> if (night) '\uE322' else '\uE305'
"rainshowersandthunder" -> if (night) '\uE32A' else '\uE30F'
"heavyrainshowersandthunder" -> if (night) '\uE329' else '\uE30E'
"sleet" -> '\uE316'
"lightssleetshowers", "sleetshowers" -> if (night) '\uE323' else '\uE306'
"heavysleetshowers" -> if (night) '\uE364' else '\uE362'
"sleetandthunder",
"lightsleetshowersandthunder",
"sleetshowersandthunder",
-> if (night) '\uE364' else '\uE362'
"lightssnow", "lightsnow", "snow" -> '\uE31A'
"heavysnow" -> '\uE35E'
"lightssnowshowers", "snowshowers" -> if (night) '\uE327' else '\uE30A'
"heavysnowshowers" -> if (night) '\uE361' else '\uE35F'
"snowandthunder",
"snowshowersandthunder",
-> if (night) '\uE367' else '\uE365'
"thunder" -> '\uE31D'
"wind" -> '\uE34B'
else -> '\uE312'
}
}
}

View File

@@ -49,7 +49,8 @@ class PreferenceViewModel @Inject constructor(
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData() private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData() private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData() private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val showNotificationDotsLiveData: MutableLiveData<Boolean> = MutableLiveData() val showNotificationDotsLiveData: MutableLiveData<Boolean> = MutableLiveData()
val showCurrentWeatherLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData() private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData() private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
@@ -303,6 +304,11 @@ class PreferenceViewModel @Inject constructor(
showNotificationDotsLiveData.postValue((preferenceHelper.showNotificationDots)) showNotificationDotsLiveData.postValue((preferenceHelper.showNotificationDots))
} }
fun setShowCurrentWeather(showCurrentWeather: Boolean) {
preferenceHelper.showCurrentWeather = showCurrentWeather
showCurrentWeatherLiveData.postValue((preferenceHelper.showCurrentWeather))
}
fun setAppLanguage(appLanguage: Constants.Language) { fun setAppLanguage(appLanguage: Constants.Language) {
preferenceHelper.appLanguage = appLanguage preferenceHelper.appLanguage = appLanguage
appLanguageLiveData.postValue((preferenceHelper.appLanguage)) appLanguageLiveData.postValue((preferenceHelper.appLanguage))

Binary file not shown.

View File

@@ -78,6 +78,14 @@
android:textSize="32sp" android:textSize="32sp"
android:visibility="gone" /> android:visibility="gone" />
<TextView
android:id="@+id/currentWeather"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="32sp"
android:visibility="gone" />
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat

View File

@@ -300,6 +300,37 @@
tools:ignore="TouchTargetSizeCheck" /> tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:ignore="MissingConstraints">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/currentWeather_text"
style="@style/TextDefaultStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="left|center"
android:text="@string/settings_display_current_weather"
android:textSize="@dimen/text_large"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/currentWeather_switchCompat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="0.7"
android:scaleY="0.8"
android:thumb="@drawable/shape_switch_thumb"
app:track="@drawable/selector_switch"
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat> </androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat <androidx.appcompat.widget.LinearLayoutCompat

View File

@@ -104,6 +104,7 @@
<string name="settings_display_alarm_clock">Show Alarm Clock</string> <string name="settings_display_alarm_clock">Show Alarm Clock</string>
<string name="settings_display_daily_word">Show Daily Word</string> <string name="settings_display_daily_word">Show Daily Word</string>
<string name="settings_display_app_icons">Show App Icons</string> <string name="settings_display_app_icons">Show App Icons</string>
<string name="settings_display_current_weather">Show Current Weather</string>
<string name="settings_display_automatic_keyboard">Auto Show Keyboard</string> <string name="settings_display_automatic_keyboard">Auto Show Keyboard</string>
<string name="settings_display_auto_open_apps">Auto Open Last App</string> <string name="settings_display_auto_open_apps">Auto Open Last App</string>
<string name="settings_home_alignment_bottom">Home Alignment Bottom</string> <string name="settings_home_alignment_bottom">Home Alignment Bottom</string>