From 8aba905d9caa60a9fc30a6b867f29fe5ac21d423 Mon Sep 17 00:00:00 2001 From: TMC Date: Thu, 12 Nov 2026 09:00:22 +0100 Subject: [PATCH] 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 --- build.gradle | 2 + flexera/res/values/flexera_dimens.xml | 50 ++++-- flexera/res/values/flexera_strings.xml | 3 + .../src/app/flexera/drawer/FlexeraDrawer.kt | 31 +++- .../drawer/FlexeraEdgeScrubController.kt | 33 +++- .../app/flexera/drawer/WaveAlphabetView.kt | 150 ++++++++++-------- flexera/src/app/flexera/ui/FlexeraAbout.kt | 84 ++++++++++ gradle/libs.versions.toml | 2 + .../preferences/PreferenceManager.kt | 3 +- .../app/lawnchair/views/LawnchairScrimView.kt | 15 +- .../uioverrides/states/AllAppsState.java | 3 + ...ButtonNavbarToOverviewTouchController.java | 2 +- 12 files changed, 281 insertions(+), 97 deletions(-) diff --git a/build.gradle b/build.gradle index e4b1f1050c..1496f55424 100644 --- a/build.gradle +++ b/build.gradle @@ -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 diff --git a/flexera/res/values/flexera_dimens.xml b/flexera/res/values/flexera_dimens.xml index 4eb586322b..6d9cf48337 100644 --- a/flexera/res/values/flexera_dimens.xml +++ b/flexera/res/values/flexera_dimens.xml @@ -1,11 +1,13 @@ - - 64dp - - 46dp - 20dp - 17sp + + 50dp + + 34dp + 16dp + 15sp 24dp 16dp 12sp - - 30dp + + 20dp + + 40dp 16dp 96dp - - 68dp - - 36dp + + 104dp + + 66dp + + 20dp + 38dp + 22sp + 6dp + 6dp @@ -42,7 +60,7 @@ 3dp 1dp - 44dp - 6dp - 14sp + 34dp + 4dp + 12sp diff --git a/flexera/res/values/flexera_strings.xml b/flexera/res/values/flexera_strings.xml index 03b8803898..f07f4bf204 100644 --- a/flexera/res/values/flexera_strings.xml +++ b/flexera/res/values/flexera_strings.xml @@ -27,6 +27,9 @@ Lawnchair 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. Lawnchair source code + Share + Scan to get Flexera Launcher + QR code that opens the download page Privacy Nothing about you leaves your phone 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. diff --git a/flexera/src/app/flexera/drawer/FlexeraDrawer.kt b/flexera/src/app/flexera/drawer/FlexeraDrawer.kt index c52927f8d8..db104b334c 100644 --- a/flexera/src/app/flexera/drawer/FlexeraDrawer.kt +++ b/flexera/src/app/flexera/drawer/FlexeraDrawer.kt @@ -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<*>) { diff --git a/flexera/src/app/flexera/drawer/FlexeraEdgeScrubController.kt b/flexera/src/app/flexera/drawer/FlexeraEdgeScrubController.kt index f16b114275..97b26fcfaa 100644 --- a/flexera/src/app/flexera/drawer/FlexeraEdgeScrubController.kt +++ b/flexera/src/app/flexera/drawer/FlexeraEdgeScrubController.kt @@ -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 } } diff --git a/flexera/src/app/flexera/drawer/WaveAlphabetView.kt b/flexera/src/app/flexera/drawer/WaveAlphabetView.kt index b48f23a476..876d3835de 100644 --- a/flexera/src/app/flexera/drawer/WaveAlphabetView.kt +++ b/flexera/src/app/flexera/drawer/WaveAlphabetView.kt @@ -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 = 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 diff --git a/flexera/src/app/flexera/ui/FlexeraAbout.kt b/flexera/src/app/flexera/ui/FlexeraAbout.kt index 5e8c9a5e48..7d26aa49ba 100644 --- a/flexera/src/app/flexera/ui/FlexeraAbout.kt +++ b/flexera/src/app/flexera/ui/FlexeraAbout.kt @@ -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) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8458a9912e..cde6033d53 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt b/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt index 809da92a05..a6b5912141 100644 --- a/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt +++ b/lawnchair/src/app/lawnchair/preferences/PreferenceManager.kt @@ -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) diff --git a/lawnchair/src/app/lawnchair/views/LawnchairScrimView.kt b/lawnchair/src/app/lawnchair/views/LawnchairScrimView.kt index 10987a53b1..a95849f593 100644 --- a/lawnchair/src/app/lawnchair/views/LawnchairScrimView.kt +++ b/lawnchair/src/app/lawnchair/views/LawnchairScrimView.kt @@ -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() } diff --git a/quickstep/src/com/android/launcher3/uioverrides/states/AllAppsState.java b/quickstep/src/com/android/launcher3/uioverrides/states/AllAppsState.java index cb114c40ed..561d430648 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/states/AllAppsState.java +++ b/quickstep/src/com/android/launcher3/uioverrides/states/AllAppsState.java @@ -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); } } diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java index 0548a39846..0438abf3c5 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java +++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java @@ -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;