Clock widget: smaller by default, with a choice of alignment and size

The first version filled its widget with the figures, which the owner found
huge. The figures are now measured against the icons: small is half an icon
tall, medium (the default) most of an icon, large taller than an icon, with the
widget's own height and width as the limit.

The owner chooses left, center or right and the size from the widget's edit
button. The options are a sheet over the home screen, kept per widget, and each
choice shows on the widget at once.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
TMCandClaude Fable 5.2 committed 2026-11-12 15:57:55 +01:00
1 parent 31ab0d52e1
commit 8e6b737894
7 files changed
+316 -42

No files matched your search

+7
View File
@@ -50,6 +50,13 @@
android:resource="@xml/flexera_clock_widget_info" />
</receiver>
<!-- The clock widget's options, a sheet over the home screen. -->
<activity
android:name="app.flexera.widget.ClockWidgetConfigActivity"
android:excludeFromRecents="true"
android:exported="false"
android:theme="@style/Theme.Transparent" />
<!-- Opens Android's photo picker for the picture widget; it has no screen of its own. -->
<activity
android:name="app.flexera.widget.PictureWidgetConfigActivity"
+9
View File
@@ -102,6 +102,15 @@
<!-- Its name in the list of widgets -->
<string name="flexera_clock_widget">Clock</string>
<string name="flexera_clock_widget_description">The time and the date, lined up with your icons</string>
<!-- The clock widget's options, reached from its edit button -->
<string name="flexera_clock_widget_alignment">Alignment</string>
<string name="flexera_clock_widget_left">Left</string>
<string name="flexera_clock_widget_center">Center</string>
<string name="flexera_clock_widget_right">Right</string>
<string name="flexera_clock_widget_size">Size</string>
<string name="flexera_clock_widget_small">Small</string>
<string name="flexera_clock_widget_medium">Medium</string>
<string name="flexera_clock_widget_large">Large</string>
<!-- The picture widget that comes with the launcher (app.flexera.widget) -->
<!-- Its name in the list of widgets -->
@@ -3,7 +3,8 @@
Flexera Launcher's clock widget (app.flexera.widget.ClockWidgetProvider). Four cells by two
when first placed, resizable from two by one upwards. The launcher draws it itself, so the
layouts named here are never shown on the home screen; previewLayout is what makes the widget
list ask for a live preview, which the launcher also draws. Nothing to set up.
list ask for a live preview, which the launcher also draws. Nothing has to be set up when it
is added (configuration_optional); its edit button opens the options for alignment and size.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
@@ -14,7 +15,9 @@
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:description="@string/flexera_clock_widget_description"
android:configure="app.flexera.widget.ClockWidgetConfigActivity"
android:initialLayout="@layout/flexera_clock_widget_placeholder"
android:previewLayout="@layout/flexera_clock_widget_placeholder"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen" />
android:widgetCategory="home_screen"
android:widgetFeatures="reconfigurable|configuration_optional" />
@@ -0,0 +1,141 @@
package app.flexera.widget
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.flexera.widget.ClockWidgetStore.Align
import app.flexera.widget.ClockWidgetStore.Size
import app.lawnchair.ui.theme.EdgeToEdge
import app.lawnchair.ui.theme.LawnchairTheme
import com.android.launcher3.R
/**
* 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.
*/
class ClockWidgetConfigActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val widget = intent?.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID,
) ?: AppWidgetManager.INVALID_APPWIDGET_ID
if (widget == AppWidgetManager.INVALID_APPWIDGET_ID) {
setResult(RESULT_CANCELED)
finish()
return
}
// The widget is fine with or without a visit here.
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widget))
setContent {
LawnchairTheme {
EdgeToEdge()
var options by remember { mutableStateOf(ClockWidgetStore.options(this, widget)) }
val change = { changed: ClockWidgetStore.Options ->
options = changed
ClockWidgetStore.set(this, widget, changed)
}
ModalBottomSheet(
onDismissRequest = { finish() },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surface,
// No dimming: the clock behind the sheet is what the choices are judged by.
scrimColor = Color.Transparent,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp),
) {
Text(
text = stringResource(id = R.string.flexera_clock_widget),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(20.dp))
Choice(
heading = R.string.flexera_clock_widget_alignment,
choices = listOf(
Align.LEFT to R.string.flexera_clock_widget_left,
Align.CENTER to R.string.flexera_clock_widget_center,
Align.RIGHT to R.string.flexera_clock_widget_right,
),
chosen = options.align,
onChoose = { change(options.copy(align = it)) },
)
Spacer(modifier = Modifier.height(20.dp))
Choice(
heading = R.string.flexera_clock_widget_size,
choices = listOf(
Size.SMALL to R.string.flexera_clock_widget_small,
Size.MEDIUM to R.string.flexera_clock_widget_medium,
Size.LARGE to R.string.flexera_clock_widget_large,
),
chosen = options.size,
onChoose = { change(options.copy(size = it)) },
)
}
}
}
}
}
}
/** A heading with one row of choices under it, of which one is taken. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> Choice(
@StringRes heading: Int,
choices: List<Pair<T, Int>>,
chosen: T,
onChoose: (T) -> Unit,
) {
Text(
text = stringResource(id = heading),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
choices.forEachIndexed { index, (value, label) ->
SegmentedButton(
selected = value == chosen,
onClick = { onChoose(value) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size),
) {
Text(text = stringResource(id = label))
}
}
}
}
@@ -2,15 +2,20 @@ package app.flexera.widget
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import com.android.launcher3.BuildConfig
/**
* The launcher's own clock widget: the time, large, with the date under it. Android knows it as
* an ordinary app widget; the launcher draws it itself with a [ClockWidgetView]. It keeps nothing
* and needs no setting up. See [FlexeraWidgets].
* an ordinary app widget; the launcher draws it itself with a [ClockWidgetView]. It needs no
* setting up; what the owner may choose for it is kept in [ClockWidgetStore]. See [FlexeraWidgets].
*/
class ClockWidgetProvider : AppWidgetProvider() {
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
appWidgetIds.forEach { ClockWidgetStore.delete(context, it) }
}
companion object {
@JvmField
val componentName = ComponentName(BuildConfig.APPLICATION_ID, ClockWidgetProvider::class.java.name)
@@ -0,0 +1,65 @@
package app.flexera.widget
import android.content.Context
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.
*/
object ClockWidgetStore {
/** Which side of the widget the time and the date stand on. */
enum class Align(val key: String) { LEFT("left"), CENTER("center"), RIGHT("right") }
/** 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)
/** Told, on the thread that made the change, that a widget's options are different now. */
fun interface Listener {
fun onClockOptionsChanged(appWidgetId: Int)
}
private val listeners = CopyOnWriteArraySet<Listener>()
fun addListener(listener: Listener) {
listeners.add(listener)
}
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
fun options(context: Context, appWidgetId: Int): Options {
val prefs = prefs(context)
val align = prefs.getString(ALIGN + appWidgetId, null)
val size = prefs.getString(SIZE + appWidgetId, null)
return Options(
align = Align.entries.firstOrNull { it.key == align } ?: Align.LEFT,
size = Size.entries.firstOrNull { it.key == size } ?: Size.MEDIUM,
)
}
fun set(context: Context, appWidgetId: Int, options: Options) {
prefs(context).edit()
.putString(ALIGN + appWidgetId, options.align.key)
.putString(SIZE + appWidgetId, options.size.key)
.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()
}
private fun prefs(context: Context): SharedPreferences =
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
private const val FILE = "flexera_clock_widget"
private const val ALIGN = "align_"
private const val SIZE = "size_"
}
@@ -1,5 +1,6 @@
package app.flexera.widget
import android.appwidget.AppWidgetManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -18,6 +19,8 @@ import android.util.AttributeSet
import android.util.Log
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import app.flexera.widget.ClockWidgetStore.Align
import app.flexera.widget.ClockWidgetStore.Size
import com.android.launcher3.R
import com.android.launcher3.util.Themes
import java.util.Calendar
@@ -27,25 +30,34 @@ import kotlin.math.max
import kotlin.math.min
/**
* What the clock widget ([ClockWidgetProvider]) looks like: the time in large light figures and
* the date under it, written straight onto the wallpaper in the colour of the icon labels.
* What the clock widget ([ClockWidgetProvider]) looks like: the time in light figures and the
* date under it, written straight onto the wallpaper in the colour of the icon labels.
*
* Like the picture widget it keeps to the box of the icons in its cells ([IconFrame]): the text
* starts at the left edge of the first column's icons and is centred between the top of the
* first row's icons and the bottom of the last row's. The figures grow with the widget, as far
* as its height and a share of its width allow.
* Like the picture widget it keeps to the box of the icons in its cells ([IconFrame]). The
* owner chooses which side of that box the text stands on, or its middle, and how large the
* 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.
*
* A tap opens the phone's clock app.
*/
class ClockWidgetView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
) : FrameLayout(context, attrs),
ClockWidgetStore.Listener {
private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
/** True while this is only a sample of the widget, in the list of widgets. */
private var sample = true
private var options = ClockWidgetStore.Options()
private val frame = RectF()
/** What the figures are measured against: the height of what is seen of an icon. */
private var iconHeight = 0f
private val ink = Rect()
private val now = Calendar.getInstance()
private var time = ""
@@ -79,7 +91,6 @@ class ClockWidgetView @JvmOverloads constructor(
// Says to Launcher3 that this widget shapes itself (see RoundedCornerEnforcement).
id = android.R.id.background
clipToOutline = true
colour()
}
@@ -109,9 +120,10 @@ class ClockWidgetView @JvmOverloads constructor(
/** Tells the view which widget it shows. Ids that Android never hands out mean a sample. */
fun bind(appWidgetId: Int) {
this.appWidgetId = appWidgetId
sample = appWidgetId <= 0
options = if (sample) ClockWidgetStore.Options() else ClockWidgetStore.options(context, appWidgetId)
colour()
invalidate()
if (sample) {
// In the list of widgets a tap belongs to the list.
setOnClickListener(null)
@@ -119,10 +131,20 @@ class ClockWidgetView @JvmOverloads constructor(
} else {
setOnClickListener { openClock() }
}
arrange()
invalidate()
}
override fun onClockOptionsChanged(appWidgetId: Int) {
if (sample || appWidgetId != this.appWidgetId) return
options = ClockWidgetStore.options(context, appWidgetId)
arrange()
invalidate()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ClockWidgetStore.addListener(this)
if (!listening) {
val changes = IntentFilter().apply {
addAction(Intent.ACTION_TIME_TICK)
@@ -134,10 +156,12 @@ class ClockWidgetView @JvmOverloads constructor(
listening = true
}
now.timeZone = TimeZone.getDefault()
if (!sample) options = ClockWidgetStore.options(context, appWidgetId)
tell()
}
override fun onDetachedFromWindow() {
ClockWidgetStore.removeListener(this)
if (listening) {
context.unregisterReceiver(clock)
listening = false
@@ -156,10 +180,12 @@ class ClockWidgetView @JvmOverloads constructor(
val aligned = IconFrame.of(this)
if (aligned != null) {
frame.set(aligned.left, aligned.top, aligned.right, aligned.bottom)
iconHeight = aligned.visibleIconSize
} else {
// In the list of widgets: the whole view, with a little air.
// In the list of widgets: the whole view with a little air, and a made-up icon.
val margin = min(width, height) * LOOSE_MARGIN_SHARE
frame.set(margin, margin, width - margin, height - margin)
iconHeight = frame.height() * LOOSE_ICON_SHARE
}
arrange()
}
@@ -183,16 +209,23 @@ class ClockWidgetView @JvmOverloads constructor(
invalidate()
}
/** Sizes the two lines for the frame and works out where they go. */
/** 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
val scaled = resources.displayMetrics.scaledDensity
// The date: a share of the height, within the sizes that read well, and never wider
// How tall the figures would like to be.
val wanted = iconHeight * when (options.size) {
Size.SMALL -> SMALL_IN_ICONS
Size.MEDIUM -> MEDIUM_IN_ICONS
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 = (height * DATE_HEIGHT_SHARE).coerceIn(DATE_MIN_SP * scaled, DATE_MAX_SP * scaled)
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) {
@@ -203,18 +236,14 @@ class ClockWidgetView @JvmOverloads constructor(
val dateHeight = dateMetrics.descent - dateMetrics.ascent
val gap = dateSize * GAP_IN_DATE_SIZES
// The time: as tall as the room above the date allows, and no wider than its share of
// the frame, measured on the figures themselves and not on the line they stand in.
// 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.
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 widthShare = if (width > height * WIDE_FROM_RATIO) TIME_WIDTH_SHARE_WIDE else 1f
val timeSize = min(
MEASURE_SIZE * (height - dateHeight - gap) / inkHeightAtMeasure,
MEASURE_SIZE * (width * widthShare) / inkWidthAtMeasure,
)
timePaint.textSize = max(1f, timeSize)
val figures = min(wanted, min(height - dateHeight - gap, 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.
@@ -223,14 +252,24 @@ class ClockWidgetView @JvmOverloads constructor(
timeBaseline = top - ink.top
dateBaseline = top + ink.height() + gap - dateMetrics.ascent
if (layoutDirection == LAYOUT_DIRECTION_RTL) {
timeX = frame.right - ink.right
dateX = frame.right - datePaint.measureText(date)
} else {
// The first figure's own edge on the frame's edge, not the space the font leaves
// before it.
timeX = frame.left - ink.left
dateX = frame.left
// 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)
when (options.align) {
Align.LEFT -> {
timeX = frame.left - ink.left
dateX = frame.left
}
Align.CENTER -> {
timeX = frame.centerX() - ink.exactCenterX()
dateX = frame.centerX() - dateLength / 2f
}
Align.RIGHT -> {
timeX = frame.right - ink.right
dateX = frame.right - dateLength
}
}
}
@@ -266,20 +305,25 @@ class ClockWidgetView @JvmOverloads constructor(
const val TIME_WEIGHT = 300
const val DATE_WEIGHT = 500
/** The date line's text size as a share of the frame's height, and its limits. */
const val DATE_HEIGHT_SHARE = 0.13f
const val DATE_MIN_SP = 13f
const val DATE_MAX_SP = 20f
/**
* How tall the figures are for each size, in icon heights. (The first version filled
* the widget, about 1.7 icons on a widget two rows high, which the owner found huge.)
*/
const val SMALL_IN_ICONS = 0.5f
const val MEDIUM_IN_ICONS = 0.8f
const val LARGE_IN_ICONS = 1.25f
/** The date's text size as a share of the figures' height, and its limits. */
const val DATE_IN_FIGURES = 0.26f
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.7f
/** In a frame wider than this many times its height, the time keeps to a share of the width. */
const val WIDE_FROM_RATIO = 1.6f
const val TIME_WIDTH_SHARE_WIDE = 0.72f
const val GAP_IN_DATE_SIZES = 0.6f
const val MEASURE_SIZE = 200f
const val LOOSE_MARGIN_SHARE = 0.12f
const val LOOSE_ICON_SHARE = 0.45f
const val SHADOW_RADIUS_DP = 3f
const val SHADOW_DROP_DP = 1f