Thumb-friendly drawer: compact list, Niagara-style alphabet, dark backdrop; QR code on About

Requests from the owner on 12 November 2026, after using 0.3.1 on the Pixel 9a, with a
photo of Niagara Launcher's alphabet as the reference.

Drawer list
- Rows are 50dp with 34dp icons and 15sp names (were 64dp, 46dp, 17sp); letter headers are
  34dp. A row grows with a large system font so a notification preview still fits.

Alphabet
- Letters sit 20dp apart at most (was 30dp). The strip keeps a stretch of edge as tall as
  26 letters, low on the edge with its end 40dp above the navigation bar, and centres the
  letters that exist in it. It used to spread over the whole edge, which put "A" out of a
  thumb's reach.
- The wave follows Niagara's: letters keep their size, the wave is wide (104dp) so about
  five letters on each side of the finger curve around the thumb, and the selected letter
  is shown large in a light bubble just beyond the wave's peak. This replaces the enlarged
  letter on a dark spot from 0.3.1, which collided with its neighbours at this spacing.
- The list dims a little during any scrub, more where the wave lies over it.

Backdrop
- The drawer's scrim is black at 78% in every theme (AllAppsState). Lawnchair's grey at 40%
  left a light grey behind white names over a light wallpaper. LawnchairScrimView judges the
  status bar icons by the scrim's own colour again, since that colour is now always dark.

Scrub from the home screen (PRD AD-10)
- On by default, the owner's decision; this closes the PRD's open question.
- It opens the drawer in 120 ms instead of the state manager's 600 ms, like swipe up and hold.
- A touch that lifts within 200 ms was a flick and opens the plain list.

Fix: undoing a scrub that was not meant
- 0.3.1 put the list back to "where it was", read from the list's rows. A closed drawer has
  been told to return to the top but lays its rows out only when it opens, so the rows
  still reported the place the drawer was last left at, and a quick swipe reopened it
  there. The list now goes where a plainly opened drawer stands.

About
- A QR code for the release page, generated on the phone with ZXing core (new dependency),
  black on white in every theme, with the address under it.

Checked on the Android 17 emulator with timed touches, a thumb-sized shape over the
screenshots, a 1.6 font scale, and by decoding the QR code from a screenshot.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
TMCandClaude Fable 5.2 committed 2026-11-12 09:00:22 +01:00
1 parent 60d00afcfd
commit 8aba905d9c
12 files changed
+281 -97

No files matched your search

+2
View File
@@ -494,6 +494,8 @@ dependencies {
implementation libs.kdrag0n.colorkt
implementation libs.coil.compose
implementation libs.xdrop.fuzzywuzzy
// Flexera Launcher: encodes the QR code on the About screen. Nothing else of it is used.
implementation libs.zxing.core
implementation libs.bundles.opto
implementation libs.androidx.datastore.preferences
+34 -16
View File
@@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Drawer list (PRD AD-2, AD-3). Rows are sized for thumb taps; 48dp is Android's minimum target. -->
<dimen name="flexera_drawer_row_height">64dp</dimen>
<!-- Smaller than a home-screen icon, so rows have air between them. -->
<dimen name="flexera_drawer_row_icon_size">46dp</dimen>
<dimen name="flexera_drawer_row_icon_gap">20dp</dimen>
<dimen name="flexera_drawer_row_text_size">17sp</dimen>
<!-- Drawer list (PRD AD-2, AD-3). A compact list, at the owner's request: more apps within a
thumb's reach. The row stays just above 48dp, Android's minimum for a tap target.
(First version: 64dp rows, 46dp icons, 17sp names.) -->
<dimen name="flexera_drawer_row_height">50dp</dimen>
<!-- Well under a home-screen icon, so rows have air between them. -->
<dimen name="flexera_drawer_row_icon_size">34dp</dimen>
<dimen name="flexera_drawer_row_icon_gap">16dp</dimen>
<dimen name="flexera_drawer_row_text_size">15sp</dimen>
<!-- Row padding on the edge without the wave alphabet. The search bar lines up with it. -->
<dimen name="flexera_drawer_row_padding_start">24dp</dimen>
<!-- Row padding on the wave alphabet's edge, which is the end edge unless the owner moves it
@@ -18,8 +20,15 @@
<!-- Where the letters rest, measured from the edge. -->
<dimen name="flexera_wave_edge_inset">16dp</dimen>
<dimen name="flexera_wave_letter_size">12sp</dimen>
<!-- Letters never sit further apart than this, however few there are. -->
<dimen name="flexera_wave_max_spacing">30dp</dimen>
<!-- Letters never sit further apart than this, however few there are. Close together, a
little closer than Niagara Launcher's, so a whole alphabet is about 83 mm tall and fits
the stretch of edge a thumb covers without shifting the grip. (First version: 30dp,
which spread the alphabet over the whole edge.) -->
<dimen name="flexera_wave_max_spacing">20dp</dimen>
<!-- The stretch of edge kept for the alphabet is as tall as 26 letters and sits low: its end
this far above the navigation bar (on top of the vertical margin). The letters that
exist are centred in it. -->
<dimen name="flexera_wave_bottom_offset">40dp</dimen>
<dimen name="flexera_wave_vertical_margin">16dp</dimen>
<!-- How far beyond the finger the wave's peak sits, so the thumb never covers the selected
letter. A thumb hides about 11 mm (70dp) to each side of the point it touches; the
@@ -28,11 +37,20 @@
<dimen name="flexera_wave_finger_gap">104dp</dimen>
<!-- The smallest bulge, used when the finger is right on the edge. -->
<dimen name="flexera_wave_min_reach">96dp</dimen>
<!-- How far up and down the edge the bulge spreads, at the least. A taller wave is wider
(WaveAlphabetView.WAVE_WIDTH_SHARE_OF_REACH). -->
<dimen name="flexera_wave_width">68dp</dimen>
<!-- The wave's peak never comes closer than this to the opposite edge of the screen. -->
<dimen name="flexera_wave_far_margin">36dp</dimen>
<!-- How far up and down the edge the bulge spreads, at the least: wide, so that about five
letters on each side of the finger ride the wave and curve around the thumb, as in
Niagara Launcher. A taller wave is wider (WaveAlphabetView.WAVE_WIDTH_SHARE_OF_REACH). -->
<dimen name="flexera_wave_width">104dp</dimen>
<!-- The wave's peak never comes closer than this to the opposite edge of the screen. It
leaves room for the bubble beyond the peak. -->
<dimen name="flexera_wave_far_margin">66dp</dimen>
<!-- The bubble with the selected letter: its size, how far beyond the wave's peak its
centre sits, and how close it may come to a screen edge. -->
<dimen name="flexera_wave_bubble_radius">20dp</dimen>
<dimen name="flexera_wave_bubble_gap">38dp</dimen>
<dimen name="flexera_wave_bubble_text_size">22sp</dimen>
<dimen name="flexera_wave_bubble_edge_margin">6dp</dimen>
<dimen name="flexera_wave_bubble_shadow_radius">6dp</dimen>
<!-- Swipe up and hold (PRD AD-17): a pause only counts once the swipe has travelled this far.
Enough to tell a hold from a hesitant start, short enough that a small swipe counts. -->
@@ -42,7 +60,7 @@
<dimen name="flexera_drawer_text_shadow_radius">3dp</dimen>
<dimen name="flexera_drawer_text_shadow_dy">1dp</dimen>
<dimen name="flexera_drawer_header_height">44dp</dimen>
<dimen name="flexera_drawer_header_padding_bottom">6dp</dimen>
<dimen name="flexera_drawer_header_text_size">14sp</dimen>
<dimen name="flexera_drawer_header_height">34dp</dimen>
<dimen name="flexera_drawer_header_padding_bottom">4dp</dimen>
<dimen name="flexera_drawer_header_text_size">12sp</dimen>
</resources>
+3
View File
@@ -27,6 +27,9 @@
<string name="flexera_about_built_on_title">Lawnchair</string>
<string name="flexera_about_built_on_body">Flexera Launcher is a modified version of Lawnchair, the open-source launcher made by the Lawnchair team. Lawnchair is itself based on Launcher3 from the Android Open Source Project.</string>
<string name="flexera_about_lawnchair_source">Lawnchair source code</string>
<string name="flexera_about_share">Share</string>
<string name="flexera_about_share_title">Scan to get Flexera Launcher</string>
<string name="flexera_about_share_qr_description">QR code that opens the download page</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>
@@ -17,6 +17,7 @@ import com.android.launcher3.R
import com.android.launcher3.allapps.ActivityAllAppsContainerView
import com.android.launcher3.allapps.BaseAllAppsAdapter.AdapterItem
import com.android.launcher3.views.BaseDragLayer
import kotlin.math.max
/**
* Flexera Launcher's app drawer: a single-column alphabetical list with letter headers
@@ -90,9 +91,15 @@ object FlexeraDrawer {
// Other icons share the "all apps" display type (search results, for one); this tag
// tells list rows apart from them.
icon.setTag(R.id.flexera_drawer_row, true)
icon.layoutParams.height = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height)
val textSize = res.getDimension(R.dimen.flexera_drawer_row_text_size)
// The row has to hold the name with a notification preview under it (PRD AD-11). At the
// usual font size the set height does; with a large system font the row grows instead.
icon.layoutParams.height = max(
res.getDimensionPixelSize(R.dimen.flexera_drawer_row_height),
(textSize * ROW_HEIGHT_IN_TEXT_SIZES).toInt(),
)
icon.compoundDrawablePadding = res.getDimensionPixelSize(R.dimen.flexera_drawer_row_icon_gap)
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, res.getDimension(R.dimen.flexera_drawer_row_text_size))
icon.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
applySidePadding(icon)
// The drawer has no background, so names sit directly on the dimmed wallpaper: white,
// with a soft shadow to stay readable over light areas.
@@ -108,6 +115,16 @@ object FlexeraDrawer {
/** Shadow behind white drawer text. */
const val TEXT_SHADOW_COLOR = 0x73000000
/** The least a row's height may be, in name text sizes: two lines of text and some air. */
private const val ROW_HEIGHT_IN_TEXT_SIZES = 2.4f
/**
* What lies between the open drawer and the blurred wallpaper: black at 78%, whatever the
* theme and the wallpaper. Lawnchair's backdrop is a grey at 40%, which over a light
* wallpaper leaves a light grey behind the drawer's white names.
*/
const val BACKDROP_COLOR = 0xC7000000.toInt()
/**
* Lawnchair fades the search field's pill out while the owner types, leaving the text on the
* drawer's panel. This drawer has no panel, so the pill stays.
@@ -246,6 +263,12 @@ object FlexeraDrawer {
return offset
}
/**
* A scrub that started from a swipe and ends sooner than this was not meant as one: nobody
* reads a letter and means it in a fifth of a second. The swipe opens the plain list.
*/
const val MIN_SCRUB_MS = 200L
/** Ends a scrub started by [scrubFromSwipe]; the drawer stays open at the chosen letter. */
@JvmStatic
fun endScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
@@ -253,8 +276,8 @@ object FlexeraDrawer {
}
/**
* Ends a scrub started by [scrubFromSwipe] and undoes it: the list goes back to where it
* stood, so the drawer opens the way an ordinary swipe up leaves it.
* Ends a scrub started by [scrubFromSwipe] and undoes it, so the drawer opens the way an
* ordinary swipe up leaves it.
*/
@JvmStatic
fun cancelScrubFromSwipe(appsView: ActivityAllAppsContainerView<*>) {
@@ -1,5 +1,6 @@
package app.flexera.drawer
import android.os.SystemClock
import android.view.MotionEvent
import android.view.ViewConfiguration
import app.lawnchair.preferences.PreferenceManager
@@ -16,7 +17,9 @@ import kotlin.math.abs
* touch. Nothing is drawn on the home screen; the letters appear with the drawer.
*
* The edge is shared with page swipes, taps and item drags, so this claims a touch only once it
* has clearly moved up or down, and never while an item is being dragged. It is off by default.
* has clearly moved up or down, and never while an item is being dragged. A touch that lifts
* again within [FlexeraDrawer.MIN_SCRUB_MS] was a flick, not a scrub, and opens the plain list.
* The setting is on unless the owner switches it off.
*/
class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchController {
@@ -26,6 +29,7 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
/** The current touch started on the edge and may still turn into a scrub. */
private var candidate = false
private var scrubbing = false
private var scrubStartTime = 0L
private var downX = 0f
private var downY = 0f
@@ -48,7 +52,11 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
dy > touchSlop && dy > dx * VERTICAL_BIAS -> {
candidate = false
scrubbing = true
launcher.stateManager.goToState(LauncherState.ALL_APPS)
scrubStartTime = SystemClock.uptimeMillis()
// The drawer starts opening and the alphabet takes the finger in the same
// moment. Opening comes first, because its start is when the drawer
// puts its list in place, which must not undo the scrub's first jump.
openDrawer()
FlexeraDrawer.scrubFromSwipe(launcher.appsView, launcher.dragLayer, ev)
return true
}
@@ -68,12 +76,28 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
scrubbing = false
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
if (SystemClock.uptimeMillis() - scrubStartTime < FlexeraDrawer.MIN_SCRUB_MS) {
// A flick up that happened to start on the edge: open the plain list.
FlexeraDrawer.cancelScrubFromSwipe(launcher.appsView)
} else {
FlexeraDrawer.endScrubFromSwipe(launcher.appsView)
}
}
}
return true
}
/**
* Opens the drawer in [OPEN_MS]. Asking the state manager to go there would take the
* drawer's own 600 ms, far too slow for a list that the finger is already scrubbing.
*/
private fun openDrawer() {
launcher.stateManager.createAnimationToNewWorkspace(LauncherState.ALL_APPS, OPEN_MS).apply {
dispatchOnStart()
start()
}
}
private fun canStart(ev: MotionEvent): Boolean =
prefs.flexeraScrubFromHome.get() &&
(ev.edgeFlags and Utilities.EDGE_NAV_BAR) == 0 &&
@@ -86,5 +110,8 @@ class FlexeraEdgeScrubController(private val launcher: Launcher) : TouchControll
private companion object {
/** A slide counts as vertical when it has moved this many times further up or down than sideways. */
const val VERTICAL_BIAS = 2f
/** How long the drawer takes to open under a scrub; the same as after swipe up and hold. */
const val OPEN_MS = 120L
}
}
@@ -6,9 +6,7 @@ import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.Rect
import android.graphics.Shader
import android.graphics.Typeface
import android.os.Build
import android.os.Bundle
@@ -26,6 +24,8 @@ import androidx.core.view.doOnNextLayout
import androidx.customview.widget.ExploreByTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import com.android.launcher3.R
import com.android.launcher3.allapps.ActivityAllAppsContainerView
import kotlin.math.abs
@@ -39,11 +39,14 @@ import kotlin.math.roundToInt
*
* Only letters that have apps are shown. Touching the strip and sliding along it jumps the list
* to the letter under the finger, with a haptic tick on every change. While the finger is down,
* the letters near it bulge away from the edge in a wave. The wave's peak, where the current
* letter is, stays a thumb's width beyond the finger however far in from the edge the finger
* is, so the letter always sits beside the thumb instead of under it.
* the letters near it bulge away from the edge in a wide wave that curves around the thumb.
* The wave's peak, where the current letter is, stays a thumb's width beyond the finger however
* far in from the edge the finger is, and just beyond the peak a bubble shows that letter
* large. The look follows Niagara Launcher's alphabet, the owner's reference.
*
* The strip sits on the right edge unless the owner moves it to the left (PRD AD-15).
* The strip sits on the right edge unless the owner moves it to the left (PRD AD-15), and low
* on that edge, with its letters close together, so that the first letter is as easy to reach
* as the last.
*
* The view covers the whole drawer so it has room to draw the wave, but it only takes touches
* that start on the strip; every other touch falls through to the list underneath.
@@ -68,6 +71,7 @@ class WaveAlphabetView(
private val edgeInset = res.getDimension(R.dimen.flexera_wave_edge_inset)
private val maxSpacing = res.getDimension(R.dimen.flexera_wave_max_spacing)
private val verticalMargin = res.getDimension(R.dimen.flexera_wave_vertical_margin)
private val bottomOffset = res.getDimension(R.dimen.flexera_wave_bottom_offset)
private val fingerGap = res.getDimension(R.dimen.flexera_wave_finger_gap)
private val minReach = res.getDimension(R.dimen.flexera_wave_min_reach)
private val waveWidth = res.getDimension(R.dimen.flexera_wave_width)
@@ -94,21 +98,26 @@ class WaveAlphabetView(
}
private val boldTypeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
/**
* A soft dark spot behind the selected letter. Out over the list the letter can land on an
* app name, and on a light wallpaper white on white; the spot keeps it readable on both.
* Drawn as a circle of radius 1 and scaled to size, so nothing is allocated while drawing.
*/
private val backdropPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = RadialGradient(
// The bubble that shows the selected letter large, beyond the wave's peak: a light disc
// with a dark letter, the one thing on the dark drawer that is not white on dark.
private val bubbleRadius = res.getDimension(R.dimen.flexera_wave_bubble_radius)
private val bubbleGap = res.getDimension(R.dimen.flexera_wave_bubble_gap)
private val bubbleTextSize = res.getDimension(R.dimen.flexera_wave_bubble_text_size)
private val bubbleEdgeMargin = res.getDimension(R.dimen.flexera_wave_bubble_edge_margin)
private val bubblePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = BUBBLE_COLOR
setShadowLayer(
res.getDimension(R.dimen.flexera_wave_bubble_shadow_radius),
0f,
0f,
1f,
intArrayOf(BACKDROP_COLOR, BACKDROP_COLOR, Color.TRANSPARENT),
floatArrayOf(0f, BACKDROP_SOLID_SHARE, 1f),
Shader.TileMode.CLAMP,
res.getDimension(R.dimen.flexera_drawer_text_shadow_dy),
BUBBLE_SHADOW_COLOR,
)
}
private val bubbleTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = BUBBLE_TEXT_COLOR
textAlign = Paint.Align.CENTER
typeface = boldTypeface
}
private var letters: List<Letter> = emptyList()
private var letterYs = FloatArray(0)
@@ -125,10 +134,6 @@ class WaveAlphabetView(
private var fingerY = 0f
private var selected = -1
/** Where the list stood when the scrub began (first visible row and its offset), for [cancelScrub]. */
private var listPositionBefore = RecyclerView.NO_POSITION
private var listOffsetBefore = 0
/** 0 when the strip is at rest, 1 when the wave is fully out. Animated on touch down and up. */
private var press = 0f
private var pressAnimator: ValueAnimator? = null
@@ -199,8 +204,14 @@ class WaveAlphabetView(
} else {
val spacing = min(maxSpacing, (availableBottom - availableTop) / count)
val blockHeight = spacing * count
// Few letters: keep the block in the middle of the edge, where a thumb rests.
stripTop = availableTop + (availableBottom - availableTop - blockHeight) / 2f
// The stretch of edge a whole alphabet would take sits low, where a thumb reaches
// without stretching: its end a little above the navigation bar, its start
// wherever that puts it. The letters there are sit in the middle of that stretch,
// so that a short alphabet is in the middle of the thumb's reach and not at its
// lower end. Only letters that need the whole edge reach up to where the list starts.
val zoneHeight = max(blockHeight, min(availableBottom - availableTop, spacing * FULL_ALPHABET))
val zoneBottom = availableBottom - min(bottomOffset, availableBottom - availableTop - zoneHeight)
stripTop = zoneBottom - zoneHeight + (zoneHeight - blockHeight) / 2f
stripBottom = stripTop + blockHeight
for (i in 0 until count) {
letterYs[i] = stripTop + spacing * (i + 0.5f)
@@ -255,15 +266,17 @@ class WaveAlphabetView(
val maxReach = max(minReach + 1f, width - edgeInset - farMargin)
val fullReach = (fingerDistance + fingerGap).coerceIn(minReach, maxReach)
val reach = fullReach * press
// A taller wave is also a wider one. It keeps its shape that way, and the letters next
// to the selected one bend around the thumb instead of vanishing under it.
// The wave is wide: several letters on each side of the finger ride it, so they curve
// around the whole thumb and stay readable above and below it. A wave that reaches
// far out is wider still, which keeps its shape.
val spread = max(waveWidth, fullReach * WAVE_WIDTH_SHARE_OF_REACH)
// Darken the list under the wave where its letters cross icons and names. From the
// right edge that only happens once the wave reaches well into the screen, as it does
// after swipe up and hold; from the left edge the icons sit right beside the strip.
// Darken the list a little while scrubbing, so the eye goes to the letters, and more
// where the wave lies over icons and names: from the right edge that is once it reaches
// well into the screen, as after swipe up and hold; from the left edge it is at once.
if (press > 0f) {
val overList = if (onLeft) 1f else ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
val reachShare = ((fullReach - minReach) / (maxReach - minReach)).coerceIn(0f, 1f)
val overList = if (onLeft) 1f else SCRUB_DIM_AT_EDGE + (1f - SCRUB_DIM_AT_EDGE) * reachShare
val dim = (SCRUB_DIM * press * overList * 255).toInt()
if (dim > 0) canvas.drawColor(Color.argb(dim, 0, 0, 0))
}
@@ -273,8 +286,9 @@ class WaveAlphabetView(
val nearness = if (press > 0f) bell((y - fingerY) / spread) else 0f
val isSelected = i == selected && press > 0f
val scale = 1f + (if (isSelected) SELECTED_GROWTH else NEAR_GROWTH) * nearness * press
paint.textSize = restingLetterSize * scale
// Letters keep their size in the wave, so close neighbours never collide; the
// selected one is bold and a touch larger. Its large copy is in the bubble.
paint.textSize = restingLetterSize * (1f + (if (isSelected) SELECTED_GROWTH * press else 0f))
paint.typeface = if (isSelected) boldTypeface else Typeface.DEFAULT
paint.color = if (isSelected) {
activeColor
@@ -283,19 +297,24 @@ class WaveAlphabetView(
}
val x = baseX + inward * reach * nearness
if (isSelected) {
val radius = paint.textSize * BACKDROP_RADIUS_IN_LETTER_SIZES
backdropPaint.alpha = (press * 255).toInt()
canvas.save()
canvas.translate(x, y)
canvas.scale(radius, radius)
canvas.drawCircle(0f, 0f, 1f, backdropPaint)
canvas.restore()
}
// Centre the glyph on the letter's line.
val baseline = y - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(letters[i].label, x, baseline, paint)
}
// The selected letter, large, in a bubble just beyond the wave's peak and level with
// the finger: the furthest thing from the thumb, so the thumb cannot hide it.
if (press > 0f && selected in letters.indices) {
val edgeRoom = bubbleRadius + bubbleEdgeMargin
val cx = (baseX + inward * (reach + bubbleGap * press)).coerceIn(edgeRoom, max(edgeRoom, width - edgeRoom))
val alpha = (press * 255).toInt()
bubblePaint.alpha = alpha
canvas.drawCircle(cx, fingerY, bubbleRadius * press, bubblePaint)
bubbleTextPaint.alpha = alpha
bubbleTextPaint.textSize = bubbleTextSize * press
val baseline = fingerY - (bubbleTextPaint.descent() + bubbleTextPaint.ascent()) / 2f
canvas.drawText(letters[selected].label, cx, baseline, bubbleTextPaint)
}
}
private fun bell(distance: Float): Float = exp(-distance * distance)
@@ -348,33 +367,29 @@ class WaveAlphabetView(
if (letters.isEmpty()) return
scrubbing = true
selected = -1
rememberListPosition()
animatePress(1f, PRESS_IN_MS)
moveScrub(x, y)
}
private fun rememberListPosition() {
listPositionBefore = RecyclerView.NO_POSITION
val rv = appsView.activeRecyclerView ?: return
val layoutManager = rv.layoutManager as? LinearLayoutManager ?: return
val position = layoutManager.findFirstVisibleItemPosition()
if (position == RecyclerView.NO_POSITION) return
listPositionBefore = position
listOffsetBefore = (layoutManager.findViewByPosition(position)?.top ?: rv.paddingTop) - rv.paddingTop
}
/**
* Ends the scrub and puts the list back where it stood before it, as if the scrub had not
* happened. For a scrub that turns out to have been started by mistake.
* Ends a scrub that began with the drawer closed and undoes it: the list goes to where a
* drawer opened by a plain swipe stands, which is the top unless the owner has the drawer
* remember its place. For a scrub that turns out to have been started by mistake.
*
* The list is not asked where it stood before the scrub. A closed drawer has been told to
* go back to the top but lays its rows out again only when it opens, so until then the
* rows still report the place the drawer was left at.
*/
fun cancelScrub() {
if (!scrubbing) return
endScrub()
if (listPositionBefore == RecyclerView.NO_POSITION) return
val rv = appsView.activeRecyclerView ?: return
rv.stopScroll()
(rv.layoutManager as? LinearLayoutManager)
?.scrollToPositionWithOffset(listPositionBefore, listOffsetBefore)
if (PreferenceManager2.getInstance(context).rememberPosition.firstCached()) {
rv.restoreScrollPosition()
} else {
rv.scrollToTop()
}
}
/** Moves the finger of a scrub started with [beginScrub]. */
@@ -520,23 +535,26 @@ class WaveAlphabetView(
}
private companion object {
/** How much larger than its resting size the selected letter grows. */
const val SELECTED_GROWTH = 1.5f
/** How many letters a whole alphabet has; the stretch of edge kept for the strip is sized for it. */
const val FULL_ALPHABET = 26
/** How much its neighbours grow, tapering with distance from the finger. */
const val NEAR_GROWTH = 0.5f
/** How much larger than its resting size the selected letter is in the wave itself. */
const val SELECTED_GROWTH = 0.25f
/** The spot behind the selected letter: black at 60%, solid to this share of its radius. */
const val BACKDROP_COLOR = 0x99000000.toInt()
const val BACKDROP_SOLID_SHARE = 0.55f
const val BACKDROP_RADIUS_IN_LETTER_SIZES = 1.15f
/** The bubble: a near-white disc, its letter near-black, a soft shadow under it. */
const val BUBBLE_COLOR = 0xFFF1F1F4.toInt()
const val BUBBLE_TEXT_COLOR = 0xFF17181C.toInt()
const val BUBBLE_SHADOW_COLOR = 0x66000000
/** The wave's spread up and down the edge, as a share of how far it reaches out. */
const val WAVE_WIDTH_SHARE_OF_REACH = 0.33f
const val WAVE_WIDTH_SHARE_OF_REACH = 0.36f
/** How dark the list gets under a wave that lies fully over it (0 = unchanged, 1 = black). */
const val SCRUB_DIM = 0.55f
/** The share of [SCRUB_DIM] applied when the wave stays at the right edge, clear of the list. */
const val SCRUB_DIM_AT_EDGE = 0.35f
/** The tallest a resting letter may be, as a share of the vertical room it has. */
const val MAX_LETTER_SHARE_OF_SLOT = 0.85f
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.text.format.Formatter
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -30,9 +32,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -54,6 +61,9 @@ import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
import app.lawnchair.ui.preferences.navigation.AboutLicenses
import com.android.launcher3.BuildConfig
import com.android.launcher3.R
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder
import kotlin.math.floor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -62,6 +72,12 @@ private const val LAWNCHAIR_SOURCE_URL = "https://github.com/LawnchairLauncher/l
/** Release notes longer than this are cut off; the full text is on the release page. */
private const val MAX_NOTES_LENGTH = 600
/** Large enough to scan from another phone held at arm's length. */
private val QR_CODE_SIZE = 220.dp
/** The blank border around a QR code, in modules; the standard asks for four. */
private const val QR_QUIET_ZONE_MODULES = 4
/**
* Flexera Launcher's About screen.
*
@@ -178,6 +194,9 @@ fun FlexeraAbout(modifier: Modifier = Modifier) {
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_update_heading)) }
groupRows(updateRows)
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_share)) }
item { PreferenceGroupItem { ShareCard(FlexeraUpdater.RELEASES_PAGE_URL) } }
item { PreferenceGroupHeading(stringResource(id = R.string.flexera_about_built_on)) }
groupRows(
listOf<@Composable () -> Unit>(
@@ -295,6 +314,71 @@ private fun UpdateStatusRow(state: UpdateState) {
}
}
/**
* What the owner holds up for someone who wants the app: a QR code that opens the release page,
* with the address under it for anyone who would rather type it.
*/
@Composable
private fun ShareCard(url: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCode(
text = url,
description = stringResource(id = R.string.flexera_about_share_qr_description),
modifier = Modifier.size(QR_CODE_SIZE),
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.flexera_about_share_title),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(4.dp))
Text(
text = url.removePrefix("https://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/**
* A QR code for [text], made on the phone. It is black on white in every theme, with the blank
* border the standard asks for, because that is what scanners read most reliably.
*/
@Composable
private fun QrCode(text: String, description: String, modifier: Modifier = Modifier) {
val modules = remember(text) { Encoder.encode(text, ErrorCorrectionLevel.M).matrix }
Canvas(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.semantics { contentDescription = description },
) {
drawRect(Color.White)
// Whole pixels per module, so neighbouring modules join without a seam.
val count = modules.width + 2 * QR_QUIET_ZONE_MODULES
val module = floor(size.minDimension / count)
val left = floor((size.width - module * count) / 2f) + module * QR_QUIET_ZONE_MODULES
val top = floor((size.height - module * count) / 2f) + module * QR_QUIET_ZONE_MODULES
for (y in 0 until modules.height) {
for (x in 0 until modules.width) {
if (modules.get(x, y).toInt() == 1) {
drawRect(
color = Color.Black,
topLeft = Offset(left + x * module, top + y * module),
size = Size(module, module),
)
}
}
}
}
}
@Composable
private fun downloadSizeText(context: Context, release: FlexeraRelease): String =
if (release.apkSize > 0) {
+2
View File
@@ -128,6 +128,8 @@ coil-compose = "io.coil-kt:coil-compose:2.7.0"
google-material = "com.google.android.material:material:1.14.0"
hoko-blur = "io.github.hokofly:hoko-blur:1.5.5"
javax-inject = "javax.inject:javax.inject:1"
# Flexera Launcher: QR code on the About screen (app.flexera.ui.FlexeraAbout).
zxing-core = "com.google.zxing:core:3.5.3"
kdrag0n-colorkt = "dev.kdrag0n:colorkt:1.0.5"
leakcanary-android = "com.squareup.leakcanary:leakcanary-android:2.14"
leakcanary-androidstudio = "com.android.tools.studio.leakcanary:leakcanary:1.0.0"
@@ -218,7 +218,8 @@ class PreferenceManager @Inject constructor(
val flexeraAlphabetOnLeft = BoolPref("pref_flexeraAlphabetOnLeft", false, recreate)
// Flexera Launcher: whether a slide along the home screen's edge opens the drawer at a letter (PRD AD-10).
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", false)
// On unless switched off, by the owner's decision of 12 November 2026.
val flexeraScrubFromHome = BoolPref("pref_flexeraScrubFromHome", true)
// Flexera Launcher: whether drawer rows show the app's latest notification under its name (PRD AD-11).
val flexeraNotificationPreviews = BoolPref("pref_flexeraNotificationPreviews", true, recreate)
@@ -3,6 +3,7 @@ package app.lawnchair.views
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import app.flexera.drawer.FlexeraDrawer
import app.lawnchair.preferences.PreferenceManager
import com.android.launcher3.R
import com.android.launcher3.util.SystemUiController
@@ -24,7 +25,10 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
val threshold = STATUS_BAR_COLOR_FORCE_UPDATE_THRESHOLD
val forceChange = visibility == VISIBLE &&
alpha > threshold &&
Color.alpha(mBackgroundColor) / (255f * drawerOpacity) > threshold
// Flexera Launcher: the scrim is at full strength when it reaches the alpha of the
// drawer's backdrop. Lawnchair measured against its drawer opacity setting, which
// is 0 here.
Color.alpha(mBackgroundColor) / Color.alpha(FlexeraDrawer.BACKDROP_COLOR).toFloat() > threshold
with(systemUiController) {
if (forceChange) {
updateUiState(SystemUiController.UI_STATE_SCRIM_VIEW, !isScrimDark)
@@ -34,9 +38,8 @@ class LawnchairScrimView(context: Context, attrs: AttributeSet?) : ScrimView(con
}
}
override fun isScrimDark() = if (drawerOpacity <= 0.3f) {
!Themes.getAttrBoolean(context, R.attr.isWorkspaceDarkText)
} else {
super.isScrimDark()
}
// Flexera Launcher: judged by the scrim's own colour, as Launcher3 does. Lawnchair went by
// the wallpaper here because its drawer scrim could be see-through; this drawer's backdrop
// is always dark, and the status bar icons over it have to be light.
override fun isScrimDark() = super.isScrimDark()
}
@@ -219,6 +219,9 @@ public class AllAppsState extends LauncherState {
} else {
backgroundColor = ColorTokens.AllAppsScrimColor.resolveColor(launcher);
}
// Flexera Launcher: the drawer's backdrop is dark whatever the theme and the wallpaper,
// so that the white names on it stay readable.
backgroundColor = app.flexera.drawer.FlexeraDrawer.BACKDROP_COLOR;
return new ScrimColors(backgroundColor, /* foregroundColor */ Color.TRANSPARENT);
}
}
@@ -98,7 +98,7 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch
private static final long FLEXERA_HOLD_STILL_MS = 150;
// A finger that lifts sooner than this after the hold was noticed was not holding; see
// onDragEnd. Too short a time to have read the letter and meant it.
private static final long FLEXERA_MIN_HOLD_MS = 200;
private static final long FLEXERA_MIN_HOLD_MS = app.flexera.drawer.FlexeraDrawer.MIN_SCRUB_MS;
private long mFlexeraScrubStartTime;
private boolean mStartedOverview;
private boolean mReachedOverview;