2 Commits
Author SHA1 Message Date
TMC 4f687957fb Release 0.11.0 2026-11-12 16:34:02 +01:00
TMCandClaude Fable 5.2 b79579a8dd Clock widget: weather line, font weight, and centring in the widget's own frame
Weather: a clock for which the owner picks a place shows a third line, the
temperature and a word for the sky there. The place is looked up by name in the
clock's options; the data comes from Open-Meteo, which needs no account. It is
off until a place is picked, asks at most every 45 minutes while a clock with
weather is in view, sends only the place's coordinates rounded to about a
kilometre, and needs no location permission. The About screen's note on what
goes online says so.

Font weight: thin, light (as before), regular or bold figures.

Centring: the text stood in the middle of the icons' box, which lies in the
upper part of the widget's cells, so it looked too high inside the outline shown
while editing. It now stands in the middle of the widget's own cells, measured
on what is seen of the text.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 16:33:49 +01:00
6 changed files with 554 additions and 60 deletions

No files matched your search

+31 -2
View File
@@ -68,8 +68,8 @@
<string name="flexera_about_share_title">Scan to get Flexera Launcher</string>
<string name="flexera_about_share_qr_description">QR code that downloads the latest version</string>
<string name="flexera_about_privacy">Privacy</string>
<string name="flexera_about_privacy_title">Nothing about you leaves your phone</string>
<string name="flexera_about_privacy_body">Flexera Launcher goes online for one thing: asking its release page whether a newer version exists, about once a day, and downloading that version when you ask. It sends nothing about you, your apps or your phone.</string>
<string name="flexera_about_privacy_title">What goes online</string>
<string name="flexera_about_privacy_body">Flexera Launcher goes online for two things. It asks its release page whether a newer version exists, about once a day, and downloads that version when you ask. And if you pick a place for the weather on a clock widget, it asks Open-Meteo, a weather service, for the weather there; that place is all it sends. Nothing else about you, your apps or your phone leaves it.</string>
<!-- Lawnchair's wording promised web results and grid settings that this app does not have -->
<string name="all_apps_device_search_hint">Search apps and more</string>
@@ -111,6 +111,35 @@
<string name="flexera_clock_widget_small">Small</string>
<string name="flexera_clock_widget_medium">Medium</string>
<string name="flexera_clock_widget_large">Large</string>
<string name="flexera_clock_widget_weight">Weight</string>
<string name="flexera_clock_widget_thin">Thin</string>
<string name="flexera_clock_widget_light">Light</string>
<string name="flexera_clock_widget_regular">Regular</string>
<string name="flexera_clock_widget_bold">Bold</string>
<string name="flexera_clock_widget_weather">Weather</string>
<string name="flexera_clock_widget_weather_off">Off. Look a place up to show its weather under the date.</string>
<!-- %1$s is a place, such as "Berlin, State of Berlin, Germany" -->
<string name="flexera_clock_widget_weather_on">Showing the weather in %1$s</string>
<string name="flexera_clock_widget_weather_place">Town or city</string>
<string name="flexera_clock_widget_weather_search">Search</string>
<string name="flexera_clock_widget_weather_nothing">No place of that name was found</string>
<string name="flexera_clock_widget_weather_failed">The weather service could not be reached. Check the connection and try again.</string>
<string name="flexera_clock_widget_weather_remove">Turn weather off</string>
<string name="flexera_clock_widget_weather_source">Weather data by Open-Meteo.com. The launcher asks it about the place you pick, about once an hour while the clock is in view, and sends nothing else.</string>
<!-- The clock widget's weather line. %1$d is the temperature, %2$s a word for the sky, such as "Overcast" -->
<string name="flexera_weather_line">%1$d° · %2$s</string>
<string name="flexera_weather_clear">Clear</string>
<string name="flexera_weather_mostly_clear">Mostly clear</string>
<string name="flexera_weather_partly_cloudy">Partly cloudy</string>
<string name="flexera_weather_overcast">Overcast</string>
<string name="flexera_weather_fog">Fog</string>
<string name="flexera_weather_drizzle">Drizzle</string>
<string name="flexera_weather_rain">Rain</string>
<string name="flexera_weather_freezing_rain">Freezing rain</string>
<string name="flexera_weather_snow">Snow</string>
<string name="flexera_weather_showers">Showers</string>
<string name="flexera_weather_snow_showers">Snow showers</string>
<string name="flexera_weather_thunderstorm">Thunderstorm</string>
<!-- The picture widget that comes with the launcher (app.flexera.widget) -->
<!-- Its name in the list of widgets -->
@@ -0,0 +1,177 @@
package app.flexera.widget
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.WorkerThread
import com.android.launcher3.R
import com.android.launcher3.util.Executors
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.util.Locale
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.math.roundToInt
import org.json.JSONObject
/**
* The weather line of the clock widget: the temperature and a word for the sky at a place the
* owner has chosen.
*
* The data comes from Open-Meteo, a weather service that needs no account. It is asked only for
* a place the owner picked by name in the clock's options, never for where the phone is, so the
* launcher needs no location permission; and only while a clock with weather is in view, at most
* every [FRESH_MS]. The place's coordinates, rounded to about a kilometre, are all that is sent.
* The last answer is kept, so that the line is there at once and survives a spell offline.
*/
object ClockWeather {
/** A place found by name. [detail] tells places of one name apart: region and country. */
data class Place(val name: String, val detail: String, val latitude: Double, val longitude: Double)
/** What the service said: degrees in the unit of the phone's country, and a WMO weather code. */
data class Reading(val degrees: Int, val code: Int, val fahrenheit: Boolean, val readAt: Long)
/** Told on the main thread that a new reading has come in. */
fun interface Listener {
fun onWeatherChanged()
}
private const val TAG = "ClockWeather"
private const val FILE = "flexera_clock_weather"
private const val FORECAST = "https://api.open-meteo.com/v1/forecast"
private const val SEARCH = "https://geocoding-api.open-meteo.com/v1/search"
private const val TIMEOUT_MS = 10_000
/** A reading younger than this is not asked for again. */
private const val FRESH_MS = 45 * 60 * 1000L
/** A reading older than this is no longer shown: stale weather is worse than none. */
private const val SHOWN_MS = 3 * 60 * 60 * 1000L
/** The countries that count degrees in Fahrenheit. */
private val fahrenheitCountries = setOf("US", "BS", "BZ", "KY", "PW", "FM", "MH", "LR")
private val listeners = CopyOnWriteArraySet<Listener>()
private val asking = HashSet<String>()
private val main = Handler(Looper.getMainLooper())
fun addListener(listener: Listener) {
listeners.add(listener)
}
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
/** The places of this name, the likeliest first. Asks the service; throws when it cannot. */
@WorkerThread
@Throws(IOException::class)
fun search(name: String): List<Place> {
val language = Locale.getDefault().language.ifEmpty { "en" }
val answer = get("$SEARCH?name=${URLEncoder.encode(name, "UTF-8")}&count=6&language=$language&format=json")
val results = JSONObject(answer).optJSONArray("results") ?: return emptyList()
return (0 until results.length()).map { index ->
val place = results.getJSONObject(index)
val detail = listOf(place.optString("admin1"), place.optString("country"))
.filter { it.isNotEmpty() }
.joinToString(", ")
Place(place.getString("name"), detail, place.getDouble("latitude"), place.getDouble("longitude"))
}
}
/** The last reading for a place, if there is one that is still worth showing. */
fun reading(context: Context, place: Place): Reading? {
val stored = prefs(context).getString(key(place), null)?.split('|') ?: return null
if (stored.size != 4) return null
val reading = Reading(
degrees = stored[0].toIntOrNull() ?: return null,
code = stored[1].toIntOrNull() ?: return null,
fahrenheit = stored[2] == "F",
readAt = stored[3].toLongOrNull() ?: return null,
)
val age = System.currentTimeMillis() - reading.readAt
return if (age in 0..SHOWN_MS && reading.fahrenheit == usesFahrenheit()) reading else null
}
/** Asks for a new reading in the background, unless the last one is fresh or one is on its way. */
fun refreshIfStale(context: Context, place: Place) {
val app = context.applicationContext
val key = key(place)
val last = reading(app, place)
if (last != null && System.currentTimeMillis() - last.readAt < FRESH_MS) return
synchronized(asking) {
if (!asking.add(key)) return
}
Executors.THREAD_POOL_EXECUTOR.execute {
try {
val fahrenheit = usesFahrenheit()
val answer = get(
"$FORECAST?latitude=${coordinate(place.latitude)}&longitude=${coordinate(place.longitude)}" +
"&current=temperature_2m,weather_code" +
"&temperature_unit=${if (fahrenheit) "fahrenheit" else "celsius"}",
)
val current = JSONObject(answer).getJSONObject("current")
val degrees = current.getDouble("temperature_2m").roundToInt()
val code = current.getInt("weather_code")
prefs(app).edit()
.putString(key, "$degrees|$code|${if (fahrenheit) "F" else "C"}|${System.currentTimeMillis()}")
.apply()
main.post { listeners.forEach { it.onWeatherChanged() } }
} catch (e: Exception) {
// Offline, or the service is down: the last reading stays until it is too old.
Log.w(TAG, "No weather this time", e)
} finally {
synchronized(asking) { asking.remove(key) }
}
}
}
/** The line as the widget shows it, such as "6° · Overcast". */
fun line(context: Context, reading: Reading): String =
context.getString(R.string.flexera_weather_line, reading.degrees, context.getString(word(reading.code)))
/** A word for a WMO weather code (the codes Open-Meteo uses). */
private fun word(code: Int): Int = when (code) {
0 -> R.string.flexera_weather_clear
1 -> R.string.flexera_weather_mostly_clear
2 -> R.string.flexera_weather_partly_cloudy
3 -> R.string.flexera_weather_overcast
45, 48 -> R.string.flexera_weather_fog
51, 53, 55 -> R.string.flexera_weather_drizzle
56, 57, 66, 67 -> R.string.flexera_weather_freezing_rain
61, 63, 65 -> R.string.flexera_weather_rain
71, 73, 75, 77 -> R.string.flexera_weather_snow
80, 81, 82 -> R.string.flexera_weather_showers
85, 86 -> R.string.flexera_weather_snow_showers
95, 96, 99 -> R.string.flexera_weather_thunderstorm
else -> R.string.flexera_weather_overcast
}
private fun usesFahrenheit(): Boolean = Locale.getDefault().country in fahrenheitCountries
/** Two decimals, about a kilometre: all a forecast needs, and no more exact than that. */
private fun coordinate(value: Double): String = String.format(Locale.ROOT, "%.2f", value)
private fun key(place: Place): String = "${coordinate(place.latitude)},${coordinate(place.longitude)}"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private fun get(address: String): String {
val connection = URL(address).openConnection() as HttpURLConnection
try {
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
connection.setRequestProperty("Accept", "application/json")
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("The weather service answered HTTP ${connection.responseCode}")
}
return connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
}
}
}
@@ -7,38 +7,62 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import app.flexera.widget.ClockWidgetStore.Align
import app.flexera.widget.ClockWidgetStore.Size
import app.flexera.widget.ClockWidgetStore.Weight
import app.lawnchair.ui.theme.EdgeToEdge
import app.lawnchair.ui.theme.LawnchairTheme
import com.android.launcher3.R
import java.util.TimeZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The clock widget's options: where its text stands and how large its figures are. Reached from
* the widget's edit button. It is a sheet over the home screen, which stays in view, and every
* choice shows on the widget at once; closing the sheet is all there is to finishing.
* The clock widget's options: where its text stands, how large and how heavy its figures are,
* and the place its weather line is for. Reached from the widget's edit button. It is a sheet
* over the home screen, which stays in view, and every choice shows on the widget at once;
* closing the sheet is all there is to finishing.
*/
class ClockWidgetConfigActivity : ComponentActivity() {
@@ -77,6 +101,8 @@ class ClockWidgetConfigActivity : ComponentActivity() {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.imePadding()
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
) {
Text(
@@ -105,6 +131,23 @@ class ClockWidgetConfigActivity : ComponentActivity() {
chosen = options.size,
onChoose = { change(options.copy(size = it)) },
)
Spacer(modifier = Modifier.height(20.dp))
Choice(
heading = R.string.flexera_clock_widget_weight,
choices = listOf(
Weight.THIN to R.string.flexera_clock_widget_thin,
Weight.LIGHT to R.string.flexera_clock_widget_light,
Weight.REGULAR to R.string.flexera_clock_widget_regular,
Weight.BOLD to R.string.flexera_clock_widget_bold,
),
chosen = options.weight,
onChoose = { change(options.copy(weight = it)) },
)
Spacer(modifier = Modifier.height(20.dp))
WeatherChoice(
place = options.weather,
onChoose = { change(options.copy(weather = it)) },
)
}
}
}
@@ -112,6 +155,16 @@ class ClockWidgetConfigActivity : ComponentActivity() {
}
}
@Composable
private fun Heading(@StringRes text: Int) {
Text(
text = stringResource(id = text),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
}
/** A heading with one row of choices under it, of which one is taken. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -121,12 +174,7 @@ private fun <T> Choice(
chosen: T,
onChoose: (T) -> Unit,
) {
Text(
text = stringResource(id = heading),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Heading(heading)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
choices.forEachIndexed { index, (value, label) ->
SegmentedButton(
@@ -134,8 +182,125 @@ private fun <T> Choice(
onClick = { onChoose(value) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size),
) {
Text(text = stringResource(id = label))
Text(text = stringResource(id = label), maxLines = 1)
}
}
}
}
private enum class Search { IDLE, RUNNING, NOTHING, FAILED }
/**
* The weather line: off until the owner looks a place up by name and picks it from what the
* weather service finds. The field starts with the city of the phone's time zone as a guess.
*/
@Composable
private fun WeatherChoice(place: ClockWeather.Place?, onChoose: (ClockWeather.Place?) -> Unit) {
val scope = rememberCoroutineScope()
var name by rememberSaveable { mutableStateOf(place?.name ?: timeZoneCity()) }
var found by remember { mutableStateOf(emptyList<ClockWeather.Place>()) }
var search by remember { mutableStateOf(Search.IDLE) }
val keyboard = LocalSoftwareKeyboardController.current
val focus = LocalFocusManager.current
val look = {
val wanted = name.trim()
if (wanted.isNotEmpty() && search != Search.RUNNING) {
// The places found are listed under the field, where the keyboard would cover them.
keyboard?.hide()
focus.clearFocus()
search = Search.RUNNING
scope.launch {
val result = withContext(Dispatchers.IO) { runCatching { ClockWeather.search(wanted) } }
found = result.getOrDefault(emptyList())
search = when {
result.isFailure -> Search.FAILED
found.isEmpty() -> Search.NOTHING
else -> Search.IDLE
}
}
}
}
Heading(R.string.flexera_clock_widget_weather)
Text(
text = if (place == null) {
stringResource(id = R.string.flexera_clock_widget_weather_off)
} else {
stringResource(id = R.string.flexera_clock_widget_weather_on, placeLabel(place))
},
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(text = stringResource(id = R.string.flexera_clock_widget_weather_place)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { look() }),
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(12.dp))
Button(onClick = { look() }) {
Text(text = stringResource(id = R.string.flexera_clock_widget_weather_search))
}
}
when (search) {
Search.RUNNING -> LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
Search.NOTHING -> Note(R.string.flexera_clock_widget_weather_nothing)
Search.FAILED -> Note(R.string.flexera_clock_widget_weather_failed)
Search.IDLE -> Unit
}
found.forEach { candidate ->
Text(
text = placeLabel(candidate),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.clickable {
onChoose(candidate)
name = candidate.name
found = emptyList()
}
.padding(vertical = 14.dp),
)
}
if (place != null) {
TextButton(onClick = { onChoose(null) }) {
Text(text = stringResource(id = R.string.flexera_clock_widget_weather_remove))
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.flexera_clock_widget_weather_source),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun Note(@StringRes text: Int) {
Text(
text = stringResource(id = text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
private fun placeLabel(place: ClockWeather.Place): String =
if (place.detail.isEmpty()) place.name else "${place.name}, ${place.detail}"
/** The city a time zone is named after ("Europe/Zagreb" gives "Zagreb"); empty for zones without one. */
private fun timeZoneCity(): String {
val id = TimeZone.getDefault().id
return if ('/' in id) id.substringAfterLast('/').replace('_', ' ') else ""
}
@@ -5,8 +5,9 @@ import android.content.SharedPreferences
import java.util.concurrent.CopyOnWriteArraySet
/**
* What the owner has chosen for each clock widget on the home screen: where its text stands and
* how large its figures are. Kept per widget, so that two clocks can differ.
* What the owner has chosen for each clock widget on the home screen: where its text stands, how
* large and how heavy its figures are, and the place its weather line is for, if it has one.
* Kept per widget, so that two clocks can differ.
*/
object ClockWidgetStore {
@@ -16,7 +17,21 @@ object ClockWidgetStore {
/** How large the figures are, measured against the icons (see ClockWidgetView). */
enum class Size(val key: String) { SMALL("small"), MEDIUM("medium"), LARGE("large") }
data class Options(val align: Align = Align.LEFT, val size: Size = Size.MEDIUM)
/** How heavy the figures are drawn; [value] is the font weight, from 100 to 900. */
enum class Weight(val key: String, val value: Int) {
THIN("thin", 100),
LIGHT("light", 300),
REGULAR("regular", 400),
BOLD("bold", 700),
}
data class Options(
val align: Align = Align.LEFT,
val size: Size = Size.MEDIUM,
val weight: Weight = Weight.LIGHT,
/** The place of the weather line; null for a clock without weather, which is how it starts. */
val weather: ClockWeather.Place? = null,
)
/** Told, on the thread that made the change, that a widget's options are different now. */
fun interface Listener {
@@ -37,23 +52,49 @@ object ClockWidgetStore {
val prefs = prefs(context)
val align = prefs.getString(ALIGN + appWidgetId, null)
val size = prefs.getString(SIZE + appWidgetId, null)
val weight = prefs.getString(WEIGHT + appWidgetId, null)
val place = prefs.getString(PLACE + appWidgetId, null)
return Options(
align = Align.entries.firstOrNull { it.key == align } ?: Align.LEFT,
size = Size.entries.firstOrNull { it.key == size } ?: Size.MEDIUM,
weight = Weight.entries.firstOrNull { it.key == weight } ?: Weight.LIGHT,
weather = place?.let {
ClockWeather.Place(
name = it,
detail = prefs.getString(PLACE_DETAIL + appWidgetId, "").orEmpty(),
latitude = prefs.getFloat(LATITUDE + appWidgetId, 0f).toDouble(),
longitude = prefs.getFloat(LONGITUDE + appWidgetId, 0f).toDouble(),
)
},
)
}
fun set(context: Context, appWidgetId: Int, options: Options) {
prefs(context).edit()
val edit = prefs(context).edit()
.putString(ALIGN + appWidgetId, options.align.key)
.putString(SIZE + appWidgetId, options.size.key)
.apply()
.putString(WEIGHT + appWidgetId, options.weight.key)
val place = options.weather
if (place == null) {
edit.remove(PLACE + appWidgetId).remove(PLACE_DETAIL + appWidgetId)
.remove(LATITUDE + appWidgetId).remove(LONGITUDE + appWidgetId)
} else {
edit.putString(PLACE + appWidgetId, place.name)
.putString(PLACE_DETAIL + appWidgetId, place.detail)
.putFloat(LATITUDE + appWidgetId, place.latitude.toFloat())
.putFloat(LONGITUDE + appWidgetId, place.longitude.toFloat())
}
edit.apply()
listeners.forEach { it.onClockOptionsChanged(appWidgetId) }
}
/** Forgets the options of a widget that left the home screen. */
fun delete(context: Context, appWidgetId: Int) {
prefs(context).edit().remove(ALIGN + appWidgetId).remove(SIZE + appWidgetId).apply()
val edit = prefs(context).edit()
for (key in listOf(ALIGN, SIZE, WEIGHT, PLACE, PLACE_DETAIL, LATITUDE, LONGITUDE)) {
edit.remove(key + appWidgetId)
}
edit.apply()
}
private fun prefs(context: Context): SharedPreferences =
@@ -62,4 +103,9 @@ object ClockWidgetStore {
private const val FILE = "flexera_clock_widget"
private const val ALIGN = "align_"
private const val SIZE = "size_"
private const val WEIGHT = "weight_"
private const val PLACE = "place_"
private const val PLACE_DETAIL = "place_detail_"
private const val LATITUDE = "latitude_"
private const val LONGITUDE = "longitude_"
}
+118 -41
View File
@@ -38,7 +38,11 @@ import kotlin.math.min
* figures are ([ClockWidgetStore], set from the widget's edit button). The sizes are measured
* against the icons, so that the clock keeps its proportion to them on any grid: small figures
* are about half an icon tall, medium ones most of an icon, large ones taller than an icon. The
* widget's own height and width are the limit in every case.
* widget's own height and width are the limit in every case. Up and down the text stands in the
* middle of the widget's own cells, which is what the outline shows while the widget is edited.
*
* A clock for which the owner has picked a place has a third line, the weather there
* ([ClockWeather]).
*
* A tap opens the phone's clock app.
*/
@@ -46,7 +50,8 @@ class ClockWidgetView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs),
ClockWidgetStore.Listener {
ClockWidgetStore.Listener,
ClockWeather.Listener {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
@@ -63,20 +68,27 @@ class ClockWidgetView @JvmOverloads constructor(
private var time = ""
private var date = ""
/** The weather line; empty for a clock without weather or while none is known. */
private var weather = ""
private val timePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = weight(TIME_WEIGHT, "sans-serif-light")
// Figures of one width, so that the time does not shift sideways from minute to minute.
fontFeatureSettings = "tnum"
}
private val datePaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = weight(DATE_WEIGHT, "sans-serif-medium")
typeface = weight(DATE_WEIGHT)
}
private val weatherPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = weight(WEATHER_WEIGHT)
}
// Where the two lines go; worked out in arrange().
// Where the lines go; worked out in arrange().
private var timeX = 0f
private var timeBaseline = 0f
private var dateX = 0f
private var dateBaseline = 0f
private var weatherX = 0f
private var weatherBaseline = 0f
private var listening = false
private val clock = object : BroadcastReceiver() {
@@ -105,16 +117,44 @@ class ClockWidgetView @JvmOverloads constructor(
} else {
Themes.getAttrColor(context, R.attr.workspaceTextColor)
}
timePaint.color = color
datePaint.color = color
val paints = listOf(timePaint, datePaint, weatherPaint)
paints.forEach { it.color = color }
// Light text on a wallpaper gets the soft shadow the icon labels have.
if (!sample && Color.luminance(color) > 0.5f) {
val density = resources.displayMetrics.density
timePaint.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
datePaint.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
paints.forEach {
it.setShadowLayer(SHADOW_RADIUS_DP * density, 0f, SHADOW_DROP_DP * density, SHADOW_COLOR)
}
} else {
timePaint.clearShadowLayer()
datePaint.clearShadowLayer()
paints.forEach { it.clearShadowLayer() }
}
}
/** Takes the owner's choices for this widget: how the figures are drawn, and the weather. */
private fun takeOptions() {
options = if (sample) ClockWidgetStore.Options() else ClockWidgetStore.options(context, appWidgetId)
timePaint.typeface = weight(options.weight.value)
readWeather()
}
/** Shows the last known weather for the chosen place and asks for newer if it is due. */
private fun readWeather() {
val place = options.weather
weather = if (place == null) {
""
} else {
if (isAttachedToWindow && !sample) ClockWeather.refreshIfStale(context, place)
ClockWeather.reading(context, place)?.let { ClockWeather.line(context, it) }.orEmpty()
}
describe()
}
override fun onWeatherChanged() {
val before = weather
readWeather()
if (weather != before) {
arrange()
invalidate()
}
}
@@ -122,7 +162,7 @@ class ClockWidgetView @JvmOverloads constructor(
fun bind(appWidgetId: Int) {
this.appWidgetId = appWidgetId
sample = appWidgetId <= 0
options = if (sample) ClockWidgetStore.Options() else ClockWidgetStore.options(context, appWidgetId)
takeOptions()
colour()
if (sample) {
// In the list of widgets a tap belongs to the list.
@@ -137,7 +177,7 @@ class ClockWidgetView @JvmOverloads constructor(
override fun onClockOptionsChanged(appWidgetId: Int) {
if (sample || appWidgetId != this.appWidgetId) return
options = ClockWidgetStore.options(context, appWidgetId)
takeOptions()
arrange()
invalidate()
}
@@ -145,6 +185,7 @@ class ClockWidgetView @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ClockWidgetStore.addListener(this)
ClockWeather.addListener(this)
if (!listening) {
val changes = IntentFilter().apply {
addAction(Intent.ACTION_TIME_TICK)
@@ -156,12 +197,14 @@ class ClockWidgetView @JvmOverloads constructor(
listening = true
}
now.timeZone = TimeZone.getDefault()
if (!sample) options = ClockWidgetStore.options(context, appWidgetId)
takeOptions()
tell()
arrange()
}
override fun onDetachedFromWindow() {
ClockWidgetStore.removeListener(this)
ClockWeather.removeListener(this)
if (listening) {
context.unregisterReceiver(clock)
listening = false
@@ -171,8 +214,12 @@ class ClockWidgetView @JvmOverloads constructor(
override fun onVisibilityAggregated(isVisible: Boolean) {
super.onVisibilityAggregated(isVisible)
// Android does not announce the minutes to an app in the background; catch up.
if (isVisible) tell()
// Android does not announce the minutes to an app in the background; catch up, with the
// time and with the weather.
if (isVisible) {
tell()
onWeatherChanged()
}
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
@@ -204,17 +251,24 @@ class ClockWidgetView @JvmOverloads constructor(
if (newTime == time && newDate == date) return
time = newTime
date = newDate
contentDescription = "$time, $date"
describe()
arrange()
invalidate()
}
/** What a screen reader says for the widget: everything it shows. */
private fun describe() {
contentDescription = listOf(time, date, weather).filter { it.isNotEmpty() }.joinToString(", ")
}
/** Sizes the two lines for the frame and the owner's choices, and works out where they go. */
private fun arrange() {
val width = frame.width()
val height = frame.height()
if (width <= 0f || height <= 0f || time.isEmpty()) return
if (width <= 0f || frame.height() <= 0f || time.isEmpty()) return
val scaled = resources.displayMetrics.scaledDensity
// Up and down, the room is the widget's own cells; in the list of widgets, the frame.
val roomTop = if (sample) frame.top else 0f
val roomHeight = if (sample) frame.height() else height.toFloat()
// How tall the figures would like to be.
val wanted = iconHeight * when (options.size) {
@@ -223,52 +277,62 @@ class ClockWidgetView @JvmOverloads constructor(
Size.LARGE -> LARGE_IN_ICONS
}
// The date goes with the figures, within the sizes that read well, and is never wider
// than the frame.
var dateSize = (wanted * DATE_IN_FIGURES).coerceIn(DATE_MIN_SP * scaled, DATE_MAX_SP * scaled)
datePaint.textSize = dateSize
val dateWidth = datePaint.measureText(date)
if (dateWidth > width) {
dateSize *= width / dateWidth
datePaint.textSize = dateSize
// The lines under the figures go with them in size, within the sizes that read well, and
// are never wider than the frame.
var smallSize = (wanted * DATE_IN_FIGURES).coerceIn(DATE_MIN_SP * scaled, DATE_MAX_SP * scaled)
datePaint.textSize = smallSize
weatherPaint.textSize = smallSize
val widest = max(datePaint.measureText(date), weatherPaint.measureText(weather))
if (widest > width) {
smallSize *= width / widest
datePaint.textSize = smallSize
weatherPaint.textSize = smallSize
}
val dateMetrics = datePaint.fontMetrics
val dateHeight = dateMetrics.descent - dateMetrics.ascent
val gap = dateSize * GAP_IN_DATE_SIZES
// What is seen of a line of words is its capitals' height; the room a font keeps above
// and below them would push the text off the middle.
val smallHeight = smallSize * CAPITALS_IN_TEXT_SIZE
val gap = smallSize * GAP_IN_DATE_SIZES
val lineGap = smallSize * LINE_GAP_IN_DATE_SIZES
val under = gap + smallHeight + if (weather.isEmpty()) 0f else lineGap + smallHeight
// The figures: as tall as wanted, unless the room above the date or the frame's width
// is less. Measured on the figures themselves, not on the line they stand in.
// The figures: as tall as wanted, unless the room above the other lines or the frame's
// width is less. Measured on the figures themselves, not on the line they stand in.
timePaint.textSize = MEASURE_SIZE
timePaint.getTextBounds(time, 0, time.length, ink)
val inkHeightAtMeasure = max(1, ink.height()).toFloat()
val inkWidthAtMeasure = max(1, ink.width()).toFloat()
val figures = min(wanted, min(height - dateHeight - gap, width * inkHeightAtMeasure / inkWidthAtMeasure))
val figures = min(wanted, min(roomHeight - under, width * inkHeightAtMeasure / inkWidthAtMeasure))
timePaint.textSize = max(1f, MEASURE_SIZE * figures / inkHeightAtMeasure)
timePaint.getTextBounds(time, 0, time.length, ink)
// Both lines together, centred between the top and the bottom of the frame.
val block = ink.height() + gap + dateHeight
val top = frame.top + (height - block) / 2f
// All lines together, in the middle of the room.
val block = ink.height() + under
val top = roomTop + (roomHeight - block) / 2f
timeBaseline = top - ink.top
dateBaseline = top + ink.height() + gap - dateMetrics.ascent
dateBaseline = top + ink.height() + gap + smallHeight
weatherBaseline = dateBaseline + lineGap + smallHeight
// Sideways it is the figures' own edges that meet the frame's, not the space the font
// leaves beside them.
val dateLength = datePaint.measureText(date)
val weatherLength = weatherPaint.measureText(weather)
when (options.align) {
Align.LEFT -> {
timeX = frame.left - ink.left
dateX = frame.left
weatherX = frame.left
}
Align.CENTER -> {
timeX = frame.centerX() - ink.exactCenterX()
dateX = frame.centerX() - dateLength / 2f
weatherX = frame.centerX() - weatherLength / 2f
}
Align.RIGHT -> {
timeX = frame.right - ink.right
dateX = frame.right - dateLength
weatherX = frame.right - weatherLength
}
}
}
@@ -278,6 +342,7 @@ class ClockWidgetView @JvmOverloads constructor(
if (frame.isEmpty || time.isEmpty()) return
canvas.drawText(time, timeX, timeBaseline, timePaint)
canvas.drawText(date, dateX, dateBaseline, datePaint)
if (weather.isNotEmpty()) canvas.drawText(weather, weatherX, weatherBaseline, weatherPaint)
}
private fun openClock() {
@@ -292,18 +357,26 @@ class ClockWidgetView @JvmOverloads constructor(
}
}
private fun weight(weight: Int, family: String): Typeface =
/** The phone's own font at a weight from 100 (thin) to 900. Android 8 only knows a few. */
private fun weight(weight: Int): Typeface =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Typeface.create(Typeface.DEFAULT, weight, false)
} else {
val family = when {
weight <= 100 -> "sans-serif-thin"
weight <= 300 -> "sans-serif-light"
weight <= 400 -> "sans-serif"
weight <= 500 -> "sans-serif-medium"
else -> "sans-serif-black"
}
Typeface.create(family, Typeface.NORMAL)
}
private companion object {
const val TAG = "ClockWidgetView"
const val TIME_WEIGHT = 300
const val DATE_WEIGHT = 500
const val WEATHER_WEIGHT = 400
/**
* How tall the figures are for each size, in icon heights. (The first version filled
@@ -318,8 +391,12 @@ class ClockWidgetView @JvmOverloads constructor(
const val DATE_MIN_SP = 12f
const val DATE_MAX_SP = 18f
/** The space between the figures and the date, in date text sizes. */
const val GAP_IN_DATE_SIZES = 0.6f
/** How tall a font's capitals are, as a share of its text size. */
const val CAPITALS_IN_TEXT_SIZE = 0.72f
/** The space between the figures and the date, and between the lines under them, in date text sizes. */
const val GAP_IN_DATE_SIZES = 0.8f
const val LINE_GAP_IN_DATE_SIZES = 0.65f
const val MEASURE_SIZE = 200f
const val LOOSE_MARGIN_SHARE = 0.12f
+1 -1
View File
@@ -2,4 +2,4 @@
# build.gradle reads this and derives the Android version code from it
# (major * 10000 + minor * 100 + patch), so the name and the code can never disagree.
# flexera/release.ps1 raises it for every release.
versionName=0.10.0
versionName=0.11.0