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.data.dao.AppInfoDAO
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.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.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import kotlinx.coroutines.flow.first
import kotlin.math.roundToInt
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.net.UnknownHostException
@@ -552,4 +556,89 @@ class AppHelper @Inject constructor() {
// Data class to hold cached weather data along with timestamp
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)
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
get() {
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.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
@@ -57,6 +58,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -114,6 +116,12 @@ class HomeFragment : Fragment(),
setupRecyclerView()
observeSwipeTouchListener()
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")
@@ -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 {
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) {
if (!appInfo.lock)
context.launchApp(appInfo)
@@ -607,6 +661,7 @@ class HomeFragment : Fragment(),
observeUserInterfaceSettings()
observeFavoriteAppList()
observeNotificationBadges()
if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
}
override fun onAppClicked(appInfo: AppInfo) {

View File

@@ -95,6 +95,7 @@ class SettingsLookFeelFragment : Fragment(),
alarmClockSwitchCompat.isChecked = preferenceHelper.showAlarmClock
dailyWordSwitchCompat.isChecked = preferenceHelper.showDailyWord
appIconsSwitchCompat.isChecked = preferenceHelper.showAppIcon
currentWeatherSwitchCompat.isChecked = preferenceHelper.showCurrentWeather
}
}
@@ -188,6 +189,12 @@ class SettingsLookFeelFragment : Fragment(),
val feedbackType = if (isChecked) "on" else "off"
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 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_UNITS = "WEATHER_UNITS"
const val LATITUDE = "LATITUDE"
@@ -71,6 +75,7 @@ object Constants {
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
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_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 lockSettingsLiveData: 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 appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
@@ -303,6 +304,11 @@ class PreferenceViewModel @Inject constructor(
showNotificationDotsLiveData.postValue((preferenceHelper.showNotificationDots))
}
fun setShowCurrentWeather(showCurrentWeather: Boolean) {
preferenceHelper.showCurrentWeather = showCurrentWeather
showCurrentWeatherLiveData.postValue((preferenceHelper.showCurrentWeather))
}
fun setAppLanguage(appLanguage: Constants.Language) {
preferenceHelper.appLanguage = appLanguage
appLanguageLiveData.postValue((preferenceHelper.appLanguage))

Binary file not shown.

View File

@@ -78,6 +78,14 @@
android:textSize="32sp"
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

View File

@@ -300,6 +300,37 @@
tools:ignore="TouchTargetSizeCheck" />
</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

View File

@@ -104,6 +104,7 @@
<string name="settings_display_alarm_clock">Show Alarm Clock</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_current_weather">Show Current Weather</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_home_alignment_bottom">Home Alignment Bottom</string>