Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f687957fb | ||
|
|
b79579a8dd |
No files matched your search
@@ -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)}" +
|
||||
"¤t=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_"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user